439 lines
13 KiB
TypeScript
439 lines
13 KiB
TypeScript
/**
|
|
* Passwordless login and one-time TOTP enrollment screens.
|
|
*
|
|
* QR material, manual keys and recovery codes exist only in React memory and
|
|
* are dropped as soon as their enrollment phase completes. PWA activation is
|
|
* blocked while those values or partially completed forms are visible so an
|
|
* update cannot erase information that the server will not reveal again.
|
|
*/
|
|
import { useMemo, useState } from 'react'
|
|
import type { FormEvent, ReactNode } from 'react'
|
|
import {
|
|
api,
|
|
copyToClipboard,
|
|
errorMessage,
|
|
json,
|
|
normalizeEnrollment,
|
|
normalizeRecoveryCodes,
|
|
} from './api'
|
|
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
|
import { LanguageSelect, useI18n } from './i18n'
|
|
import type { TotpEnrollment } from './types'
|
|
|
|
function AuthShell({
|
|
eyebrow,
|
|
title,
|
|
children,
|
|
footer,
|
|
}: {
|
|
eyebrow: string
|
|
title: string
|
|
children: ReactNode
|
|
footer?: ReactNode
|
|
}) {
|
|
const { t } = useI18n()
|
|
return (
|
|
<main className="auth-page">
|
|
<section className="auth-card jade-panel">
|
|
<div className="auth-toolbar">
|
|
<LanguageSelect />
|
|
<PwaControls />
|
|
</div>
|
|
<div className="auth-mark" aria-hidden="true">
|
|
{t('common.star')}
|
|
</div>
|
|
<p className="eyebrow">{eyebrow}</p>
|
|
<h1>{title}</h1>
|
|
{children}
|
|
{footer && <footer>{footer}</footer>}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
|
const { t } = useI18n()
|
|
const source = useMemo(() => {
|
|
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
|
if (enrollment.qrSvg)
|
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
|
|
return undefined
|
|
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
|
|
|
return (
|
|
<div className="totp-enrollment">
|
|
<div className="totp-qr">
|
|
{source ? (
|
|
<img src={source} alt={t('auth.totp_qr_alt')} />
|
|
) : (
|
|
<span>{t('auth.qr_unavailable')}</span>
|
|
)}
|
|
</div>
|
|
<div className="totp-copy">
|
|
<h2>{t('auth.bind_authenticator')}</h2>
|
|
<ol>
|
|
<li>{t('auth.totp_step_scan')}</li>
|
|
<li>{t('auth.totp_step_manual')}</li>
|
|
<li>{t('auth.totp_step_confirm')}</li>
|
|
</ol>
|
|
<label>
|
|
{t('auth.manual_key')}
|
|
<div className="secret-row">
|
|
<code>{enrollment.manualKey || t('common.not_provided')}</code>
|
|
{enrollment.manualKey && (
|
|
<button
|
|
type="button"
|
|
className="text-button"
|
|
onClick={() => void copyToClipboard(enrollment.manualKey)}
|
|
>
|
|
{t('common.copy')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
|
const { t } = useI18n()
|
|
const [copied, setCopied] = useState(false)
|
|
const text = codes.join('\n')
|
|
const download = () => {
|
|
const blob = new Blob(
|
|
[`${t('auth.recovery_file_title')}\n`, `${t('auth.recovery_file_warning')}\n\n`, text, '\n'],
|
|
{ type: 'text/plain;charset=utf-8' },
|
|
)
|
|
const url = URL.createObjectURL(blob)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.download = 'live-component-recovery-codes.txt'
|
|
anchor.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
return (
|
|
<AuthShell eyebrow={t('auth.security_complete')} title={t('auth.save_recovery_codes')}>
|
|
<p className="auth-lead">{t('auth.recovery_description')}</p>
|
|
{codes.length > 0 ? (
|
|
<div className="recovery-grid">
|
|
{codes.map(code => (
|
|
<code key={code}>{code}</code>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="notice warning">{t('auth.no_recovery_codes')}</div>
|
|
)}
|
|
<div className="form-actions">
|
|
{codes.length > 0 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="secondary"
|
|
onClick={() => void copyToClipboard(text).then(setCopied)}
|
|
>
|
|
{copied ? t('common.copied') : t('auth.copy_all')}
|
|
</button>
|
|
<button type="button" className="secondary" onClick={download}>
|
|
{t('auth.download_text')}
|
|
</button>
|
|
</>
|
|
)}
|
|
<button type="button" onClick={onContinue}>
|
|
{t('auth.recovery_saved')}
|
|
</button>
|
|
</div>
|
|
</AuthShell>
|
|
)
|
|
}
|
|
|
|
export function LoginPage({
|
|
onAuthenticated,
|
|
setupRequired,
|
|
}: {
|
|
onAuthenticated: () => Promise<void>
|
|
setupRequired: boolean
|
|
}) {
|
|
const { t } = useI18n()
|
|
const [username, setUsername] = useState('')
|
|
const [totpCode, setTotpCode] = useState('')
|
|
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
usePwaUpdateBlocker('login-form', t('auth.login_blocker'), busy || Boolean(username || totpCode))
|
|
|
|
const submit = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
await api('/api/v1/auth/login', json('POST', { username: username.trim(), code: totpCode }))
|
|
setTotpCode('')
|
|
await onAuthenticated()
|
|
location.assign('/control/')
|
|
} catch (reason) {
|
|
setError(errorMessage(reason, t('auth.login_failed')))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AuthShell
|
|
eyebrow={t('auth.product_eyebrow')}
|
|
title={t('auth.login_title')}
|
|
footer={
|
|
<p>
|
|
{setupRequired ? (
|
|
<>
|
|
{t('auth.first_deployment')} <a href={authRoute('setup')}>{t('auth.create_admin')}</a>
|
|
</>
|
|
) : (
|
|
<>
|
|
{t('auth.have_invitation')}{' '}
|
|
<a href={authRoute('register')}>{t('auth.register_account')}</a>
|
|
</>
|
|
)}
|
|
</p>
|
|
}
|
|
>
|
|
<p className="auth-lead">{t('auth.passwordless_description')}</p>
|
|
<form className="stack-form" onSubmit={submit}>
|
|
<label>
|
|
{t('auth.username')}
|
|
<input
|
|
autoFocus
|
|
required
|
|
autoComplete="username"
|
|
value={username}
|
|
onChange={event => setUsername(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
{useRecoveryCode ? t('auth.recovery_code') : t('auth.totp_code')}
|
|
<input
|
|
required
|
|
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
|
inputMode={useRecoveryCode ? 'text' : 'numeric'}
|
|
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
|
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
|
maxLength={useRecoveryCode ? 64 : 6}
|
|
placeholder={useRecoveryCode ? t('auth.recovery_placeholder') : '000000'}
|
|
value={totpCode}
|
|
onChange={event =>
|
|
setTotpCode(
|
|
useRecoveryCode
|
|
? event.target.value.trimStart().slice(0, 64)
|
|
: event.target.value.replace(/\D/g, '').slice(0, 6),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="inline-link"
|
|
onClick={() => {
|
|
setUseRecoveryCode(current => !current)
|
|
setTotpCode('')
|
|
}}
|
|
>
|
|
{useRecoveryCode ? t('auth.use_totp') : t('auth.use_recovery')}
|
|
</button>
|
|
{error && (
|
|
<div className="notice error" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<button disabled={busy}>{busy ? t('auth.verifying') : t('auth.login')}</button>
|
|
</form>
|
|
</AuthShell>
|
|
)
|
|
}
|
|
|
|
export function EnrollmentPage({
|
|
mode,
|
|
onAuthenticated,
|
|
}: {
|
|
mode: 'setup' | 'register'
|
|
onAuthenticated: () => Promise<void>
|
|
}) {
|
|
const { language, t } = useI18n()
|
|
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
|
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
|
const [username, setUsername] = useState('')
|
|
const [bootstrapPassword, setBootstrapPassword] = useState('')
|
|
const [enrollment, setEnrollment] = useState<TotpEnrollment>()
|
|
const [totpCode, setTotpCode] = useState('')
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[]>()
|
|
const [error, setError] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const isSetup = mode === 'setup'
|
|
usePwaUpdateBlocker(
|
|
'totp-enrollment',
|
|
enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
|
|
busy ||
|
|
Boolean(
|
|
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
|
|
),
|
|
)
|
|
|
|
const start = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
const payload = await api<unknown>(
|
|
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/start`,
|
|
json('POST', {
|
|
...(isSetup ? {} : { inviteCode: inviteCode.trim() }),
|
|
username: username.trim(),
|
|
...(isSetup ? { bootstrapPassword } : {}),
|
|
}),
|
|
)
|
|
const next = normalizeEnrollment(payload)
|
|
if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
|
|
setEnrollment(next)
|
|
setBootstrapPassword('')
|
|
if (!isSetup) {
|
|
setInviteCode('')
|
|
history.replaceState(null, '', authRoute('register'))
|
|
}
|
|
} catch (reason) {
|
|
setError(errorMessage(reason, t('auth.registration_start_failed')))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const confirm = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
if (!enrollment) return
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
const payload = await api<unknown>(
|
|
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
|
json('POST', {
|
|
enrollmentToken: enrollment.enrollmentToken,
|
|
code: totpCode,
|
|
language,
|
|
}),
|
|
)
|
|
setTotpCode('')
|
|
// Drop QR/manual-key material from React state as soon as enrollment is
|
|
// committed; only the one-time recovery codes remain on screen.
|
|
setEnrollment(undefined)
|
|
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
|
} catch (reason) {
|
|
setError(errorMessage(reason, t('auth.registration_confirm_failed')))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const finish = async () => {
|
|
await onAuthenticated()
|
|
location.assign('/control/')
|
|
}
|
|
|
|
if (recoveryCodes) return <RecoveryCodes codes={recoveryCodes} onContinue={() => void finish()} />
|
|
|
|
if (enrollment) {
|
|
return (
|
|
<AuthShell
|
|
eyebrow={isSetup ? t('auth.setup_step_two') : t('auth.register_step_two')}
|
|
title={t('auth.require_totp')}
|
|
>
|
|
<TotpQr enrollment={enrollment} />
|
|
<form className="stack-form compact-form" onSubmit={confirm}>
|
|
<label>
|
|
{t('auth.authenticator_code')}
|
|
<input
|
|
required
|
|
autoFocus
|
|
className="otp-input"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
pattern="[0-9]{6}"
|
|
maxLength={6}
|
|
placeholder="000000"
|
|
value={totpCode}
|
|
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
/>
|
|
</label>
|
|
{error && (
|
|
<div className="notice error" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<button disabled={busy || totpCode.length !== 6}>
|
|
{busy ? t('auth.confirming') : t('auth.confirm_create')}
|
|
</button>
|
|
</form>
|
|
</AuthShell>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AuthShell
|
|
eyebrow={isSetup ? t('auth.setup_only') : t('auth.invited_only')}
|
|
title={isSetup ? t('auth.create_admin') : t('auth.create_your_account')}
|
|
footer={
|
|
<p>
|
|
{t('auth.already_account')} <a href={authRoute('login')}>{t('auth.back_to_login')}</a>
|
|
</p>
|
|
}
|
|
>
|
|
<p className="auth-lead">
|
|
{isSetup ? t('auth.setup_description') : t('auth.register_description')}
|
|
</p>
|
|
<form className="stack-form" onSubmit={start}>
|
|
{!isSetup && (
|
|
<label>
|
|
{t('auth.invitation')}
|
|
<input
|
|
required
|
|
autoFocus={!inviteFromFragment}
|
|
autoComplete="off"
|
|
value={inviteCode}
|
|
onChange={event => setInviteCode(event.target.value)}
|
|
/>
|
|
</label>
|
|
)}
|
|
<label>
|
|
{t('auth.username')}
|
|
<input
|
|
required
|
|
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
|
autoComplete="username"
|
|
minLength={3}
|
|
maxLength={32}
|
|
value={username}
|
|
onChange={event => setUsername(event.target.value)}
|
|
/>
|
|
</label>
|
|
{isSetup && (
|
|
<label>
|
|
{t('auth.bootstrap_password')}
|
|
<input
|
|
required
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={bootstrapPassword}
|
|
onChange={event => setBootstrapPassword(event.target.value)}
|
|
/>
|
|
<small>{t('auth.bootstrap_help')}</small>
|
|
</label>
|
|
)}
|
|
{error && (
|
|
<div className="notice error" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<button disabled={busy}>{busy ? t('auth.preparing_totp') : t('auth.next_bind')}</button>
|
|
</form>
|
|
</AuthShell>
|
|
)
|
|
}
|