Merge pull request #503 from AtalayaLabs/claude/external-user-vignette

feat(shares): external-user vignette — avatar, email & internal/external badge (#500)
This commit is contained in:
Dionisio Pozo
2026-06-20 00:35:21 +02:00
committed by GitHub
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;
}
+4 -18
View File
@@ -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;
+16 -9
View File
@@ -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<Member[]>([]);
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()}
>
<Icon name={m.subject.type === 'group' ? 'user-group' : 'user'} />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{#if m.subject.type === 'user'}
<UserVignette
userId={m.subject.id}
fallbackLabel={m.recipient.label}
fallbackSublabel={m.recipient.sublabel}
/>
{:else}
<Icon name="user-group" />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{/if}
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
@@ -0,0 +1,150 @@
<script lang="ts">
/**
* Identity chip for a user in share/recipient lists: avatar (uploaded photo
* or coloured initials), display name, email, and an internal-vs-external
* badge. Resolves the profile lazily via `/api/users/{id}` (cached) so
* external users show real details instead of a bare UUID. Falls back to a
* caller-supplied label/sublabel while resolving or when not visible.
*/
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { resolveUser, type ResolvedUser } from '$lib/api/endpoints/users';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
interface Props {
userId: string;
fallbackLabel?: string;
fallbackSublabel?: string;
}
let { userId, fallbackLabel, fallbackSublabel }: Props = $props();
let resolved = $state<ResolvedUser | null>(null);
$effect(() => {
let alive = true;
resolved = null;
void resolveUser(userId).then((u) => {
if (alive) resolved = u;
});
return () => {
alive = false;
};
});
const label = $derived(resolved?.name ?? fallbackLabel ?? userId);
const email = $derived(resolved?.email || fallbackSublabel || '');
const isExternal = $derived(resolved?.isExternal ?? false);
const image = $derived(resolved?.image ?? null);
const colorIndex = $derived(avatarColorIndex(userId));
const initials = $derived(userInitials(label));
</script>
<span class="uv">
<span class="uv__avatar">
{#if image}
<img class="uv__photo" src={image} alt="" />
{:else}
<span class="uv__initials uv__initials--c{colorIndex}">{initials}</span>
{/if}
{#if isExternal}
<span class="uv__badge" title={t('share.externalUser', 'External user')}>
<Icon name="building-circle-xmark" />
</span>
{/if}
</span>
<span class="uv__text">
<span class="uv__name">{label}</span>
{#if email}<span class="uv__email">{email}</span>{/if}
</span>
</span>
<style>
.uv {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.uv__avatar {
position: relative;
flex-shrink: 0;
}
/* Colour buckets mirror AppShell's .avatar--c* (shared userVignette palette). */
.uv__photo,
.uv__initials {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
font-size: var(--text-xs);
font-weight: var(--weight-bold);
}
.uv__photo {
object-fit: cover;
}
.uv__initials--c0 {
background: var(--color-badge-indigo-bg);
color: var(--color-badge-indigo-text);
}
.uv__initials--c1 {
background: var(--color-badge-green-bg);
color: var(--color-badge-green-text);
}
.uv__initials--c2 {
background: var(--color-badge-orange-bg);
color: var(--color-badge-orange-text);
}
.uv__initials--c3 {
background: var(--color-badge-blue-bg);
color: var(--color-badge-blue-text);
}
.uv__initials--c4 {
background: var(--color-badge-amber-bg);
color: var(--color-badge-amber-text);
}
.uv__badge {
position: absolute;
right: -3px;
bottom: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-bg-surface);
color: var(--color-text-muted);
font-size: 9px;
}
.uv__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.uv__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uv__email {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
/**
* Shared avatar helpers — initials + a deterministic colour bucket — so the
* user vignette (shares, recipients) and the app-shell account button render
* identically. Mirrors the original `userVignette` `_initials` / `_colorIndex`.
*/
/** Up-to-two-letter initials from a display label (name or email). */
export function userInitials(label: string | null | undefined): string {
const base = (label ?? '').trim();
if (!base) return '?';
const parts = base.split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return base.slice(0, 2).toUpperCase();
}
/**
* Deterministic colour bucket 0–4 from an id, so the same user always gets the
* same avatar colour (matches the original `userVignette._colorIndex`).
*/
export function avatarColorIndex(id: string | null | undefined): number {
let hash = 0;
const s = id ?? '';
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0;
return Math.abs(hash) % 5;
}