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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
84
frontend/src/components/auth/OidcCallback.tsx
Normal file
84
frontend/src/components/auth/OidcCallback.tsx
Normal 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…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -521,8 +521,12 @@ export function HeapsPanel() {
|
||||
navigateToSection(`heap-${sh.id}`, { heapId: sh.id })
|
||||
}
|
||||
>
|
||||
<Avatar name={sh.owner_username} size="xs" />
|
||||
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_username})`}>
|
||||
<Avatar
|
||||
name={sh.owner_username}
|
||||
imageUrl={sh.owner_avatar_url}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate" title={`${sh.name} (shared by ${sh.owner_display_name || sh.owner_username})`}>
|
||||
{sh.name}
|
||||
</span>
|
||||
<PermissionIcon
|
||||
|
||||
@@ -899,8 +899,12 @@ export function LeftSidebar() {
|
||||
navigateToSection(`folder-${sf.id}`, { folderId: sf.id })
|
||||
}
|
||||
>
|
||||
<Avatar name={sf.owner_username} size="xs" />
|
||||
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_username})`}>
|
||||
<Avatar
|
||||
name={sf.owner_username}
|
||||
imageUrl={sf.owner_avatar_url}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate" title={`${sf.name} (shared by ${sf.owner_display_name || sf.owner_username})`}>
|
||||
{sf.name}
|
||||
</span>
|
||||
<PermissionIcon
|
||||
@@ -955,8 +959,19 @@ export function LeftSidebar() {
|
||||
<div className="border-t border-border p-1.5 space-y-0.5">
|
||||
{/* User row */}
|
||||
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
|
||||
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="flex-1 truncate text-text">{user?.username}</span>
|
||||
{user ? (
|
||||
<Avatar
|
||||
name={user.username}
|
||||
imageUrl={user.avatar_url}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-text">
|
||||
{user?.display_name || user?.username}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
|
||||
<Shield className="inline h-2.5 w-2.5" />
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Hash-tinted initial bubble used across every sharing surface
|
||||
* (ShareDialog, NotificationBell, sidebar shared rows). The tint
|
||||
* isn't meaningful — it's just an identity cue so a list of names
|
||||
* feels less anonymous. Using the same hash across components means
|
||||
* a given user's bubble stays the same colour everywhere. */
|
||||
* a given user's bubble stays the same colour everywhere.
|
||||
*
|
||||
* When `imageUrl` is present (Authentik `picture` claim or a Gravatar
|
||||
* URL), an <img> sits on top of the tinted bubble; if the image fails
|
||||
* to load we collapse back to initials, so a broken avatar never
|
||||
* shows up as a white square. */
|
||||
const PALETTE = [
|
||||
'bg-primary/25 text-primary',
|
||||
'bg-pick/25 text-pick',
|
||||
@@ -23,10 +29,12 @@ export function avatarColor(name: string): string {
|
||||
|
||||
export function Avatar({
|
||||
name,
|
||||
imageUrl,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
imageUrl?: string | null
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
className?: string
|
||||
}) {
|
||||
@@ -38,17 +46,38 @@ export function Avatar({
|
||||
: size === 'sm'
|
||||
? 'h-5 w-5 text-[9px]'
|
||||
: 'h-8 w-8 text-[11px]'
|
||||
|
||||
// Reset the broken-image flag when the URL changes, e.g. after the
|
||||
// user updates their Gravatar or logs in via a different provider.
|
||||
const [broken, setBroken] = useState(false)
|
||||
useEffect(() => {
|
||||
setBroken(false)
|
||||
}, [imageUrl])
|
||||
|
||||
const showImage = Boolean(imageUrl) && !broken
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full font-semibold uppercase tracking-wide',
|
||||
'relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold uppercase tracking-wide',
|
||||
dims,
|
||||
tint,
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
{showImage ? (
|
||||
<img
|
||||
src={imageUrl!}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setBroken(true)}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,10 +90,16 @@ export function NotificationBell() {
|
||||
className="flex flex-col gap-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Avatar name={invite.owner_username} size="sm" />
|
||||
<Avatar
|
||||
name={invite.owner_username}
|
||||
imageUrl={invite.owner_avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm leading-tight text-text">
|
||||
<span className="font-medium">{invite.owner_username}</span>
|
||||
<span className="font-medium">
|
||||
{invite.owner_display_name || invite.owner_username}
|
||||
</span>
|
||||
<span className="text-text-muted"> shared </span>
|
||||
<span className="inline-flex items-center gap-1 align-baseline">
|
||||
<TypeIcon className="inline h-3 w-3 text-text-muted" />
|
||||
|
||||
@@ -180,8 +180,8 @@ export function ShareDialog({
|
||||
{availableUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.username}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar name={u.username} size="sm" />
|
||||
{u.username}
|
||||
<Avatar name={u.username} imageUrl={u.avatar_url} size="sm" />
|
||||
{u.display_name || u.username}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -259,11 +259,14 @@ export function ShareDialog({
|
||||
key={share.id}
|
||||
className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-surface-2"
|
||||
>
|
||||
<Avatar name={share.shared_with_username} />
|
||||
<Avatar
|
||||
name={share.shared_with_username}
|
||||
imageUrl={share.shared_with_avatar_url}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-text">
|
||||
{share.shared_with_username}
|
||||
{share.shared_with_display_name || share.shared_with_username}
|
||||
</span>
|
||||
{share.status === 'pending' && (
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user