11995b1b0c
* feat(auth): make automatic logout an opt-in per-user setting Session timeouts (30 min idle / 12 h absolute on hosted) now apply only to users who enable "Automatic logout" in Settings > Security. Default is off: sessions live for the full Supabase refresh-token lifetime, the behavior from before the 2026-07 session hardening. - user_preferences.auto_logout (migration, default false), toggled via the extended /api/user/preferences route - The opt-in is snapshotted into the signed timeout cookie at mint, so enforcement stays DB-read-free per request; the preferences route clears the cookie on change so a toggle takes effect immediately - Pre-toggle cookies are authentic-but-stale: re-minted preserving their timers, never routed down the tamper path, so the rollout does not log anyone out - NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for every user regardless of preference (emergency lever, also plumbed through the Docker image); self-hosted stays disabled by default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): resolve PR #1536 review findings - Replace the spread upsert in /api/user/preferences with one literal payload per field: the phantom-column schema guard cannot resolve spread payloads (Unit tests 3/4 ceiling failure) - Map the preferences 500 through getErrorMessage so the user-facing text is Swedish (CodeRabbit) - fetchAutoLogoutPreference now returns null on a FAILED read instead of a fail-open false: callers skip minting so an unknown preference is never persisted into the year-long signed cookie, and the next request retries; failures log at error level, distinct from the normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): write multi-field preference updates as one atomic upsert A request carrying both hide_assistant_fab and auto_logout previously issued two sequential writes, so a failure of the second returned 500 after half the request had persisted (CodeRabbit, PR #1536). One literal upsert per accepted field combination keeps the write atomic and stays resolvable for the phantom-column schema guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { SettingsRow } from '@/components/settings/SettingsRows'
|
|
|
|
// Session timeouts are a hosted concern: self-hosted deployments have them
|
|
// disabled at the config level, so the toggle would be a no-op there.
|
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
|
|
|
/**
|
|
* Per-user opt-in for automatic logout. Off by default: the session then
|
|
* lives as long as the Supabase refresh token. On: the hosted idle/absolute
|
|
* timeouts apply (enforced by the middleware via the signed timeout cookie,
|
|
* which the preferences API resets on change).
|
|
*/
|
|
export function AutoLogoutToggle() {
|
|
const t = useTranslations('settings_security')
|
|
const { toast } = useToast()
|
|
const [enabled, setEnabled] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (isSelfHosted) return
|
|
let active = true
|
|
;(async () => {
|
|
try {
|
|
const res = await fetch('/api/user/preferences', { cache: 'no-store' })
|
|
if (!res.ok) return
|
|
const payload = (await res.json()) as {
|
|
data?: { auto_logout?: boolean }
|
|
}
|
|
if (active) setEnabled(payload.data?.auto_logout === true)
|
|
} catch {
|
|
// Leave the default (off); a failed read must not block the page.
|
|
} finally {
|
|
if (active) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [])
|
|
|
|
if (isSelfHosted) return null
|
|
|
|
async function handleChange(next: boolean) {
|
|
setEnabled(next)
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetch('/api/user/preferences', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ auto_logout: next }),
|
|
})
|
|
if (!res.ok) throw new Error('Could not save')
|
|
toast({
|
|
title: next
|
|
? t('auto_logout_enabled_toast')
|
|
: t('auto_logout_disabled_toast'),
|
|
})
|
|
} catch {
|
|
setEnabled(!next)
|
|
toast({ title: t('auto_logout_save_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettingsRow
|
|
label={t('auto_logout_label')}
|
|
help={t('auto_logout_description')}
|
|
>
|
|
<Switch
|
|
id="auto-logout"
|
|
checked={enabled}
|
|
onCheckedChange={(value) => void handleChange(value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
<label htmlFor="auto-logout" className="cursor-pointer text-sm">
|
|
{t('auto_logout_switch_label')}
|
|
</label>
|
|
</SettingsRow>
|
|
)
|
|
}
|