Files
mule-image/frontend/src/components/auth/LoginPage.tsx
Claudio 99d504842e feat(auth): auto-redirect to Authentik when OIDC enabled
Even when the user has a live Authentik session, hitting
photos.hubris.network used to drop them on the LoginPage with a 'Sign
in with Authentik' button they had to click manually. With OIDC set
up for a single trusted IdP that's friction with no upside.

LoginPage now reads /auth/config on mount and, if OIDC is enabled,
immediately navigates to the OIDC login URL. Authentik recognizes
the existing session and bounces the browser back through the
callback signed in — no clicks needed.

Two escape hatches so the user is never stuck:
  - ?password=1 in the URL forces the password form
  - sessionStorage 'skipAutoSso' flag, set by the logout flow and by
    the OIDC callback's error branch, suppresses the next auto-redirect
    so logouts actually log out and OIDC failures surface their error
    instead of looping straight back to the IdP

While the redirect is in flight we show 'Signing in with Authentik...'
plus a small 'Use password instead' link, so users on a slow or
broken IdP connection aren't left staring at a spinner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:59:15 +02:00

165 lines
5.4 KiB
TypeScript

import { useEffect, useState, type FormEvent } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import api from '../../services/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface OidcConfig {
enabled: boolean
label: string
login_url: string
}
interface AuthConfig {
oidc: OidcConfig | null
}
export function LoginPage() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [oidc, setOidc] = useState<OidcConfig | null>(null)
// While the OIDC config loads we may auto-bounce to the IdP. Hide
// the form until we know we're staying so the user doesn't see a
// flash of password fields right before the redirect kicks in.
const [autoRedirecting, setAutoRedirecting] = useState(true)
// Ask the backend which login methods to show. If OIDC is enabled
// and the user already has an SSO session at the IdP, the natural
// flow is for them to land here, get bounced through Authentik, and
// come straight back signed in — without ever clicking a button.
// Two escape hatches: `?password=1` in the URL for explicit password
// login, and a `skipAutoSso` sessionStorage flag set by logout and
// by the OIDC callback's error branch so users don't get trapped in
// a redirect loop.
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await api.get<AuthConfig>('/auth/config')
if (cancelled) return
const cfg = res.data.oidc
setOidc(cfg)
if (!cfg?.enabled) {
setAutoRedirecting(false)
return
}
const params = new URLSearchParams(window.location.search)
if (
params.has('password') ||
sessionStorage.getItem('skipAutoSso') === '1'
) {
sessionStorage.removeItem('skipAutoSso')
setAutoRedirecting(false)
return
}
window.location.href = cfg.login_url
} catch {
if (!cancelled) setAutoRedirecting(false)
}
})()
return () => {
cancelled = true
}
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await login(username, password)
} catch (err: any) {
setError(
err.response?.data?.detail ?? 'Unable to sign in. Check your credentials.',
)
} finally {
setLoading(false)
}
}
if (autoRedirecting) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-sm space-y-3 rounded-lg border border-border bg-surface p-8 text-center shadow-xl">
<div className="text-text-muted">
Signing in with {oidc?.label ?? 'identity provider'}&hellip;
</div>
<a
href="?password=1"
className="inline-block text-xs text-text-muted underline hover:text-text"
onClick={() => setAutoRedirecting(false)}
>
Use password instead
</a>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl">
<h1 className="text-center text-xl font-semibold text-text">
Sign in to Mulita
</h1>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{oidc?.enabled && (
<>
{/* Full-page navigation (not a fetch) — Authlib sets a
* signed session cookie in the /oidc/login response, so
* the browser needs to follow the redirect chain itself. */}
<Button asChild variant="outline" className="w-full">
<a href={oidc.login_url}>Sign in with {oidc.label}</a>
</Button>
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-text-muted">
<span className="h-px flex-1 bg-border" />
or continue with password
<span className="h-px flex-1 bg-border" />
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="login-user">Username</Label>
<Input
id="login-user"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="login-pass">Password</Label>
<Input
id="login-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in…' : 'Sign In'}
</Button>
</form>
</div>
</div>
)
}