feat(oidc): Profilbild vom Identitätsanbieter übernehmen

Schickt der Anbieter ein `picture`, wird es bei der Anmeldung geholt,
mittig auf 256x256 zugeschnitten und oben rechts angezeigt. Fehlt es
oder scheitert das Holen, bleibt es beim Buchstabenkreis - die
Anmeldung selbst hängt nicht davon ab.

Die Bildadresse kommt aus einem Token, also von außen. Erlaubt ist
deshalb ausschließlich der Ursprung aus OIDC_ISSUER, Weiterleitungen
eingeschlossen; dazu 4 MB und 5 Sekunden als Deckel. Ohne das wäre es
eine Aufforderung an den Server, für einen Fremden beliebige Adressen
im internen Netz anzufragen.

Neu: Migration 000019 (avatar_key/-source/-updated_at), Paket
internal/avatar, Route GET /api/v1/users/{id}/avatar. Der GC durchsucht
"avatars/" mit und kennt die Schlüssel - sonst räumte er die Bilder
nach einem Tag weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 13:43:22 +02:00
1 parent 3019e84681
commit 9f44215698
18 files changed
+814 -26

No files matched your search

+13
View File
@@ -15,9 +15,22 @@ export interface User {
/** Konto meldet sich über den Identitätsanbieter an und hat hier kein
* Passwort – die Passwortänderung wird dann ausgeblendet. */
is_oidc?: boolean
/** Zeitstempel des Profilbildes in Sekunden; 0 oder fehlend = keins.
* Adresse dazu über avatarUrl(). */
avatar_ver?: number
created_at: string
}
/**
* Adresse des Profilbildes – oder null, wenn keines hinterlegt ist.
*
* Der Zeitstempel hängt als ?v= dran, damit der Browser das Bild lange behalten
* darf und es trotzdem sofort wechselt, sobald ein anderes hinterlegt wird.
*/
export function avatarUrl(user: Pick<User, 'id' | 'avatar_ver'>): string | null {
return user.avatar_ver ? `/api/v1/users/${user.id}/avatar?v=${user.avatar_ver}` : null
}
export interface FileItem {
id: string
/** Datum auf dem Beleg (YYYY-MM-DD) – nicht das des Uploads. */
+28 -4
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { avatarUrl } from '../api/client'
import type { User } from '../api/client'
import type { View } from '../hooks/useNavHistory'
@@ -94,7 +95,7 @@ export default function UserMenu() {
onMouseEnter={e => { if (!open) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!open) e.currentTarget.style.background = 'transparent' }}
>
<Avatar name={name} size={26} />
<Avatar name={name} src={avatarUrl(user)} size={26} />
<span style={{
fontSize: 13, fontWeight: 500, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
@@ -157,16 +158,39 @@ export default function UserMenu() {
)
}
/** Der farbige Kreis mit dem ersten Buchstaben. */
export function Avatar({ name, size = 26 }: { name: string; size?: number }) {
/**
* Das Profilbild – und solange keines da ist, der farbige Kreis mit dem ersten
* Buchstaben.
*
* Der Buchstabe liegt **unter** dem Bild statt an seiner Stelle: Lädt das Bild
* nicht (Anbieter offline, Bild inzwischen gelöscht), steht der Kreis schon da,
* statt dass ein Loch in der Leiste bleibt. Deshalb auch kein Zustand für
* „fehlgeschlagen" – ein `<img>`, das nichts anzeigt, gibt den Kreis von selbst
* frei.
*/
export function Avatar({ name, src, size = 26 }: { name: string; src?: string | null; size?: number }) {
return (
<span style={{
width: size, height: size, borderRadius: '50%',
position: 'relative', width: size, height: size, borderRadius: '50%',
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: Math.round(size * 0.45), fontWeight: 700, color: '#fff', flexShrink: 0,
overflow: 'hidden',
}}>
{(name[0] ?? '?').toUpperCase()}
{src && (
<img
src={src}
alt=""
width={size}
height={size}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%', objectFit: 'cover',
}}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
)}
</span>
)
}