Files
kapteins-daagbok/client/src/components/AuthOnboarding.tsx
T
elpatron 0caaf681d8 Fix live journal freeze and passkey login on localhost.
Harden live log init with safe per-entry decrypt, stable loading state, and no parallel list scan in live mode. Improve multi-sail picker UX, stop WebAuthn retry after user cancel, redirect 127.0.0.1 to localhost, and tolerate missing appearance prefs table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:49:45 +02:00

675 lines
22 KiB
TypeScript

import React, { 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
} 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
}
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
const { t, i18n } = useTranslation()
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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<string[]>(() => getKnownUsernames())
// Registration recovery phrase flow
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Login recovery phrase fallback flow
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
const [recoveryInput, setRecoveryInput] = useState('')
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(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 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)
}
}
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 <RegistrationDisclaimer variant="accept" onDismiss={handleDisclaimerAccept} />
}
// Render 1: Display new registration recovery phrase
if (recoveryPhrase) {
return (
<div className="auth-card glass">
<div className="auth-header">
<ShieldAlert className="auth-icon warn" size={48} />
<h2>{t('auth.recovery_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_warning')}</p>
<div className="phrase-grid">
{recoveryPhrase.split(" ").map((word, idx) => (
<div key={idx} className="phrase-word">
<span className="word-num">{idx + 1}</span> {word}
</div>
))}
</div>
<div className="auth-actions">
<button className="btn secondary" onClick={copyToClipboard}>
{copied ? t('auth.copied') : t('auth.copy_phrase')}
</button>
<button className="btn primary" onClick={handleConfirmRecovery}>
{t('auth.confirm_recovery')}
</button>
</div>
</div>
)
}
// Render 4: PIN setup screen
if (showPinSetup) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.setup_pin_title')}</h2>
</div>
<p className="recovery-warning">
{t('auth.setup_pin_warning')}
</p>
<form onSubmit={handlePinSetupSubmit} className="auth-form">
<div className="input-group">
<label className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.pin_label')}
</label>
<input
type="password"
name="new-pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
className="input-text"
placeholder={t('auth.pin_placeholder')}
value={pinInput}
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="new-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error" style={{ color: '#ef4444', fontSize: '14px', marginTop: '8px' }}>{error}</div>}
<div className="auth-actions" style={{ marginTop: '20px' }}>
<button
type="button"
className="btn secondary"
onClick={finishAuth}
disabled={loading}
>
{t('auth.skip_pin')}
</button>
<button type="submit" className="btn primary" disabled={loading || pinInput.length < 4}>
{loading ? t('auth.processing') : t('auth.save_pin')}
</button>
</div>
</form>
</div>
)
}
// Render 5: PIN login screen
if (showPinLogin) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_pin_title')}</h2>
</div>
<p className="recovery-warning">
{t('auth.enter_pin_warning')}
</p>
<form onSubmit={handlePinLoginSubmit} className="auth-form">
<div className="input-group">
<input
type="password"
name="pin"
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
className="input-text"
placeholder={t('auth.enter_pin_placeholder')}
value={pinLoginInput}
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error" style={{ color: '#ef4444', fontSize: '14px', marginTop: '8px' }}>{error}</div>}
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '20px' }}>
<button type="submit" className="btn primary" disabled={loading} style={{ width: '100%' }}>
{loading ? t('auth.decrypting') : t('auth.decrypt_with_pin')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setShowPinLogin(false)
setShowRecoveryFallback(true)
setError(null)
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
)
}
// Render 2: Ask for recovery phrase fallback if biometric PRF fails
if (showRecoveryFallback) {
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">
{t('auth.recovery_fallback_warning')}
</p>
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error">{error}</div>}
<div className="auth-actions">
<button
type="button"
className="btn secondary"
onClick={() => setShowRecoveryFallback(false)}
disabled={loading}
>
{t('auth.back')}
</button>
<button type="submit" className="btn primary" disabled={loading}>
{loading ? t('auth.decrypting') : t('auth.decrypt_logbook')}
</button>
</div>
</form>
</div>
)
}
// Render 3: Standard Login / Registration options form
return (
<>
<div className="auth-card glass">
<div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
<div className="auth-brand-title-row">
<h1>{t('app.name')}</h1>
<BetaBadge />
</div>
<p className="tagline">{t('auth.tagline')}</p>
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
? t('auth.processing')
: knownUsers.length === 1
? t('auth.login_as', { name: knownUsers[0] })
: t('auth.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 && (
<button
type="button"
onClick={() => handleForgetUser(knownUsers[0])}
disabled={loading}
style={{
background: 'transparent',
border: 'none',
color: '#64748b',
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
alignSelf: 'center',
padding: 0
}}
>
{t('auth.not_user', { name: knownUsers[0] })}
</button>
)}
{/* 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 && (
<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%' }}>
{knownUsers.map((name) => (
<div
key={name}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '999px',
padding: '4px 4px 4px 12px'
}}
>
<button
type="button"
onClick={() => handleLogin(name)}
disabled={loading}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: 'transparent',
border: 'none',
color: '#e2e8f0',
cursor: 'pointer',
fontSize: '14px',
padding: 0
}}
>
<UserRound size={16} />
{name}
</button>
<button
type="button"
onClick={() => handleForgetUser(name)}
disabled={loading}
title={t('auth.forget_account')}
aria-label={t('auth.forget_account')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '22px',
height: '22px',
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
borderRadius: '50%'
}}
>
<X size={14} />
</button>
</div>
))}
</div>
</div>
)}
{/* Separator */}
<div style={{ display: 'flex', alignItems: 'center', margin: '10px 0', width: '100%' }}>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>{t('auth.or_register')}</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
</div>
<button
type="button"
className="btn secondary"
onClick={() => onOpenDemo?.()}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.explore_demo')}
</button>
{/* Registration form */}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<div className="input-group">
<input
type="text"
className="input-text"
placeholder={t('auth.username_placeholder')}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
</div>
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
</button>
</form>
{error && <div className="auth-error">{error}</div>}
</div>
<div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<button
type="button"
className="btn-icon-text link-sec"
onClick={() => setShowHelp(true)}
title={t('disclaimer.button_title')}
aria-label={t('disclaimer.button_title')}
>
<HelpCircle size={18} />
{t('auth.help')}
</button>
</div>
</div>
<DisclaimerModal open={showHelp} onClose={() => setShowHelp(false)} />
</>
)
}