From b3bde0d8969f2fc1870a1c1a53f0f70fb28a32bf Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Sat, 20 Jun 2026 00:23:54 +0200 Subject: [PATCH] feat(shares): show external users with avatar, email and a badge (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related parity gaps from the VanillaJS → Svelte migration (issue #500): internal-vs-external users weren't badged, and external users in a share's member list rendered as a bare UUID with a static icon — no avatar, no email. Both share one root cause: there was no shared user vignette and no resolver for non-directory (external) users (the system address book lists internal users only, and ShareDialog hardcoded isExternal=false). - lib/api/endpoints/users.ts: resolveUser(id) — cached GET /api/users/{id} (the authenticated per-user profile lookup) → {name, email, image, isExternal}; returns null when the profile isn't visible so callers keep their fallback label. - lib/components/UserVignette.svelte: reusable identity chip — avatar (photo or coloured initials), name, email, and a building-circle-xmark badge for external users; resolves lazily and falls back to a caller-supplied label. - lib/utils/avatar.ts: userInitials() + avatarColorIndex() extracted from AppShell (now shared by both — no duplicated logic) so vignette and account button render identically. - ShareDialog: user member rows now render ; groups keep their icon+label. Drops the dead hardcoded isExternal. Backend already exposes everything (UserDto.email/image/is_external via GET /api/users/{id}); no backend change. Frontend gate green (svelte-check 0/0, eslint, stylelint, prettier) + 47 Vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api/endpoints/users.ts | 59 +++++++ frontend/src/lib/components/AppShell.svelte | 22 +-- .../src/lib/components/ShareDialog.svelte | 25 +-- .../src/lib/components/UserVignette.svelte | 150 ++++++++++++++++++ frontend/src/lib/utils/avatar.ts | 25 +++ 5 files changed, 254 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/users.ts create mode 100644 frontend/src/lib/components/UserVignette.svelte create mode 100644 frontend/src/lib/utils/avatar.ts diff --git a/frontend/src/lib/api/endpoints/users.ts b/frontend/src/lib/api/endpoints/users.ts new file mode 100644 index 00000000..ff9b9be7 --- /dev/null +++ b/frontend/src/lib/api/endpoints/users.ts @@ -0,0 +1,59 @@ +/** + * Per-user profile resolution via `GET /api/users/{id}`, cached per id. + * + * Used to render external (and any non-directory) users in share/recipient UIs + * with their real name, email, avatar and an internal/external flag — the + * system address book only lists internal users, so external grant subjects + * would otherwise show as a bare UUID. Mirrors the original `systemUsers` + * resolver. The endpoint enforces its own visibility rules; a non-visible + * profile resolves to `null` so callers fall back to whatever label they have. + */ +import { apiFetch } from '$lib/api/client'; + +export interface ResolvedUser { + id: string; + name: string; + email: string; + image: string | null; + isExternal: boolean; +} + +/** Subset of the backend `UserDto` we consume here. */ +interface UserDtoShape { + id: string; + username?: string | null; + email?: string | null; + image?: string | null; + is_external: boolean; +} + +// id → in-flight/resolved lookup (the Promise is cached so concurrent callers +// for the same id share one request, and a `null` result isn't re-fetched). +const cache = new Map>(); + +export function resolveUser(id: string): Promise { + const hit = cache.get(id); + if (hit) return hit; + + const pending = (async (): Promise => { + try { + const res = await apiFetch(`/api/users/${encodeURIComponent(id)}`, { + credentials: 'same-origin' + }); + if (!res.ok) return null; + const u = (await res.json()) as UserDtoShape; + return { + id: u.id, + name: u.username?.trim() || u.email || u.id, + email: u.email ?? '', + image: u.image ?? null, + isExternal: u.is_external + }; + } catch { + return null; + } + })(); + + cache.set(id, pending); + return pending; +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index bce05dce..f291aa17 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -9,6 +9,7 @@ import CommandPalette from '$lib/components/CommandPalette.svelte'; import Icon from '$lib/icons/Icon.svelte'; import { iconNameFromClass } from '$lib/utils/display'; + import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; import { apiFetch } from '$lib/api/client'; import { session } from '$lib/stores/session.svelte'; @@ -171,28 +172,13 @@ : 0 ); - const initials = $derived.by(() => { - const u = session.user; - if (!u) return '?'; - const base = u.username || u.email || '?'; - const parts = base.trim().split(/\s+/); - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - return base.slice(0, 2).toUpperCase(); - }); + const initials = $derived(userInitials(session.user?.username || session.user?.email)); /** Uploaded avatar photo URL, if any. */ const avatarPhoto = $derived(session.user?.image ?? null); - /** - * Deterministic colour bucket 0–4 from the user id (matches the original - * userVignette `_colorIndex`) so the same user always gets the same colour. - */ - const avatarColor = $derived.by(() => { - const id = session.user?.id ?? ''; - let hash = 0; - for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0; - return Math.abs(hash) % 5; - }); + /** Deterministic colour bucket 0–4 from the user id (shared with UserVignette). */ + const avatarColor = $derived(avatarColorIndex(session.user?.id)); function closeMenus() { notifOpen = false; diff --git a/frontend/src/lib/components/ShareDialog.svelte b/frontend/src/lib/components/ShareDialog.svelte index 29f0122a..5b97a3c0 100644 --- a/frontend/src/lib/components/ShareDialog.svelte +++ b/frontend/src/lib/components/ShareDialog.svelte @@ -31,6 +31,7 @@ import type { ItemType, ShareItem } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; + import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -74,7 +75,6 @@ /** Representative grant id for notify (any grant on this subject). */ notifyGrantId?: string; expiry: string | null; // YYYY-MM-DD or null - isExternal: boolean; } let members = $state([]); let grantsLoading = $state(false); @@ -115,8 +115,7 @@ role: e.role, grantIds: e.ids, notifyGrantId: e.ids[0], - expiry: e.expiry, - isExternal: false + expiry: e.expiry })); } @@ -452,12 +451,20 @@ class="member" class:member--expired={m.expiry && new Date(m.expiry) < new Date()} > - - - {m.recipient.label} - {#if m.recipient.sublabel}{m.recipient.sublabel}{/if} - + {#if m.subject.type === 'user'} + + {:else} + + + {m.recipient.label} + {#if m.recipient.sublabel}{m.recipient.sublabel}{/if} + + {/if} {@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}