0caaf681d8
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>
675 lines
22 KiB
TypeScript
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)} />
|
|
</>
|
|
)
|
|
}
|