feat(shares): show external users with avatar, email and a badge (#500)

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 <UserVignette>; 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) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-20 00:23:54 +02:00
parent 3f887089ae
commit b3bde0d896
5 changed files with 254 additions and 27 deletions
+59
View File
@@ -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<string, Promise<ResolvedUser | null>>();
export function resolveUser(id: string): Promise<ResolvedUser | null> {
const hit = cache.get(id);
if (hit) return hit;
const pending = (async (): Promise<ResolvedUser | null> => {
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;
}