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,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>
)
}

View File

@@ -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" />

View File

@@ -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