Files
fancy-dns/web/src/pages/Login.tsx
T
2026-09-27 11:34:26 +00:00

183 lines
6.0 KiB
TypeScript

/**
* Login — the only page that renders outside AppShell.
*
* The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the
* form is built from what `/api/whoami` reports rather than hard-coded. A 401
* from that probe is expected (that is why the visitor is here) and is not
* shown as an error.
*/
import { useEffect, useState } from 'react'
import type { FormEvent } from 'react'
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import { ApiError, login, whoami } from '../lib/api'
import type { WhoamiResponse } from '../lib/api'
import { Button, Card, CardBody, Input } from '../components/ui'
import { Spinner } from '../components/ui'
/** "apikey" -> one field, "basic" -> two, anything else -> one field. */
function wantsPassword(mode: string): boolean {
return mode === 'basic'
}
export default function Login() {
const [params] = useSearchParams()
const next = params.get('next') ?? '/'
const [mode, setMode] = useState<string | null>(null)
const [apiKey, setApiKey] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let live = true
whoami()
.then((who: WhoamiResponse) => {
if (live) setMode(who.auth_mode ?? 'apikey')
})
.catch(() => {
// Unauthenticated probes and older daemons both land here: show the
// form rather than an error, the submit will say what is wrong.
if (live) setMode('apikey')
})
return () => {
live = false
}
}, [])
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (pending) return
setError(null)
setPending(true)
try {
if (wantsPassword(mode ?? 'apikey')) {
await login({ username, password })
} else {
await login({ api_key: apiKey })
}
// A full navigation: the shell reads whoami and the token on boot.
window.location.assign(next.startsWith('/') ? next : '/')
} catch (err) {
setError(
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: 'login failed',
)
} finally {
setPending(false)
}
}
const passwordMode = wantsPassword(mode ?? 'apikey')
return (
<div className="flex min-h-svh items-center justify-center bg-canvas px-4 py-10">
<div className="w-full max-w-sm">
<div className="mb-5 flex items-center justify-center gap-2">
<span className="grid size-8 place-items-center rounded-lg bg-accent-soft text-accent">
<Globe size={17} strokeWidth={2.25} aria-hidden="true" />
</span>
<span className="text-base font-semibold tracking-tight text-fg">fancy-dns</span>
</div>
<Card
title="Sign in"
subtitle={
mode === null
? 'Checking the authentication mode…'
: passwordMode
? 'This node uses username and password.'
: 'This node uses an API key.'
}
>
<CardBody>
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
{passwordMode ? (
<>
<Input
label="Username"
icon={ShieldCheck}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
<Input
label="Password"
type="password"
icon={KeyRound}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</>
) : (
<Input
label="API key"
type="password"
icon={KeyRound}
placeholder="paste the node's API key"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="current-password"
spellCheck={false}
autoFocus
required
/>
)}
{error !== null && (
<p
role="alert"
className="rounded-lg border border-danger/40 bg-danger-soft px-3 py-2 text-xs text-danger"
>
{error}
</p>
)}
<Button
type="submit"
variant="primary"
size="lg"
icon={pending ? undefined : LogIn}
loading={pending}
className="w-full justify-center"
>
{pending ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardBody>
</Card>
<p className="mt-4 flex items-start gap-2 text-xs text-fg-subtle">
<Terminal size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
Scripts can skip this page: send the same credential as an{' '}
<span className="font-mono text-fg-muted">X-API-Key</span> header, for example{' '}
<span className="font-mono text-fg-muted">
curl -H &quot;X-API-Key: $KEY&quot; http://node:8080/api/dashboard
</span>
.
</span>
</p>
{pending && (
<p className="mt-3 flex items-center justify-center gap-2 text-xs text-fg-muted">
<Spinner size="sm" />
waiting for the node
</p>
)}
</div>
</div>
)
}