feat(auth): make automatic logout an opt-in per-user setting (#1536)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2e2a64dd0a
commit
11995b1b0c
@@ -0,0 +1,89 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { isMfaRequired } from '@/lib/auth/mfa'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { AutoLogoutToggle } from '@/components/settings/AutoLogoutToggle'
|
||||
import { BankIdSettings } from '@/components/settings/BankIdSettings'
|
||||
import { userHasPassword } from '@/lib/auth/has-password'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -327,6 +328,9 @@ export function SecuritySettings() {
|
||||
)}
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{/* Automatic logout: per-user opt-in, renders nothing on self-hosted */}
|
||||
<AutoLogoutToggle />
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user