import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { registerUser, loginUser, completeLoginWithRecovery, setLocalPin, hasLocalPin, decryptWithLocalPin, getActiveMasterKey, getKnownUsernames, forgetUsername, hasUnlockedLocalSession, logoutUser, resolveRestoreUsername } from '../services/auth.js' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import DisclaimerModal from './DisclaimerModal.tsx' import BetaBadge from './BetaBadge.tsx' import { isPasskeyCompatibleLocation, localizeWebAuthnError, toPasskeyCompatibleUrl } from '../utils/passkeyHost.ts' 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, restoreSession = false }: AuthOnboardingProps) { const { t, i18n } = useTranslation() const [username, setUsername] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) // Accounts that have already authenticated on this device. Used to offer a // one-click login without re-typing the username. const [knownUsers, setKnownUsers] = useState(() => getKnownUsernames()) // Registration recovery phrase flow const [recoveryPhrase, setRecoveryPhrase] = useState(null) const [copied, setCopied] = useState(false) // Login recovery phrase fallback flow const [showRecoveryFallback, setShowRecoveryFallback] = useState(false) const [recoveryInput, setRecoveryInput] = useState('') const [encryptedPayloads, setEncryptedPayloads] = useState(null) // PIN setup flow state const [showPinSetup, setShowPinSetup] = useState(false) const [pinInput, setPinInput] = useState('') const [pinSetupUsername, setPinSetupUsername] = useState('') // PIN login fallback flow state const [showPinLogin, setShowPinLogin] = useState(false) const [pinLoginInput, setPinLoginInput] = useState('') 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) const formatAuthError = (message: string) => localizeWebAuthnError(message, { invalidHost: t('auth.error_invalid_host'), cancelled: t('auth.error_passkey_cancelled'), invalidRpId: t('auth.error_invalid_rp_id') }) const finishAuth = () => { if (isNewRegistration) { setShowDisclaimer(true) return } onAuthenticated() } const handleDisclaimerAccept = () => { setIsNewRegistration(false) setShowDisclaimer(false) onAuthenticated() } const handleRegister = async (e: React.FormEvent) => { e.preventDefault() if (!username.trim()) return setLoading(true) setError(null) try { const result = await registerUser(username.trim()) if (result.verified) { setIsNewRegistration(true) setRecoveryPhrase(result.recoveryPhrase) } } catch (err: any) { setError(formatAuthError(err.message || 'Registration failed')) } finally { setLoading(false) } } const handleLogin = async (explicitUsername?: string) => { setLoading(true) setError(null) try { // Pass the username when available so the server returns a concrete // allowCredentials list. A usernameless (discoverable) assertion fails on // some platform authenticators (e.g. Windows Hello); giving the explicit // credential id makes those work. Priority: an explicitly clicked account // > a typed name > the single remembered account > usernameless discovery. const remembered = getKnownUsernames() const target = explicitUsername || username.trim() || (remembered.length === 1 ? remembered[0] : undefined) const result = await loginUser(target) if (result.verified) { if (result.prfSuccess) { // Biometric E2E decryption succeeded onAuthenticated() } else { // Biometrics succeeded but PRF key wasn't supported/available, fall back to PIN or recovery phrase setEncryptedPayloads(result.encryptedPayloads) const resolvedUser = result.username || result.encryptedPayloads?.username || '' if (resolvedUser) { setUsername(resolvedUser) } if (resolvedUser && hasLocalPin(resolvedUser)) { setShowPinLogin(true) } else { setShowRecoveryFallback(true) } } } } catch (err: any) { setError(formatAuthError(err.message || 'Login failed')) } finally { setLoading(false) } } 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 setLoading(true) setError(null) try { const resolvedUser = username.trim() || encryptedPayloads.username const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads) if (success) { // Offer PIN setup to prevent future recovery phrase entries on this device setPinSetupUsername(resolvedUser) setShowRecoveryFallback(false) setShowPinSetup(true) } else { setError(t('auth.error_incorrect_recovery')) } } catch (err: any) { setError(t('auth.error_decryption_failed')) } finally { setLoading(false) } } const handleConfirmRecovery = () => { setPinSetupUsername(username.trim() || encryptedPayloads?.username || '') // Clear the recovery phrase so the PIN-setup screen can render: the // `recoveryPhrase` branch is evaluated before `showPinSetup`, so leaving it // set would keep the user stuck on the recovery-phrase screen. setRecoveryPhrase(null) setShowPinSetup(true) } const handlePinSetupSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!pinInput.trim() || pinInput.length < 4) { setError(t('auth.pin_length_error', 'PIN must be at least 4 digits')) return } setLoading(true) setError(null) try { const activeKey = getActiveMasterKey() if (activeKey) { await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey) finishAuth() } else { setError('No active master key found') } } catch (err: any) { setError(err.message || 'Failed to save PIN') } finally { setLoading(false) } } const handlePinLoginSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!pinLoginInput.trim() || loading) return const resolvedUser = username.trim() || encryptedPayloads?.username || localStorage.getItem('active_username') || '' if (!resolvedUser) { setError(t('auth.error_session_incomplete')) return } setLoading(true) setError(null) try { const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser) if (!key) { setError(t('auth.error_incorrect_pin')) return } if (!hasUnlockedLocalSession()) { setError(t('auth.error_session_incomplete')) return } setShowPinLogin(false) onAuthenticated() } catch { setError(t('auth.error_incorrect_pin')) } finally { setLoading(false) } } const handleForgetUser = (name: string) => { forgetUsername(name) setKnownUsers(getKnownUsernames()) } const toggleLanguage = () => { cycleAppLanguage(i18n) } const copyToClipboard = () => { if (recoveryPhrase) { navigator.clipboard.writeText(recoveryPhrase) setCopied(true) setTimeout(() => setCopied(false), 2000) } } // Render 0: Registration disclaimer (new accounts only, before app onboarding) if (showDisclaimer) { return } // Render 1: Display new registration recovery phrase if (recoveryPhrase) { return (

{t('auth.recovery_title')}

{t('auth.recovery_warning')}

{recoveryPhrase.split(" ").map((word, idx) => (
{idx + 1} {word}
))}
) } // Render 4: PIN setup screen if (showPinSetup) { return (

{t('auth.setup_pin_title')}

{t('auth.setup_pin_warning')}

setPinInput(e.target.value.replace(/\D/g, ''))} disabled={loading} required autoComplete="new-password" style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} />
{error &&
{error}
}
) } // Render 5: PIN login screen if (showPinLogin) { return (

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

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

setPinLoginInput(e.target.value.replace(/\D/g, ''))} disabled={loading} required autoComplete="current-password" style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} />
{error &&
{error}
}
) } // Render 2: Ask for recovery phrase fallback if biometric PRF fails if (showRecoveryFallback) { return (

{t('auth.enter_recovery')}

{t('auth.recovery_fallback_warning')}

{(username.trim() || encryptedPayloads?.username) && ( )}
setRecoveryInput(e.target.value)} disabled={loading} required autoComplete="current-password" style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }} />
{error &&
{error}
}
) } // 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 ( <>
Kapteins Daagbok

{t('app.name')}

{t('auth.tagline')}

{!passkeyHostOk && passkeyCompatibleUrl && (

{t('auth.error_invalid_host')}

{t('auth.use_localhost_link')}
)} {/* Prominent Login button */} {/* Single remembered account: the main button already logs in as them, so just offer a way to forget it (e.g. on a shared device). */} {knownUsers.length === 1 && ( )} {/* Quick-login chips for accounts remembered on this device. Each one logs in with its concrete credential, so no username typing needed. */} {knownUsers.length > 1 && (
{t('auth.quick_login')}
{knownUsers.map((name) => (
))}
)} {/* Separator */}
{t('auth.or_register')}
{/* Registration form */}
setUsername(e.target.value)} disabled={loading} required />
{error &&
{error}
}
setShowHelp(false)} /> ) }