183 lines
6.0 KiB
TypeScript
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 "X-API-Key: $KEY" 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>
|
|
)
|
|
}
|