feat(auth): Session-Wiederherstellung nach Reload ohne vollen Login

Nach gültigem Server-Cookie wird automatisch Passkey oder PIN zum Entsperren angeboten, statt die komplette Anmelde-Maske zu zeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 11:42:06 +02:00
parent 10835c9def
commit 968e81f4fb
9 changed files with 229 additions and 11 deletions
+19 -1
View File
@@ -97,6 +97,8 @@ function App() {
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo') const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin')) const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
const [isAdminUser, setIsAdminUser] = useState(false) const [isAdminUser, setIsAdminUser] = useState(false)
const [sessionChecked, setSessionChecked] = useState(false)
const [serverSessionActive, setServerSessionActive] = useState(false)
const syncQueueCount = useLiveQuery( const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -316,6 +318,8 @@ function App() {
const session = await checkServerSession() const session = await checkServerSession()
if (cancelled) return if (cancelled) return
setServerSessionActive(session.authenticated)
if (session.authenticated) { if (session.authenticated) {
persistSessionUserId(session.userId) persistSessionUserId(session.userId)
} }
@@ -335,6 +339,10 @@ function App() {
if (!cancelled) { if (!cancelled) {
console.warn('Session restore failed:', err) console.warn('Session restore failed:', err)
} }
} finally {
if (!cancelled) {
setSessionChecked(true)
}
} }
})() })()
@@ -618,7 +626,17 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="auth-screen"> <div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} /> {!sessionChecked ? (
<div className="auth-card glass">
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
</div>
) : (
<AuthOnboarding
restoreSession={serverSessionActive}
onAuthenticated={handleAuthenticated}
onOpenDemo={openDemo}
/>
)}
</div> </div>
) )
} }
+133 -5
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { import {
@@ -12,7 +12,8 @@ import {
getKnownUsernames, getKnownUsernames,
forgetUsername, forgetUsername,
hasUnlockedLocalSession, hasUnlockedLocalSession,
logoutUser logoutUser,
resolveRestoreUsername
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
@@ -27,9 +28,15 @@ import {
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
onOpenDemo?: () => 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 { t, i18n } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false) const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false) const [showHelp, setShowHelp] = useState(false)
const [showStandardLogin, setShowStandardLogin] = useState(false)
const autoUnlockAttempted = useRef(false)
const isRestoreFlow = restoreSession && !showStandardLogin
const passkeyHostOk = isPasskeyCompatibleLocation() const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href) 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) => { const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return if (!recoveryInput.trim() || !encryptedPayloads) return
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<KeyRound className="auth-icon accent" size={48} /> <KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_pin_title')}</h2> <h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
</div> </div>
<p className="recovery-warning"> <p className="recovery-warning">
{t('auth.enter_pin_warning')} {isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
</p> </p>
<form onSubmit={handlePinLoginSubmit} className="auth-form"> <form onSubmit={handlePinLoginSubmit} className="auth-form">
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => { onClick={() => {
if (isRestoreFlow) {
setShowPinLogin(false)
setPinLoginInput('')
setError(null)
return
}
void (async () => { void (async () => {
setShowPinLogin(false) setShowPinLogin(false)
setPinLoginInput('') 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 (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.restore_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
{loading && (
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
{t('auth.restore_unlocking')}
</p>
)}
{error && <div className="auth-error">{error}</div>}
{!loading && (
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
{restoreUser && passkeyHostOk && (
<button
type="button"
className="btn primary"
onClick={() => handleLogin(restoreUser)}
style={{ width: '100%' }}
>
{t('auth.restore_with_passkey', { name: restoreUser })}
</button>
)}
{restoreUser && hasLocalPin(restoreUser) && (
<button
type="button"
className="btn secondary"
onClick={() => {
setUsername(restoreUser)
setShowPinLogin(true)
}}
style={{ width: '100%' }}
>
{t('auth.restore_with_pin')}
</button>
)}
{restoreKnownUsers.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
{t('auth.quick_login')}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
{restoreKnownUsers.map((name) => (
<button
key={name}
type="button"
onClick={() => {
if (hasLocalPin(name)) {
setUsername(name)
setShowPinLogin(true)
} else {
void handleLogin(name)
}
}}
disabled={loading}
className="btn secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<UserRound size={16} />
{name}
</button>
))}
</div>
</div>
)}
<button
type="button"
className="btn secondary"
onClick={() => {
setShowStandardLogin(true)
setError(null)
}}
style={{ width: '100%' }}
>
{t('auth.restore_other_account')}
</button>
</div>
)}
</div>
)
}
// Render 3: Standard Login / Registration options form // Render 3: Standard Login / Registration options form
return ( return (
<> <>
+9 -1
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Skift til localhost", "use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.", "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_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": { "pwa": {
"title": "Installer app", "title": "Installer app",
+9 -1
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Zu localhost wechseln", "use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.", "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_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": { "pwa": {
"title": "App installieren", "title": "App installieren",
+9 -1
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Switch to localhost", "use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.", "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_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": { "pwa": {
"title": "Install app", "title": "Install app",
+9 -1
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Bytt til localhost", "use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.", "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_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": { "pwa": {
"title": "Installer app", "title": "Installer app",
+9 -1
View File
@@ -91,7 +91,15 @@
"use_localhost_link": "Byt till localhost", "use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.", "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_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": { "pwa": {
"title": "Installera app", "title": "Installera app",
+9
View File
@@ -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<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, { const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST' method: 'POST'
+23
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { import {
hasUnlockedLocalCrypto, hasUnlockedLocalCrypto,
hasUnlockedLocalSession, hasUnlockedLocalSession,
resolveRestoreUsername,
setActiveMasterKey setActiveMasterKey
} from './auth.js' } 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', () => { describe('persistSessionUserId', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()