Files
lxc-streamutils/apps/overlay/src/auth.tsx
T

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>
)
}