add
build / build-push (push) Successful in 30s

This commit is contained in:
mats committed 2026-09-27 22:32:03 +02:00
1 parent d53552f855
commit 2348d2e4b4
15 files changed
+412 -36

No files matched your search

+1
View File
@@ -69,6 +69,7 @@ export default function App() {
<Routes>
{/* Bare: no shell, no header, no nav. */}
<Route path="/auth/login" element={<Login />} />
<Route path="/auth/oidc/callback" element={<Login />} />
<Route
path="*"
element={
+2 -2
View File
@@ -154,7 +154,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
}
const token = getToken()
if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token)
if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`)
let res: Response
try {
@@ -369,7 +369,7 @@ export function streamLogs(handlers: LogStreamHandlers): () => void {
if (stopped) return
const token = getToken()
const headers = new Headers({ Accept: 'text/event-stream' })
if (token) headers.set('X-API-Key', token)
if (token) headers.set('Authorization', `Bearer ${token}`)
try {
const res = await fetch(STREAM_PATH, {
+104 -14
View File
@@ -7,13 +7,12 @@
* shown as an error.
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, 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 { Whoami } from '../lib/types'
import { ApiError, login, setToken } from '../lib/api'
import { Button, Card, CardBody, Input } from '../components/ui'
import { Spinner } from '../components/ui'
@@ -22,11 +21,17 @@ function wantsPassword(mode: string): boolean {
return mode === 'basic'
}
function safeNext(value: string): string {
return value.startsWith('/') && !value.startsWith('//') && !value.includes('\\') ? value : '/'
}
export default function Login() {
const [params] = useSearchParams()
const next = params.get('next') ?? '/'
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('')
@@ -35,33 +40,118 @@ export default function Login() {
useEffect(() => {
let live = true
whoami()
.then((who: Whoami) => {
if (live) setMode(who.auth_mode ?? 'apikey')
fetch('/api/auth/config', { headers: { Accept: 'application/json' } })
.then(async (res) => {
if (!res.ok) throw new Error('could not load authentication settings')
return (await res.json()) as { mode?: string; issuer?: string; client_id?: string }
})
.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')
.then((cfg) => {
if (!live) return
setMode(cfg.mode ?? 'apikey')
if (cfg.issuer && cfg.client_id) setOIDC({ issuer: cfg.issuer, client_id: cfg.client_id })
})
.catch(() => { if (live) setMode('apikey') })
return () => {
live = false
}
}, [])
useEffect(() => {
const providerError = params.get('error')
if (providerError) {
setError(params.get('error_description') ?? `identity provider sign in failed: ${providerError}`)
return
}
const code = params.get('code')
if (!code || !oidc || mode !== 'oidc') return
if (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')
setPending(false)
}
}
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
if (pending) return
setError(null)
setPending(true)
try {
if (mode === 'oidc') {
await startOIDC()
return
}
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 : '/')
window.location.assign(safeNext(next))
} catch (err) {
setError(
err instanceof ApiError
@@ -94,12 +184,12 @@ export default function Login() {
? 'Checking the authentication mode…'
: passwordMode
? 'This node uses username and password.'
: 'This node uses an API key.'
: mode === 'oidc' ? 'Sign in with your identity provider.' : 'This node uses an API key.'
}
>
<CardBody>
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
{passwordMode ? (
{mode === 'oidc' ? null : passwordMode ? (
<>
<Input
label="Username"
@@ -152,7 +242,7 @@ export default function Login() {
loading={pending}
className="w-full justify-center"
>
{pending ? 'Signing in…' : 'Sign in'}
{pending ? 'Signing in…' : mode === 'oidc' ? 'Continue with OIDC' : 'Sign in'}
</Button>
</form>
</CardBody>