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:
Mattsson
2026-08-12 16:45:41 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2e2a64dd0a
commit 11995b1b0c
19 changed files with 702 additions and 59 deletions
+3
View File
@@ -9,9 +9,12 @@ NEXT_PUBLIC_SELF_HOSTED=true
# Session timeouts are also disabled by default for self-hosted deployments.
# Uncomment to opt into hosted-style banking-app limits (milliseconds).
# Automatic logout is additionally opt-in per user (Settings > Security);
# set NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true to enforce it for everyone.
# NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000
# NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000
# NEXT_PUBLIC_SESSION_WARNING_MS=120000
# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=false
# Optional dedicated HMAC secret; otherwise SUPABASE_SERVICE_ROLE_KEY is used.
# SESSION_TIMEOUT_SECRET=
+9 -3
View File
@@ -25,14 +25,20 @@ RECEIPT_HUNT_MODEL_ID=
RECEIPT_HUNT_MIN_CONFIDENCE=
RECEIPT_HUNT_COMPANY_IDS=
# Hosted session security defaults: 30 minutes idle, 12 hours absolute,
# with a warning 2 minutes before expiry. Set a timeout to 0 to disable that
# limit. Self-hosted deployments default both limits to 0 unless overridden.
# Session timeouts are opt-in per user (user_preferences.auto_logout, toggled
# in Settings > Security): users who have not opted in stay signed in for the
# full Supabase session lifetime. The variables below set the limits that
# apply to opted-in users: 30 minutes idle, 12 hours absolute, warning 2
# minutes before expiry. Set a timeout to 0 to disable that limit entirely.
# Self-hosted deployments default both limits to 0 unless overridden.
# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces the timeouts for every
# user regardless of their preference (emergency lever / strict deployments).
# The signing key falls back to SUPABASE_SERVICE_ROLE_KEY; set a dedicated
# random secret if session signing should rotate independently.
# NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000
# NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000
# NEXT_PUBLIC_SESSION_WARNING_MS=120000
# NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=false
# SESSION_TIMEOUT_SECRET=
# Self-hosted only: set to true when public signup is turned off in your
+1
View File
@@ -876,4 +876,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor.
[2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all.
[2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes.
[2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out.
[2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag.
+1
View File
@@ -41,6 +41,7 @@ ENV NEXT_PUBLIC_REQUIRE_MFA=__NEXT_PUBLIC_REQUIRE_MFA__
ENV NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS__
ENV NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__
ENV NEXT_PUBLIC_SESSION_WARNING_MS=__NEXT_PUBLIC_SESSION_WARNING_MS__
ENV NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__
# Keep the branding placeholder intact through prebuild's inject script so
# docker-entrypoint.sh can substitute the runtime value into public/sw.js.
ENV NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__
@@ -26,12 +26,24 @@ vi.mock('next/headers', () => ({
import { GET, POST } from '../route'
const preference = { autoLogout: false, error: null as unknown }
const supabase = {
auth: {
getClaims: vi.fn(async () => ({
data: { claims: { session_id: 'session-1' } },
})),
},
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({
maybeSingle: vi.fn(async () => ({
data: preference.error ? null : { auto_logout: preference.autoLogout },
error: preference.error,
})),
})),
})),
})),
}
describe('session heartbeat route', () => {
@@ -47,18 +59,23 @@ describe('session heartbeat route', () => {
error: null,
})
mocks.cookieValue = undefined
preference.autoLogout = false
preference.error = null
})
async function setState(args?: {
startedAt?: number
lastActivityAt?: number
sessionId?: string
autoLogout?: boolean
}) {
const state = {
...createSessionTimeoutState({
userId: 'user-1',
sessionId: args?.sessionId ?? 'session-1',
method: 'password',
// Default the opt-in to true: most cases here exercise enforcement.
autoLogout: args?.autoLogout ?? true,
now: args?.startedAt ?? Date.now(),
}),
...(args?.lastActivityAt === undefined
@@ -127,6 +144,67 @@ describe('session heartbeat route', () => {
})
})
it('reports enabled: false and never expires an opted-out session', async () => {
const now = Date.now()
// Times that would be long expired if enforcement applied.
await setState({
startedAt: now - 120_000,
lastActivityAt: now - 120_000,
autoLogout: false,
})
const response = await GET()
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
data: { enabled: false },
})
})
it('upgrades a pre-toggle cookie in place, keeping its timers', async () => {
preference.autoLogout = true
const startedAt = Date.now() - 5_000
const legacy: Record<string, unknown> = {
...createSessionTimeoutState({
userId: 'user-1',
sessionId: 'session-1',
method: 'password',
autoLogout: true,
now: startedAt,
}),
}
delete legacy.autoLogout
mocks.cookieValue =
(await signSessionTimeoutState(
legacy as Parameters<typeof signSessionTimeoutState>[0],
)) ?? undefined
const response = await GET()
expect(response.status).toBe(200)
const upgraded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
expect(upgraded).toBeTruthy()
await expect(verifySessionTimeoutState(upgraded)).resolves.toMatchObject({
startedAt,
autoLogout: true,
})
})
it('answers without persisting a snapshot when the preference read fails', async () => {
preference.error = { message: 'connection reset' }
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const response = await GET()
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
data: { enabled: false },
})
// No cookie: the unknown preference must not be baked into a snapshot.
expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)).toBeUndefined()
errorSpy.mockRestore()
})
it('passes through the existing authentication error', async () => {
mocks.requireAuth.mockResolvedValue({
user: null,
+31 -17
View File
@@ -5,8 +5,10 @@ import { requireAuth } from '@/lib/auth/require-auth'
import {
createSessionTimeoutState,
evaluateSessionTimeout,
fetchAutoLogoutPreference,
getSessionTimeoutConfig,
sessionStateMatchesUser,
sessionStateNeedsRemint,
sessionTimeoutCookieOptions,
signSessionTimeoutState,
toSessionTimeoutClientState,
@@ -71,28 +73,40 @@ async function heartbeat(updateActivity: boolean): Promise<NextResponse> {
const state = await verifySessionTimeoutState(encodedState)
const sessionId = await getSessionId(auth.supabase)
if (!state || !sessionStateMatchesUser(state, auth.user.id, sessionId)) {
// Mirror middleware initialization: a missing or session-mismatched
// cookie means the timeout state has not been established for this
// session yet, not that the session expired.
const stateMatches =
state !== null && sessionStateMatchesUser(state, auth.user.id, sessionId)
if (!state || !stateMatches || sessionStateNeedsRemint(state)) {
// Mirror middleware initialization: a missing, session-mismatched, or
// pre-toggle cookie means the timeout state has not been established
// for this session yet, not that the session expired.
const hintedMethod = cookieStore.get(SESSION_AUTH_METHOD_HINT_COOKIE)?.value
const freshState = createSessionTimeoutState({
userId: auth.user.id,
sessionId,
method: isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password',
now,
})
const autoLogout = await fetchAutoLogoutPreference(auth.supabase, auth.user.id)
const freshState = state && stateMatches
? { ...state, autoLogout: autoLogout ?? false }
: createSessionTimeoutState({
userId: auth.user.id,
sessionId,
method: isSessionAuthMethod(hintedMethod) ? hintedMethod : 'password',
autoLogout: autoLogout ?? false,
now,
})
const response = NextResponse.json({
data: toSessionTimeoutClientState(freshState, config, now),
})
response.headers.set('Cache-Control', 'no-store')
const signedFresh = await signSessionTimeoutState(freshState)
if (signedFresh) {
response.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedFresh,
sessionTimeoutCookieOptions(),
)
// Unknown preference (failed read): answer this poll without persisting
// a fail-open snapshot; the next resync retries the read (mirrors the
// middleware).
if (autoLogout !== null) {
const signedFresh = await signSessionTimeoutState(freshState)
if (signedFresh) {
response.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedFresh,
sessionTimeoutCookieOptions(),
)
}
}
return response
}
@@ -21,7 +21,9 @@ function unauthed() {
})
}
function authedForGet(row: { hide_assistant_fab: boolean } | null) {
function authedForGet(
row: { hide_assistant_fab?: boolean; auto_logout?: boolean } | null,
) {
const maybeSingle = vi.fn().mockResolvedValue({ data: row, error: null })
const supabase = {
from: vi.fn(() => ({
@@ -56,19 +58,19 @@ describe('GET /api/user/preferences', () => {
expect(res.status).toBe(401)
})
it('returns the stored preference', async () => {
authedForGet({ hide_assistant_fab: true })
it('returns the stored preferences', async () => {
authedForGet({ hide_assistant_fab: true, auto_logout: true })
const res = await GET()
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ hide_assistant_fab: true })
expect(body.data).toEqual({ hide_assistant_fab: true, auto_logout: true })
})
it('defaults to false when no preferences row exists', async () => {
authedForGet(null)
const res = await GET()
const { body } = await parseJsonResponse<{ data: unknown }>(res)
expect(body.data).toEqual({ hide_assistant_fab: false })
expect(body.data).toEqual({ hide_assistant_fab: false, auto_logout: false })
})
})
@@ -103,6 +105,48 @@ describe('PATCH /api/user/preferences', () => {
)
})
it('writes a multi-field request as one atomic upsert', async () => {
const { upsert } = authedForPatch()
const res = await PATCH(
patchRequest({ hide_assistant_fab: true, auto_logout: false })
)
expect(res.status).toBe(200)
expect(upsert).toHaveBeenCalledTimes(1)
expect(upsert).toHaveBeenCalledWith(
{ user_id: 'user-1', hide_assistant_fab: true, auto_logout: false },
{ onConflict: 'user_id' }
)
})
it('rejects an empty body with 400', async () => {
authedForPatch()
const res = await PATCH(patchRequest({}))
expect(res.status).toBe(400)
})
it('upserts auto_logout and resets the session timeout cookie', async () => {
const { upsert } = authedForPatch()
const res = await PATCH(patchRequest({ auto_logout: true }))
const { status, body } = await parseJsonResponse<{ data: unknown }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ auto_logout: true })
expect(upsert).toHaveBeenCalledWith(
{ user_id: 'user-1', auto_logout: true },
{ onConflict: 'user_id' }
)
// The signed timeout cookie caches the opt-in; a change must clear it so
// the middleware re-mints with the new preference on the next request.
const setCookie = res.headers.get('set-cookie') ?? ''
expect(setCookie).toContain('gnubok-session-timeout=;')
})
it('does not touch the session timeout cookie for unrelated preferences', async () => {
authedForPatch()
const res = await PATCH(patchRequest({ hide_assistant_fab: true }))
expect(res.status).toBe(200)
expect(res.headers.get('set-cookie')).toBeNull()
})
it('returns 500 when the upsert fails', async () => {
authedForPatch({ message: 'boom' })
const res = await PATCH(patchRequest({ hide_assistant_fab: false }))
+62 -12
View File
@@ -1,6 +1,9 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { sessionTimeoutClearCookieOptions } from '@/lib/auth/session-timeout'
import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared'
// User-level UI preferences (not company-scoped), stored on user_preferences.
// Mirrors the /api/user/locale pattern: requireAuth directly because these
@@ -8,9 +11,13 @@ import { requireAuth } from '@/lib/auth/require-auth'
const BodySchema = z
.object({
hide_assistant_fab: z.boolean(),
hide_assistant_fab: z.boolean().optional(),
auto_logout: z.boolean().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, {
message: 'At least one preference is required',
})
export async function GET() {
const { user, supabase, error } = await requireAuth()
@@ -18,12 +25,15 @@ export async function GET() {
const { data } = await supabase
.from('user_preferences')
.select('hide_assistant_fab')
.select('hide_assistant_fab, auto_logout')
.eq('user_id', user.id)
.maybeSingle()
return NextResponse.json({
data: { hide_assistant_fab: data?.hide_assistant_fab ?? false },
data: {
hide_assistant_fab: data?.hide_assistant_fab ?? false,
auto_logout: data?.auto_logout ?? false,
},
})
}
@@ -43,16 +53,56 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: 'Invalid preferences' }, { status: 400 })
}
const { error: upsertError } = await supabase
.from('user_preferences')
.upsert(
{ user_id: user.id, hide_assistant_fab: parsed.data.hide_assistant_fab },
{ onConflict: 'user_id' }
)
const { hide_assistant_fab, auto_logout } = parsed.data
if (upsertError) {
return NextResponse.json({ error: 'Could not save preference' }, { status: 500 })
// One literal upsert per accepted field combination: the phantom-column
// schema guard cannot resolve spread payloads, and a single write keeps a
// multi-field request atomic.
let upsertError: unknown = null
if (hide_assistant_fab !== undefined && auto_logout !== undefined) {
const { error } = await supabase
.from('user_preferences')
.upsert(
{ user_id: user.id, hide_assistant_fab, auto_logout },
{ onConflict: 'user_id' },
)
upsertError = error
} else if (hide_assistant_fab !== undefined) {
const { error } = await supabase
.from('user_preferences')
.upsert({ user_id: user.id, hide_assistant_fab }, { onConflict: 'user_id' })
upsertError = error
} else if (auto_logout !== undefined) {
const { error } = await supabase
.from('user_preferences')
.upsert({ user_id: user.id, auto_logout }, { onConflict: 'user_id' })
upsertError = error
}
return NextResponse.json({ data: parsed.data })
if (upsertError) {
return NextResponse.json(
{
error: getErrorMessage(upsertError, {
context: 'settings',
statusCode: 500,
}),
},
{ status: 500 },
)
}
const response = NextResponse.json({ data: parsed.data })
if (parsed.data.auto_logout !== undefined) {
// The middleware caches the opt-in inside the signed timeout cookie.
// Clearing it forces a re-mint on the next request, so the change takes
// effect immediately instead of at the next login.
response.cookies.set(
SESSION_TIMEOUT_COOKIE,
'',
sessionTimeoutClearCookieOptions(),
)
}
return response
}
+89
View File
@@ -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>
)
}
+4
View File
@@ -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>
)
}
+2
View File
@@ -95,6 +95,7 @@ if [ -n "$SUBST_PATHS" ]; then
E_SESSION_IDLE_TIMEOUT_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS:-}")
E_SESSION_ABSOLUTE_TIMEOUT_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS:-}")
E_SESSION_WARNING_MS=$(sed_esc "${NEXT_PUBLIC_SESSION_WARNING_MS:-}")
E_SESSION_TIMEOUT_FORCE_ALL=$(sed_esc "${NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL:-}")
E_BRANDING_APP_NAME=$(sed_esc "${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}")
# File-type coverage:
@@ -119,6 +120,7 @@ if [ -n "$SUBST_PATHS" ]; then
-e "s|__NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS__|${E_SESSION_IDLE_TIMEOUT_MS}|g" \
-e "s|__NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS__|${E_SESSION_ABSOLUTE_TIMEOUT_MS}|g" \
-e "s|__NEXT_PUBLIC_SESSION_WARNING_MS__|${E_SESSION_WARNING_MS}|g" \
-e "s|__NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL__|${E_SESSION_TIMEOUT_FORCE_ALL}|g" \
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${E_BRANDING_APP_NAME}|g"
fi
+15 -4
View File
@@ -93,9 +93,12 @@ CRON_SECRET=<generate with: openssl rand -hex 32>
`NEXT_PUBLIC_APP_URL` must match your public-facing URL. It is used in invoice reminder emails, calendar feed links, and PSD2 callbacks. If left as a placeholder, links will be broken.
Hosted Accounted sessions default to a 30-minute idle limit, a 12-hour absolute
limit, and a warning 2 minutes before expiry. Self-hosted installations leave
both limits disabled unless you opt in (values are milliseconds):
Automatic logout is opt-in per user: sessions only expire for users who have
enabled "Automatic logout" in Settings > Security (stored on
`user_preferences.auto_logout`, default off). For opted-in users, hosted
Accounted defaults to a 30-minute idle limit, a 12-hour absolute limit, and a
warning 2 minutes before expiry. Self-hosted installations leave both limits
disabled unless you opt in (values are milliseconds):
```bash
NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS=1800000
@@ -103,7 +106,15 @@ NEXT_PUBLIC_SESSION_ABSOLUTE_TIMEOUT_MS=43200000
NEXT_PUBLIC_SESSION_WARNING_MS=120000
```
Set either timeout to `0` to disable only that limit. Timeout state is signed
Set either timeout to `0` to disable only that limit. To enforce the timeouts
for every user regardless of their per-user preference (the pre-2026-08
behavior), also set:
```bash
NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true
```
Timeout state is signed
with `SUPABASE_SERVICE_ROLE_KEY` by default; set `SESSION_TIMEOUT_SECRET` to a
separate random value if you want to rotate it independently. Changing either
signing secret invalidates existing timeout cookies and requires users to sign
+137
View File
@@ -3,9 +3,12 @@ import {
apiRequestSkipsSessionTimeout,
createSessionTimeoutState,
evaluateSessionTimeout,
fetchAutoLogoutPreference,
getSessionTimeoutConfig,
sessionStateMatchesUser,
sessionStateNeedsRemint,
signSessionTimeoutState,
toSessionTimeoutClientState,
verifySessionTimeoutState,
} from '../session-timeout'
@@ -20,6 +23,7 @@ describe('session timeout configuration', () => {
idleTimeoutMs: 30 * 60 * 1000,
absoluteTimeoutMs: 12 * 60 * 60 * 1000,
warningMs: 2 * 60 * 1000,
enforceForAll: false,
})
})
@@ -29,6 +33,7 @@ describe('session timeout configuration', () => {
idleTimeoutMs: 0,
absoluteTimeoutMs: 0,
warningMs: 0,
enforceForAll: false,
})
})
@@ -42,6 +47,7 @@ describe('session timeout configuration', () => {
idleTimeoutMs: 60000,
absoluteTimeoutMs: 0,
warningMs: 60000,
enforceForAll: false,
})
})
@@ -55,9 +61,16 @@ describe('session timeout configuration', () => {
idleTimeoutMs: 0,
absoluteTimeoutMs: 60000,
warningMs: 10000,
enforceForAll: false,
})
})
it('reads the force-all override', () => {
expect(getSessionTimeoutConfig({
NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL: 'true',
})).toMatchObject({ enforceForAll: true })
})
it('ignores negative and non-integer overrides', () => {
expect(getSessionTimeoutConfig({
NEXT_PUBLIC_SESSION_IDLE_TIMEOUT_MS: '-1',
@@ -75,6 +88,7 @@ describe('signed session timeout state', () => {
userId: 'user-1',
sessionId: 'session-1',
method: 'bankid',
autoLogout: true,
now: 1000,
})
@@ -89,6 +103,7 @@ describe('signed session timeout state', () => {
userId: 'user-1',
sessionId: 'session-1',
method: 'password',
autoLogout: true,
now: 1000,
})
@@ -100,6 +115,7 @@ describe('signed session timeout state', () => {
userId: 'user-1',
sessionId: 'session-1',
method: 'password',
autoLogout: true,
now: 1000,
})
const signed = await signSessionTimeoutState(state, SIGNING_ENV)
@@ -118,6 +134,7 @@ describe('signed session timeout state', () => {
userId: 'user-1',
sessionId: 'session-1',
method: 'password',
autoLogout: true,
now: 1000,
})
@@ -131,12 +148,14 @@ describe('signed session timeout state', () => {
userId: 'user-1',
sessionId: 'session-1',
method: 'password',
autoLogout: true,
now: 1000,
})
const unbound = createSessionTimeoutState({
userId: 'user-1',
sessionId: null,
method: 'password',
autoLogout: true,
now: 1000,
})
@@ -152,6 +171,7 @@ describe('session expiry', () => {
idleTimeoutMs: 30_000,
absoluteTimeoutMs: 60_000,
warningMs: 10_000,
enforceForAll: false,
}
it('uses inclusive boundaries and gives absolute expiry precedence', () => {
@@ -160,6 +180,7 @@ describe('session expiry', () => {
userId: 'user-1',
sessionId: 'session-1',
method: 'password' as const,
autoLogout: true,
now: 1000,
}),
lastActivityAt: 31_000,
@@ -174,6 +195,7 @@ describe('session expiry', () => {
userId: 'user-1',
sessionId: null,
method: 'password',
autoLogout: true,
now: 1000,
})
@@ -182,6 +204,121 @@ describe('session expiry', () => {
})
})
describe('per-user opt-in gating', () => {
const config = {
enabled: true,
idleTimeoutMs: 30_000,
absoluteTimeoutMs: 60_000,
warningMs: 10_000,
enforceForAll: false,
}
function makeState(autoLogout: boolean) {
return createSessionTimeoutState({
userId: 'user-1',
sessionId: null,
method: 'password' as const,
autoLogout,
now: 1000,
})
}
it('never expires a session that has not opted in', () => {
const state = makeState(false)
// Far past both limits: still no timeout without the opt-in.
expect(evaluateSessionTimeout(state, config, 10_000_000)).toBeNull()
})
it('expires an opted-in session normally', () => {
const state = makeState(true)
expect(evaluateSessionTimeout(state, config, 61_000)).toBe('absolute')
})
it('lets enforceForAll override the opt-out', () => {
const state = makeState(false)
expect(
evaluateSessionTimeout(state, { ...config, enforceForAll: true }, 61_000),
).toBe('absolute')
})
it('treats a pre-toggle state as authentic but needing a re-mint', async () => {
const legacy = makeState(true) as { autoLogout?: boolean }
delete legacy.autoLogout
const state = legacy as ReturnType<typeof makeState>
const signed = await signSessionTimeoutState(state, SIGNING_ENV)
await expect(verifySessionTimeoutState(signed!, SIGNING_ENV)).resolves.toEqual(state)
expect(sessionStateNeedsRemint(state)).toBe(true)
expect(sessionStateNeedsRemint(makeState(false))).toBe(false)
// A legacy state is never enforced against until it has been re-minted.
expect(evaluateSessionTimeout(state, config, 10_000_000)).toBeNull()
})
it('reports the client state as enabled only when enforced', () => {
expect(toSessionTimeoutClientState(makeState(true), config, 2000).enabled).toBe(true)
expect(toSessionTimeoutClientState(makeState(false), config, 2000).enabled).toBe(false)
expect(
toSessionTimeoutClientState(
makeState(false),
{ ...config, enforceForAll: true },
2000,
).enabled,
).toBe(true)
expect(
toSessionTimeoutClientState(
makeState(true),
{ ...config, enabled: false },
2000,
).enabled,
).toBe(false)
})
})
describe('fetchAutoLogoutPreference', () => {
function client(result: { data: unknown; error: unknown } | 'throw') {
return {
from: () => ({
select: () => ({
eq: () => ({
maybeSingle: async () => {
if (result === 'throw') throw new Error('network down')
return result
},
}),
}),
}),
} as unknown as Parameters<typeof fetchAutoLogoutPreference>[0]
}
it('returns the stored opt-in', async () => {
await expect(
fetchAutoLogoutPreference(client({ data: { auto_logout: true }, error: null }), 'u1'),
).resolves.toBe(true)
await expect(
fetchAutoLogoutPreference(client({ data: { auto_logout: false }, error: null }), 'u1'),
).resolves.toBe(false)
})
it('treats a missing row as a definitive opt-out', async () => {
await expect(
fetchAutoLogoutPreference(client({ data: null, error: null }), 'u1'),
).resolves.toBe(false)
})
it('returns null (unknown) on a failed read, never a fail-open false', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(
fetchAutoLogoutPreference(client({ data: null, error: { message: 'boom' } }), 'u1'),
).resolves.toBeNull()
await expect(fetchAutoLogoutPreference(client('throw'), 'u1')).resolves.toBeNull()
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
})
describe('API exclusions', () => {
it('only lets bearer-authenticated machine surfaces bypass timeouts', () => {
expect(apiRequestSkipsSessionTimeout('/api/v1/companies/c1/invoices', true)).toBe(true)
+75 -1
View File
@@ -1,3 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import {
SESSION_TIMEOUT_COOKIE,
type SessionAuthMethod,
@@ -16,6 +17,9 @@ export interface SessionTimeoutConfig {
idleTimeoutMs: number
absoluteTimeoutMs: number
warningMs: number
// Ignore the per-user auto_logout opt-in and enforce timeouts for every
// cookie session (emergency lever / strict self-hosted deployments).
enforceForAll: boolean
}
export interface SessionTimeoutState {
@@ -25,6 +29,10 @@ export interface SessionTimeoutState {
startedAt: number
lastActivityAt: number
method: SessionAuthMethod
// Snapshot of user_preferences.auto_logout taken when the state was minted.
// Absent on cookies minted before the opt-in toggle existed: those must be
// re-minted (reading the preference), never treated as tampered.
autoLogout?: boolean
}
type Environment = Record<string, string | undefined>
@@ -64,6 +72,7 @@ export function getSessionTimeoutConfig(
idleTimeoutMs,
absoluteTimeoutMs,
warningMs,
enforceForAll: env.NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL === 'true',
}
}
@@ -71,6 +80,7 @@ export function createSessionTimeoutState(args: {
userId: string
sessionId: string | null
method: SessionAuthMethod
autoLogout: boolean
now?: number
}): SessionTimeoutState {
const now = args.now ?? Date.now()
@@ -81,6 +91,56 @@ export function createSessionTimeoutState(args: {
startedAt: now,
lastActivityAt: now,
method: args.method,
autoLogout: args.autoLogout,
}
}
/**
* Whether timeouts actually apply to this session. Auto logout is opt-in
* per user (founder decision 2026-08-12): without the opt-in snapshot, or
* the global force-all override, a session lives as long as the Supabase
* refresh token.
*/
export function sessionTimeoutEnforced(
state: SessionTimeoutState,
config: SessionTimeoutConfig,
): boolean {
return config.enabled && (state.autoLogout === true || config.enforceForAll)
}
/**
* Read the user's auto_logout opt-in. A missing row is a definitive false
* (never opted in); a FAILED read returns null, deliberately distinct from
* the opt-out path: callers must not persist a snapshot for an unknown
* preference (which would silently disable the control for an opted-in
* user for the cookie's lifetime) and instead skip minting so the next
* request retries the read. Sessions that already carry a snapshot are
* unaffected: enforcement keeps running off the signed cookie.
*/
export async function fetchAutoLogoutPreference(
supabase: SupabaseClient,
userId: string,
): Promise<boolean | null> {
try {
const { data, error } = await supabase
.from('user_preferences')
.select('auto_logout')
.eq('user_id', userId)
.maybeSingle()
if (error) {
console.error(
'[session-timeout] auto_logout preference read FAILED; enforcement undetermined for this request',
error,
)
return null
}
return data?.auto_logout === true
} catch (error) {
console.error(
'[session-timeout] auto_logout preference read FAILED; enforcement undetermined for this request',
error,
)
return null
}
}
@@ -89,6 +149,8 @@ export function evaluateSessionTimeout(
config: SessionTimeoutConfig,
now = Date.now(),
): SessionTimeoutReason | null {
if (!sessionTimeoutEnforced(state, config)) return null
if (
config.absoluteTimeoutMs > 0 &&
now - state.startedAt >= config.absoluteTimeoutMs
@@ -169,6 +231,9 @@ function isValidState(value: unknown): value is SessionTimeoutState {
Number.isSafeInteger(state.startedAt) &&
Number.isSafeInteger(state.lastActivityAt) &&
(state.method === 'password' || state.method === 'bankid') &&
// Absent on pre-toggle cookies: valid, but callers re-mint (see
// sessionStateNeedsRemint) instead of treating the cookie as forged.
(state.autoLogout === undefined || typeof state.autoLogout === 'boolean') &&
(state.startedAt as number) > 0 &&
(state.lastActivityAt as number) >= (state.startedAt as number)
)
@@ -284,7 +349,7 @@ export function toSessionTimeoutClientState(
serverNow = Date.now(),
): SessionTimeoutClientState {
return {
enabled: config.enabled,
enabled: sessionTimeoutEnforced(state, config),
idleTimeoutMs: config.idleTimeoutMs,
absoluteTimeoutMs: config.absoluteTimeoutMs,
warningMs: config.warningMs,
@@ -309,6 +374,15 @@ export function sessionStateMatchesUser(
return state.sessionId === sessionId
}
/**
* Pre-toggle cookies carry no auto_logout snapshot. They are authentic, so
* they must not hit the tampering path; callers re-mint them, reading the
* preference, so every live cookie converges on the v-with-snapshot shape.
*/
export function sessionStateNeedsRemint(state: SessionTimeoutState): boolean {
return state.autoLogout === undefined
}
export function apiRequestSkipsSessionTimeout(
pathname: string,
hasAuthorizationHeader: boolean,
+93 -2
View File
@@ -27,6 +27,9 @@ const state = vi.hoisted(() => ({
error: unknown
},
signOut: vi.fn(async () => ({ error: null })),
// Row returned for user_preferences reads (the auto_logout mint lookup).
userPreferences: null as null | { auto_logout: boolean },
userPreferencesError: null as unknown,
}))
vi.mock('@supabase/ssr', () => ({
@@ -46,13 +49,20 @@ vi.mock('@supabase/ssr', () => ({
},
},
rpc: vi.fn(async () => state.company),
from: vi.fn(() => {
from: vi.fn((table: string) => {
const chain: Record<string, unknown> = {}
const self = new Proxy(chain, {
get: (_t, prop) => {
if (prop === 'then') return undefined
if (prop === 'maybeSingle' || prop === 'single') {
return async () => ({ data: null, error: null })
return async () => ({
data:
table === 'user_preferences' && !state.userPreferencesError
? state.userPreferences
: null,
error:
table === 'user_preferences' ? state.userPreferencesError : null,
})
}
return () => self
},
@@ -102,6 +112,8 @@ describe('updateSession redirect destinations', () => {
data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }],
error: null,
}
state.userPreferences = null
state.userPreferencesError = null
delete process.env.NEXT_PUBLIC_REQUIRE_MFA
delete process.env.NEXT_PUBLIC_SELF_HOSTED
process.env.SESSION_TIMEOUT_SECRET = 'middleware-test-secret'
@@ -139,18 +151,26 @@ describe('updateSession redirect destinations', () => {
method?: 'password' | 'bankid'
userId?: string
sessionId?: string | null
autoLogout?: boolean
legacy?: boolean
}) {
const stateValue = {
...createSessionTimeoutState({
userId: args?.userId ?? 'user-1',
sessionId: args?.sessionId === undefined ? 'session-1' : args.sessionId,
method: args?.method ?? 'password',
// Default the opt-in to true: these tests exercise enforcement.
autoLogout: args?.autoLogout ?? true,
now: args?.startedAt ?? Date.now(),
}),
...(args?.lastActivityAt === undefined
? {}
: { lastActivityAt: args.lastActivityAt }),
}
if (args?.legacy) {
// Pre-toggle cookies carry no auto_logout snapshot.
delete (stateValue as { autoLogout?: boolean }).autoLogout
}
const signed = await signSessionTimeoutState(stateValue)
if (!signed) throw new Error('test signing secret missing')
return signed
@@ -233,6 +253,77 @@ describe('updateSession redirect destinations', () => {
expect((await run('/api/v1/companies/c1/invoices', { headers })).status).toBe(200)
})
it('mints the cookie with the auto_logout opt-out default for new sessions', async () => {
const response = await run('/settings/tax')
expect(response.status).toBe(200)
const encoded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
await expect(verifySessionTimeoutState(encoded)).resolves.toMatchObject({
autoLogout: false,
})
})
it('snapshots an opted-in preference at mint time', async () => {
state.userPreferences = { auto_logout: true }
const response = await run('/settings/tax')
const encoded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
await expect(verifySessionTimeoutState(encoded)).resolves.toMatchObject({
autoLogout: true,
})
})
it('persists no snapshot when the preference read fails', async () => {
state.userPreferencesError = { message: 'connection reset' }
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const response = await run('/settings/tax')
// Unknown preference: nothing minted, nobody logged out; the next
// request retries the read.
expect(response.status).toBe(200)
expect(response.cookies.get(SESSION_TIMEOUT_COOKIE)).toBeUndefined()
expect(state.signOut).not.toHaveBeenCalled()
errorSpy.mockRestore()
})
it('never logs out a session that has not opted in', async () => {
const now = Date.now()
// Far past both limits: without the opt-in the session must survive.
const encoded = await signedCookie({
startedAt: now - 600_000,
lastActivityAt: now - 600_000,
autoLogout: false,
})
const response = await run('/reports/vat', {
headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` },
})
expect(response.status).toBe(200)
expect(state.signOut).not.toHaveBeenCalled()
})
it('upgrades a pre-toggle cookie in place instead of treating it as forged', async () => {
state.userPreferences = { auto_logout: true }
const startedAt = Date.now() - 5_000
const encoded = await signedCookie({ startedAt, legacy: true })
const response = await run('/settings/tax', {
headers: { cookie: `${SESSION_TIMEOUT_COOKIE}=${encoded}` },
})
expect(response.status).toBe(200)
expect(state.signOut).not.toHaveBeenCalled()
const upgraded = response.cookies.get(SESSION_TIMEOUT_COOKIE)?.value
// Timers survive the upgrade: only the opt-in snapshot is added.
await expect(verifySessionTimeoutState(upgraded)).resolves.toMatchObject({
startedAt,
autoLogout: true,
})
})
it('starts a new timeout window when the Supabase session changes', async () => {
const encoded = await signedCookie({ sessionId: 'old-session' })
+33 -15
View File
@@ -9,8 +9,10 @@ import {
apiRequestSkipsSessionTimeout,
createSessionTimeoutState,
evaluateSessionTimeout,
fetchAutoLogoutPreference,
getSessionTimeoutConfig,
sessionStateMatchesUser,
sessionStateNeedsRemint,
sessionTimeoutClearCookieOptions,
sessionTimeoutCookieOptions,
signSessionTimeoutState,
@@ -103,9 +105,14 @@ export async function updateSession(request: NextRequest) {
)
}
const stateMatches =
verifiedState !== null &&
sessionStateMatchesUser(verifiedState, user.id, sessionId)
if (
!verifiedState ||
!sessionStateMatchesUser(verifiedState, user.id, sessionId)
!stateMatches ||
sessionStateNeedsRemint(verifiedState)
) {
const hintedMethod = request.cookies.get(
SESSION_AUTH_METHOD_HINT_COOKIE,
@@ -113,21 +120,32 @@ export async function updateSession(request: NextRequest) {
const method = isSessionAuthMethod(hintedMethod)
? hintedMethod
: 'password'
const state = createSessionTimeoutState({
userId: user.id,
sessionId,
method,
})
const signedState = await signSessionTimeoutState(state)
const autoLogout = await fetchAutoLogoutPreference(supabase, user.id)
if (signedState) {
request.cookies.set(SESSION_TIMEOUT_COOKIE, signedState)
supabaseResponse.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedState,
sessionTimeoutCookieOptions(),
)
clearAuthMethodHint(request, supabaseResponse)
// Unknown preference (failed read): mint nothing, so no fail-open
// snapshot gets persisted; the next request retries the read.
if (autoLogout !== null) {
// A matching pre-toggle cookie keeps its timers: upgrading the shape
// must not restart the absolute window.
const state = verifiedState && stateMatches
? { ...verifiedState, autoLogout }
: createSessionTimeoutState({
userId: user.id,
sessionId,
method,
autoLogout,
})
const signedState = await signSessionTimeoutState(state)
if (signedState) {
request.cookies.set(SESSION_TIMEOUT_COOKIE, signedState)
supabaseResponse.cookies.set(
SESSION_TIMEOUT_COOKIE,
signedState,
sessionTimeoutCookieOptions(),
)
clearAuthMethodHint(request, supabaseResponse)
}
}
} else {
const timeoutReason = evaluateSessionTimeout(
+6
View File
@@ -2605,6 +2605,12 @@
"link_button": "Link BankID"
},
"settings_security": {
"auto_logout_label": "Automatic logout",
"auto_logout_description": "Signs you out automatically after a period of inactivity, and at the latest after a per-session maximum. When off, you stay signed in on this browser until you sign out yourself.",
"auto_logout_switch_label": "Log me out automatically when inactive",
"auto_logout_enabled_toast": "Automatic logout enabled",
"auto_logout_disabled_toast": "Automatic logout disabled",
"auto_logout_save_failed": "Could not save the setting",
"set_password_title": "Set a password",
"set_password_description": "You signed in with BankID and have no password yet. Set one to enable 2FA or to sign in when BankID is unavailable.",
"set_password_button": "Set password",
+6
View File
@@ -2605,6 +2605,12 @@
"link_button": "Koppla BankID"
},
"settings_security": {
"auto_logout_label": "Automatisk utloggning",
"auto_logout_description": "Loggar ut dig automatiskt efter en period av inaktivitet, och senast efter en maxtid per session. Avstängd förblir du inloggad i den här webbläsaren tills du loggar ut själv.",
"auto_logout_switch_label": "Logga ut mig automatiskt vid inaktivitet",
"auto_logout_enabled_toast": "Automatisk utloggning aktiverad",
"auto_logout_disabled_toast": "Automatisk utloggning avstängd",
"auto_logout_save_failed": "Kunde inte spara inställningen",
"set_password_title": "Sätt ett lösenord",
"set_password_description": "Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt.",
"set_password_button": "Sätt lösenord",
@@ -0,0 +1,8 @@
-- Per-user opt-in automatic logout (founder-approved 2026-08-12): the hosted
-- session timeouts (idle/absolute) only apply to users who enable this.
-- Default false keeps everyone signed in for the full Supabase session
-- lifetime, the behavior from before the 2026-07 session hardening.
alter table public.user_preferences
add column if not exists auto_logout boolean not null default false;
notify pgrst, 'reload schema';