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