feat(auth): Authentik OIDC sign-in + Gravatar avatars

Adds optional SSO via Authentik (or any OIDC provider) alongside the
existing password flow, and pulls profile images from the provider's
`picture` claim or Gravatar so the sharing UI stops looking anonymous.
Password login stays available as a recovery path; JIT provisioning and
admin-group mapping are env-configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 21:06:32 +02:00
parent 319be20389
commit e8e1adcf37
20 changed files with 852 additions and 60 deletions

View File

@@ -1,16 +1,46 @@
import { useState, type FormEvent } from 'react'
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)
// Ask the backend which login methods to show. Failure is silent —
// worst case the SSO button just doesn't appear and the user falls
// back to username/password.
useEffect(() => {
let cancelled = false
;(async () => {
try {
const res = await api.get<AuthConfig>('/auth/config')
if (!cancelled) setOidc(res.data.oidc)
} catch {
/* ignore — SSO button stays hidden */
}
})()
return () => {
cancelled = true
}
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
@@ -29,10 +59,7 @@ export function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
>
<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>
@@ -43,33 +70,51 @@ export function LoginPage() {
</Alert>
)}
<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>
{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>
</>
)}
<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>
<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>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in\u2026' : 'Sign In'}
</Button>
</form>
<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>
)
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
/** Landing page for the OIDC redirect.
*
* Authentik bounces the browser to /auth/callback?access_token=...&refresh_token=...
* (or ?error=oidc_xxx when something went wrong). We pull those out of
* the URL, hand them to AuthContext, then replace history so the
* tokens don't linger in the location bar, the back button, or
* whatever screen-recording the user has going.
*
* Tokens in the query string are an accepted trade-off here: they're
* short-lived, they never leave the app origin, and the alternative
* (HTTP-only cookies) would be a much larger rework of an otherwise
* JWT-in-localStorage codebase.
*/
const ERROR_MESSAGES: Record<string, string> = {
oidc_exchange_failed: 'Could not complete sign-in with the identity provider.',
oidc_missing_sub: 'Identity provider did not return a user identifier.',
oidc_signup_disabled: 'Your identity provider account has not been authorized for this instance.',
oidc_deactivated: 'This account has been deactivated.',
}
export function OidcCallback() {
const { onOidcTokens } = useAuth()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const errCode = params.get('error')
// Drop everything after the origin + root path, including the
// tokens, so refreshing or sharing the URL doesn't leak them.
const clean = () => window.history.replaceState({}, '', '/')
if (errCode) {
setError(ERROR_MESSAGES[errCode] || 'Sign-in failed. Please try again.')
clean()
return
}
if (!accessToken || !refreshToken) {
setError('The identity provider did not return the expected tokens.')
clean()
return
}
;(async () => {
try {
await onOidcTokens(accessToken, refreshToken)
} catch {
setError('Could not complete sign-in. Please try again.')
} finally {
clean()
}
})()
}, [onOidcTokens])
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-sm space-y-4 rounded-lg border border-border bg-surface p-8 shadow-xl">
<h1 className="text-center text-lg font-semibold text-text">Sign-in failed</h1>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button className="w-full" onClick={() => (window.location.href = '/')}>
Back to sign in
</Button>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg">
<div className="text-text-muted">Signing you in&hellip;</div>
</div>
)
}