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

@@ -14,6 +14,8 @@ export interface AuthUser {
email: string | null
role: 'admin' | 'user'
is_active: boolean
avatar_url: string | null
display_name: string | null
}
interface AuthContextValue {
@@ -26,6 +28,9 @@ interface AuthContextValue {
logout: () => void
/** Called after the setup endpoint creates the first admin. */
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
/** Adopt tokens received from the OIDC callback. Same effect as
* onSetupComplete but semantically distinct. */
onOidcTokens: (accessToken: string, refreshToken: string) => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
@@ -131,6 +136,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[fetchMe],
)
const onOidcTokens = useCallback(
async (accessToken: string, refreshToken: string) => {
storeToken(accessToken)
storeRefreshToken(refreshToken)
await fetchMe()
},
[fetchMe],
)
// Axios interceptor: on 401, try to refresh once using the stored
// refresh token. If that fails, sign out.
useEffect(() => {
@@ -168,7 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete, onOidcTokens }}
>
{children}
</AuthContext.Provider>