rework
build / build-push (push) Successful in 1m8s

This commit is contained in:
mats committed 2026-10-16 11:13:13 +02:00
1 parent 7fa48bcdf9
commit a3d1614dca
15 files changed
+704 -123

No files matched your search

+36 -96
View File
@@ -1,18 +1,11 @@
/**
* 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.
*/
/** Sign-in uses the node's public auth settings. OIDC tokens stay on the server. */
import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
import { Globe, KeyRound, LogIn, ShieldCheck } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import { ApiError, login, setToken } from '../lib/api'
import { ApiError, login, clearToken } from '../lib/api'
import { Button, Card, CardBody, Input } from '../components/ui'
import { Spinner } from '../components/ui'
@@ -31,7 +24,6 @@ export default function Login() {
const callbackStarted = useRef(false)
const [mode, setMode] = useState<string | null>(null)
const [oidc, setOIDC] = useState<{ issuer: string; client_id: string } | null>(null)
const [apiKey, setApiKey] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -48,13 +40,13 @@ export default function Login() {
.then((cfg) => {
if (!live) return
setMode(cfg.mode ?? 'apikey')
if (cfg.issuer && cfg.client_id) setOIDC({ issuer: cfg.issuer, client_id: cfg.client_id })
if (cfg.mode === 'none') window.location.replace(safeNext(next))
})
.catch(() => { if (live) setMode('apikey') })
.catch(() => { if (live) setError('Could not load sign-in settings. Reload to try again.') })
return () => {
live = false
}
}, [])
}, [next])
useEffect(() => {
const providerError = params.get('error')
@@ -63,78 +55,37 @@ export default function Login() {
return
}
const code = params.get('code')
if (!code || !oidc || mode !== 'oidc') return
if (callbackStarted.current) return
if (!code || mode !== 'oidc' || callbackStarted.current) return
callbackStarted.current = true
let pendingLogin: { state: string; verifier: string; next: string } | null
try {
pendingLogin = JSON.parse(sessionStorage.getItem('fancydns.oidc.pending') ?? 'null')
} catch { pendingLogin = null }
if (!pendingLogin?.state || pendingLogin.state !== params.get('state') || !pendingLogin.verifier) {
setError('OIDC login state check failed. Start sign in again.')
return
}
sessionStorage.removeItem('fancydns.oidc.pending')
setPending(true)
void (async () => {
try {
const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`)
if (!discoveryRes.ok) throw new Error('could not discover the identity provider')
const discovery = (await discoveryRes.json()) as { token_endpoint?: string }
if (!discovery.token_endpoint) throw new Error('identity provider has no token endpoint')
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: oidc.client_id,
code,
redirect_uri: `${window.location.origin}/auth/oidc/callback`,
code_verifier: pendingLogin.verifier,
})
const tokenRes = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const tokenBody = (await tokenRes.json()) as { access_token?: string; error_description?: string }
if (!tokenRes.ok || !tokenBody.access_token) throw new Error(tokenBody.error_description ?? 'code exchange failed')
setToken(tokenBody.access_token)
window.location.assign(safeNext(pendingLogin.next))
} catch (err) {
setError(err instanceof Error ? err.message : 'OIDC sign in failed')
} finally { setPending(false) }
})()
}, [mode, oidc, params])
const startOIDC = async (): Promise<void> => {
if (!oidc) return
setPending(true)
setError(null)
try {
const discoveryRes = await fetch(`${oidc.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`)
if (!discoveryRes.ok) throw new Error('could not discover the identity provider')
const discovery = (await discoveryRes.json()) as { authorization_endpoint?: string }
if (!discovery.authorization_endpoint) throw new Error('identity provider has no authorization endpoint')
const random = (bytes: number): string => {
const data = crypto.getRandomValues(new Uint8Array(bytes))
return btoa(String.fromCharCode(...data)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
const verifier = random(32)
const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(challenge))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const state = random(24)
const redirectURI = `${window.location.origin}/auth/oidc/callback`
sessionStorage.setItem('fancydns.oidc.pending', JSON.stringify({ state, verifier, next }))
const authorize = new URL(discovery.authorization_endpoint)
authorize.search = new URLSearchParams({
response_type: 'code', client_id: oidc.client_id, redirect_uri: redirectURI,
scope: 'openid profile email', state, code_challenge: codeChallenge, code_challenge_method: 'S256',
}).toString()
window.location.assign(authorize.toString())
} catch (err) {
setError(err instanceof Error ? err.message : 'could not start OIDC sign in')
void fetch('/api/auth/oidc/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state: params.get('state') }),
}).then(async (res) => {
const body = await res.json() as { next?: string; error?: string }
if (!res.ok) throw new Error(body.error ?? 'Sign in failed')
clearToken()
window.location.replace(safeNext(body.next ?? '/'))
}).catch((err: unknown) => {
window.history.replaceState(null, '', '/auth/login')
setError(err instanceof Error ? err.message : 'Sign in failed')
setPending(false)
}
})
}, [mode, params])
const startOIDC = (): void => {
setPending(true)
window.location.assign(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`)
}
useEffect(() => {
if (mode === 'oidc' && !params.has('code') && !params.has('error') && !params.has('signed_out') && !callbackStarted.current) {
callbackStarted.current = true
window.location.replace(`/api/auth/oidc/start?next=${encodeURIComponent(safeNext(next))}`)
}
}, [mode, next, params])
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (pending) return
@@ -184,12 +135,12 @@ export default function Login() {
? 'Checking the authentication mode…'
: passwordMode
? 'This node uses username and password.'
: mode === 'oidc' ? 'Sign in with your identity provider.' : 'This node uses an API key.'
: mode === 'oidc' ? 'Continue with your k6n account.' : 'This node uses an API key.'
}
>
<CardBody>
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
{mode === 'oidc' ? null : passwordMode ? (
{mode === null || mode === 'oidc' ? null : passwordMode ? (
<>
<Input
label="Username"
@@ -236,30 +187,19 @@ export default function Login() {
<Button
type="submit"
disabled={mode === null || pending}
variant="primary"
size="lg"
icon={pending ? undefined : LogIn}
loading={pending}
className="w-full justify-center"
>
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue with OIDC' : 'Sign in'}
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue to sign 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" />