Files
accounted/components/auth/SessionTimeoutController.tsx
Jakob Wennberg 38a890c8d1 fix(underlag): carry the phone photo that is too big to send, and say why when we cannot (#1550)
* fix(whatsapp-inbox): register the channel question event types

Every follow-up question the WhatsApp intake asks has been failing its
processing_history append in production: ChannelQuestionAsked,
ChannelQuestionAnswered and ChannelQuestionExpired were never added to
the processing_event_types catalog the event_type FK points at.

appendQuestionHistory() catches and logs that failure by design, so the
reply to the sender still goes out and nothing looked broken from the
outside. What was lost is the durable record of the exchange, which is
part of how the underlag was obtained (BFNAR 2013:2 kap 8).

Catalog rows only: aggregate_type 'System' already passes the CHECK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(underlag): say why an upload failed, and get out of an expired session

A user reported that none of the three ways to add a receipt from a
phone worked, all of them answering "Uppladdning misslyckades. Nagot
gick fel, forsok igen" immediately. Production told us nothing: every
upload request that reached the route in the same 24 hours returned 200.

Both halves of that are the same bug. The workspace read failures as
`throw new Error(json.error)`, which loses a body that is not JSON (the
res.json() call throws first) and stringifies the structured envelope to
"[object Object]", so anything the route did not answer with a plain
string arrived as the generic fallback. The middleware 401 for an
expired cookie session is exactly that envelope shape, and a phone tab
left open is exactly where the session expires unnoticed: the
controller's timers are throttled in the background, so the request the
user just made is what finds out.

Now the response is resolved where it fails, through the house helper
that already knows the status map, and an expired session is announced
on the session-timeout BroadcastChannel so the controller signs out and
routes to /login the same way it does for an expired heartbeat. Failed
uploads also post metadata (status, size, mime type, resolved reason) to
/api/log, the one API path exempt from the timeout gate, so a request
answered before the route runs stops being invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(underlag): carry the phone photo that is too big to send

The reported failure was not the account and not the session: hosted
rejects any request body over 4.5 MB itself, before the function runs.
Measured against production, 4.4 MB reaches the route and 4.6 MB comes
back as a plain-text FUNCTION_PAYLOAD_TOO_LARGE. Nothing invokes the
function, so nothing lands in the logs, which is why one user's failing
uploads were invisible while every upload that arrived returned 200. An
iPhone photo in "Most Compatible" mode is 4-12 MB, so whether it worked
depended on whose phone took the picture. Meanwhile the route advertises
a 10 MB limit it can never be handed.

Photos are now re-encoded in the browser when they exceed what the
platform will carry: 2400px on the long edge at JPEG q0.85, stepping the
quality down only if that is not enough. That keeps the small print on a
receipt legible, which is what BFL 7 kap asks of an archived underlag
("varaktigt läsbart skick", a faithful reproduction), and a refusal is
not. What cannot be shrunk (a PDF, or HEIC where the browser will not
decode it) is refused before the upload starts, naming its actual size
and the limit rather than failing in transit.

413 joins the HTTP status map so a rejection we cannot pre-empt still
says what happened: the platform's body is plain text, so the status is
the only thing there is to translate.

Self-hosted Docker has no proxy in front of the app, so none of this
applies there and the route's own MAX_FILE_SIZE keeps governing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 09:39:23 +02:00

258 lines
8.3 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import {
SESSION_TIMEOUT_CHANNEL,
SESSION_TIMEOUT_REASON_HEADER,
type SessionTimeoutClientState,
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
import { SessionTimeoutModal } from './SessionTimeoutModal'
const ACTIVITY_HEARTBEAT_INTERVAL_MS = 15_000
const SERVER_RESYNC_INTERVAL_MS = 60_000
const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart'] as const
type Warning = { reason: SessionTimeoutReason; seconds: number }
function timeoutDeadline(
state: SessionTimeoutClientState,
lastActivityAt: number,
): { at: number; reason: SessionTimeoutReason } {
const absoluteAt = state.absoluteTimeoutMs > 0
? state.startedAt + state.absoluteTimeoutMs
: Number.POSITIVE_INFINITY
const idleAt = state.idleTimeoutMs > 0
? lastActivityAt + state.idleTimeoutMs
: Number.POSITIVE_INFINITY
return absoluteAt <= idleAt
? { at: absoluteAt, reason: 'absolute' }
: { at: idleAt, reason: 'idle' }
}
export function SessionTimeoutController() {
const [warning, setWarning] = useState<Warning | null>(null)
const [isExtending, setIsExtending] = useState(false)
const stateRef = useRef<SessionTimeoutClientState | null>(null)
const lastActivityAtRef = useRef(0)
const lastHeartbeatAtRef = useRef(0)
const heartbeatInFlightRef = useRef(false)
const expiringRef = useRef(false)
const warningOpenRef = useRef(false)
const channelRef = useRef<BroadcastChannel | null>(null)
const expire = useCallback(async (reason: SessionTimeoutReason) => {
if (expiringRef.current) return
expiringRef.current = true
resetAnalyticsIdentity()
try {
await createClient().auth.signOut({ scope: 'local' })
} catch {
// Middleware remains authoritative and clears the server cookies.
}
const method = stateRef.current?.method ?? 'password'
const url = new URL('/login', window.location.origin)
url.searchParams.set('reason', reason)
url.searchParams.set('method', method)
const next = window.location.pathname + window.location.search
if (next !== '/') url.searchParams.set('next', next)
window.location.assign(url.toString())
}, [])
const applyServerState = useCallback((state: SessionTimeoutClientState) => {
if (!state.enabled) {
stateRef.current = null
lastActivityAtRef.current = 0
warningOpenRef.current = false
setWarning(null)
return
}
const clockOffset = Date.now() - state.serverNow
stateRef.current = {
...state,
startedAt: state.startedAt + clockOffset,
lastActivityAt: state.lastActivityAt + clockOffset,
serverNow: Date.now(),
}
lastActivityAtRef.current = state.lastActivityAt + clockOffset
}, [])
const handleExpiredResponse = useCallback((response: Response) => {
const reason = response.headers.get(SESSION_TIMEOUT_REASON_HEADER) === 'idle'
? 'idle'
: 'absolute'
void expire(reason)
}, [expire])
const syncFromServer = useCallback(async () => {
try {
const response = await fetch('/api/auth/heartbeat', {
method: 'GET',
cache: 'no-store',
credentials: 'same-origin',
})
if (response.status === 401) {
handleExpiredResponse(response)
return
}
if (!response.ok) return
const payload = await response.json() as { data: SessionTimeoutClientState }
applyServerState(payload.data)
} catch {
// A later resync or protected request will retry server enforcement.
}
}, [applyServerState, handleExpiredResponse])
const sendHeartbeat = useCallback(async (showProgress = false) => {
if (heartbeatInFlightRef.current || !stateRef.current) return false
heartbeatInFlightRef.current = true
if (showProgress) setIsExtending(true)
try {
const response = await fetch('/api/auth/heartbeat', {
method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
})
if (response.status === 401) {
handleExpiredResponse(response)
return false
}
if (!response.ok) return false
const payload = await response.json() as { data: SessionTimeoutClientState }
applyServerState(payload.data)
lastHeartbeatAtRef.current = Date.now()
warningOpenRef.current = false
setWarning(null)
channelRef.current?.postMessage({ type: 'heartbeat', state: payload.data })
return true
} catch {
return false
} finally {
heartbeatInFlightRef.current = false
if (showProgress) setIsExtending(false)
}
}, [applyServerState, handleExpiredResponse])
const recordActivity = useCallback(() => {
const state = stateRef.current
if (!state || warningOpenRef.current || expiringRef.current) return
const now = Date.now()
lastActivityAtRef.current = now
channelRef.current?.postMessage({ type: 'activity', at: now })
if (now - lastHeartbeatAtRef.current >= ACTIVITY_HEARTBEAT_INTERVAL_MS) {
void sendHeartbeat()
}
}, [sendHeartbeat])
useEffect(() => {
void syncFromServer()
const channel = typeof BroadcastChannel === 'undefined'
? null
: new BroadcastChannel(SESSION_TIMEOUT_CHANNEL)
channelRef.current = channel
if (channel) {
channel.onmessage = (event: MessageEvent<{
type: 'activity' | 'heartbeat' | 'expired'
at?: number
reason?: SessionTimeoutReason
state?: SessionTimeoutClientState
}>) => {
// 'expired' comes from notifySessionExpired(): a data request hit the
// middleware 401 before our own timers noticed, which is the normal
// order of events in a backgrounded tab where they are throttled.
if (event.data.type === 'expired') {
void expire(event.data.reason === 'idle' ? 'idle' : 'absolute')
} else if (event.data.type === 'heartbeat' && event.data.state) {
applyServerState(event.data.state)
lastHeartbeatAtRef.current = Date.now()
warningOpenRef.current = false
setWarning(null)
} else if (
event.data.type === 'activity' &&
typeof event.data.at === 'number' &&
!warningOpenRef.current
) {
lastActivityAtRef.current = Math.max(
lastActivityAtRef.current,
event.data.at,
)
}
}
}
for (const eventName of ACTIVITY_EVENTS) {
window.addEventListener(eventName, recordActivity, { passive: true })
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') void syncFromServer()
}
document.addEventListener('visibilitychange', onVisibilityChange)
const timer = window.setInterval(() => {
const state = stateRef.current
if (!state) return
const deadline = timeoutDeadline(state, lastActivityAtRef.current)
const remainingMs = deadline.at - Date.now()
if (remainingMs <= 0) {
void expire(deadline.reason)
return
}
if (state.warningMs > 0 && remainingMs <= state.warningMs) {
warningOpenRef.current = true
setWarning({
reason: deadline.reason,
seconds: Math.max(1, Math.ceil(remainingMs / 1000)),
})
} else if (warningOpenRef.current) {
warningOpenRef.current = false
setWarning(null)
}
}, 1000)
const resyncTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') void syncFromServer()
}, SERVER_RESYNC_INTERVAL_MS)
return () => {
window.clearInterval(timer)
window.clearInterval(resyncTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
for (const eventName of ACTIVITY_EVENTS) {
window.removeEventListener(eventName, recordActivity)
}
channel?.close()
channelRef.current = null
}
}, [applyServerState, expire, recordActivity, syncFromServer])
if (!warning) return null
return (
<SessionTimeoutModal
reason={warning.reason}
seconds={warning.seconds}
isExtending={isExtending}
onContinue={() => {
if (warning.reason === 'absolute') {
void expire('absolute')
} else {
lastActivityAtRef.current = Date.now()
void sendHeartbeat(true)
}
}}
/>
)
}