From 968e81f4fbe2ddf63d55ac6ae9bb0da32651c692 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 5 Jun 2026 11:42:06 +0200 Subject: [PATCH] feat(auth): Session-Wiederherstellung nach Reload ohne vollen Login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach gültigem Server-Cookie wird automatisch Passkey oder PIN zum Entsperren angeboten, statt die komplette Anmelde-Maske zu zeigen. Co-authored-by: Cursor --- client/src/App.tsx | 20 +++- client/src/components/AuthOnboarding.tsx | 138 ++++++++++++++++++++++- client/src/i18n/locales/da.json | 10 +- client/src/i18n/locales/de.json | 10 +- client/src/i18n/locales/en.json | 10 +- client/src/i18n/locales/nb.json | 10 +- client/src/i18n/locales/sv.json | 10 +- client/src/services/auth.ts | 9 ++ client/src/services/authSession.test.ts | 23 ++++ 9 files changed, 229 insertions(+), 11 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index d316d83..6a6d6bd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -97,6 +97,8 @@ function App() { const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo') const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin')) const [isAdminUser, setIsAdminUser] = useState(false) + const [sessionChecked, setSessionChecked] = useState(false) + const [serverSessionActive, setServerSessionActive] = useState(false) const syncQueueCount = useLiveQuery( () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), @@ -316,6 +318,8 @@ function App() { const session = await checkServerSession() if (cancelled) return + setServerSessionActive(session.authenticated) + if (session.authenticated) { persistSessionUserId(session.userId) } @@ -335,6 +339,10 @@ function App() { if (!cancelled) { console.warn('Session restore failed:', err) } + } finally { + if (!cancelled) { + setSessionChecked(true) + } } })() @@ -618,7 +626,17 @@ function App() { if (!isAuthenticated) { return (
- + {!sessionChecked ? ( +
+

{t('auth.restore_checking')}

+
+ ) : ( + + )}
) } diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index f0f983e..3262678 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { @@ -12,7 +12,8 @@ import { getKnownUsernames, forgetUsername, hasUnlockedLocalSession, - logoutUser + logoutUser, + resolveRestoreUsername } from '../services/auth.js' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' @@ -27,9 +28,15 @@ import { interface AuthOnboardingProps { onAuthenticated: () => void onOpenDemo?: () => void + /** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */ + restoreSession?: boolean } -export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) { +export default function AuthOnboarding({ + onAuthenticated, + onOpenDemo, + restoreSession = false +}: AuthOnboardingProps) { const { t, i18n } = useTranslation() const [username, setUsername] = useState('') const [loading, setLoading] = useState(false) @@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo const [isNewRegistration, setIsNewRegistration] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false) const [showHelp, setShowHelp] = useState(false) + const [showStandardLogin, setShowStandardLogin] = useState(false) + const autoUnlockAttempted = useRef(false) + const isRestoreFlow = restoreSession && !showStandardLogin const passkeyHostOk = isPasskeyCompatibleLocation() const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href) @@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo } } + useEffect(() => { + if (!isRestoreFlow || autoUnlockAttempted.current) return + + const user = resolveRestoreUsername() + if (user && hasLocalPin(user)) { + autoUnlockAttempted.current = true + setUsername(user) + setShowPinLogin(true) + return + } + + if (user && passkeyHostOk) { + autoUnlockAttempted.current = true + void handleLogin(user) + } + }, [isRestoreFlow, passkeyHostOk]) + const handleRecoverySubmit = async (e: React.FormEvent) => { e.preventDefault() if (!recoveryInput.trim() || !encryptedPayloads) return @@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
-

{t('auth.enter_pin_title')}

+

{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}

- {t('auth.enter_pin_warning')} + {isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}

@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo type="button" className="btn secondary" onClick={() => { + if (isRestoreFlow) { + setShowPinLogin(false) + setPinLoginInput('') + setError(null) + return + } void (async () => { setShowPinLogin(false) setPinLoginInput('') @@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo ) } + // Render: Session restore (active server cookie, master key lost after reload) + if (isRestoreFlow) { + const restoreUser = resolveRestoreUsername() + const restoreKnownUsers = getKnownUsernames() + + return ( +
+
+ +

{t('auth.restore_title')}

+
+

{t('auth.restore_subtitle')}

+ + {loading && ( +

+ {t('auth.restore_unlocking')} +

+ )} + + {error &&
{error}
} + + {!loading && ( +
+ {restoreUser && passkeyHostOk && ( + + )} + + {restoreUser && hasLocalPin(restoreUser) && ( + + )} + + {restoreKnownUsers.length > 1 && ( +
+ + {t('auth.quick_login')} + +
+ {restoreKnownUsers.map((name) => ( + + ))} +
+
+ )} + + +
+ )} +
+ ) + } + // Render 3: Standard Login / Registration options form return ( <> diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index b1569d4..289d016 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -91,7 +91,15 @@ "use_localhost_link": "Skift til localhost", "error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.", "error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.", - "error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen." + "error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.", + "restore_checking": "Tjekker session…", + "restore_title": "Gendan session", + "restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.", + "restore_unlocking": "Låser op…", + "restore_with_passkey": "Lås op med passkey ({{name}})", + "restore_with_pin": "Lås op med PIN", + "restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.", + "restore_other_account": "Log ind med en anden konto" }, "pwa": { "title": "Installer app", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 5b68a3f..5c6ae2a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -91,7 +91,15 @@ "use_localhost_link": "Zu localhost wechseln", "error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.", "error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.", - "error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden." + "error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.", + "restore_checking": "Session wird geprüft…", + "restore_title": "Session wiederherstellen", + "restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.", + "restore_unlocking": "Wird entsperrt…", + "restore_with_passkey": "Mit Passkey entsperren ({{name}})", + "restore_with_pin": "Mit PIN entsperren", + "restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.", + "restore_other_account": "Anderer Account anmelden" }, "pwa": { "title": "App installieren", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 07f71f2..2a32916 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -91,7 +91,15 @@ "use_localhost_link": "Switch to localhost", "error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.", "error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.", - "error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again." + "error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.", + "restore_checking": "Checking session…", + "restore_title": "Restore session", + "restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.", + "restore_unlocking": "Unlocking…", + "restore_with_passkey": "Unlock with passkey ({{name}})", + "restore_with_pin": "Unlock with PIN", + "restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.", + "restore_other_account": "Sign in with another account" }, "pwa": { "title": "Install app", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 1a06508..b01f7ff 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -91,7 +91,15 @@ "use_localhost_link": "Bytt til localhost", "error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.", "error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.", - "error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen." + "error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.", + "restore_checking": "Sjekker økt…", + "restore_title": "Gjenopprett økt", + "restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.", + "restore_unlocking": "Låser opp…", + "restore_with_passkey": "Lås opp med passkey ({{name}})", + "restore_with_pin": "Lås opp med PIN", + "restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.", + "restore_other_account": "Logg inn med en annen konto" }, "pwa": { "title": "Installer app", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 557bd8f..7f7641c 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -91,7 +91,15 @@ "use_localhost_link": "Byt till localhost", "error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.", "error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.", - "error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen." + "error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.", + "restore_checking": "Kontrollerar session…", + "restore_title": "Återställ session", + "restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.", + "restore_unlocking": "Låser upp…", + "restore_with_passkey": "Lås upp med passkey ({{name}})", + "restore_with_pin": "Lås upp med PIN", + "restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.", + "restore_other_account": "Logga in med ett annat konto" }, "pwa": { "title": "Installera app", diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 15bed59..5693450 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void { } } +/** Username to use when re-unlocking after reload (active account or sole remembered user). */ +export function resolveRestoreUsername(): string | null { + const stored = localStorage.getItem('active_username') + if (stored) return stored + const known = getKnownUsernames() + if (known.length === 1) return known[0] + return null +} + export async function reauthWithPasskey(): Promise { const options = await apiJson(`${API_BASE}/reauth-options`, { method: 'POST' diff --git a/client/src/services/authSession.test.ts b/client/src/services/authSession.test.ts index 9f1fc07..272378b 100644 --- a/client/src/services/authSession.test.ts +++ b/client/src/services/authSession.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { hasUnlockedLocalCrypto, hasUnlockedLocalSession, + resolveRestoreUsername, setActiveMasterKey } from './auth.js' @@ -33,6 +34,28 @@ describe('local session unlock checks', () => { }) }) +describe('resolveRestoreUsername', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('prefers active_username from storage', () => { + localStorage.setItem('active_username', 'captain') + localStorage.setItem('daagbox_known_users', JSON.stringify(['other'])) + expect(resolveRestoreUsername()).toBe('captain') + }) + + it('falls back to a single remembered user', () => { + localStorage.setItem('daagbox_known_users', JSON.stringify(['solo'])) + expect(resolveRestoreUsername()).toBe('solo') + }) + + it('returns null when multiple users and no active username', () => { + localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta'])) + expect(resolveRestoreUsername()).toBeNull() + }) +}) + describe('persistSessionUserId', () => { beforeEach(() => { localStorage.clear()