more code
This commit is contained in:
1 parent
f24fd63e9a
commit
ee72257c64
29 files changed
+5636
-440
No files matched your search
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user