Files
accounted/components/settings/AutoLogoutToggle.tsx
T
Mattsson 11995b1b0c 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>
2026-08-12 16:45:41 +02:00

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>
)
}