Files
descrybe/apps/web/src/lib/components/UserSwitcher.svelte
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

236 lines
8.4 KiB
Svelte

<script lang="ts">
import { i18n } from "$lib/i18n";
import { Building2, Check, ChevronDown, Star, Undo2, UserRound } from "@lucide/svelte";
import { api, ApiError } from "$lib/api";
import { notifyApiError } from "$lib/notify";
import type { MeResponse, SwitchableUser } from "$lib/types";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "$lib/components/ui";
let {
me
}: {
me: MeResponse;
} = $props();
let switching = $state(false);
let menuOpen = $state(false);
let loadingUsers = $state(false);
let users = $state<SwitchableUser[]>([]);
let loadError = $state("");
let unavailable = $state(false);
const currentUserId = $derived(me.user?.id ?? "");
const currentEmail = $derived((me.user?.email ?? "").trim());
const companyName = $derived((me.company?.name ?? "").trim() || i18n.t("switcher.noCompany"));
const companyHint = $derived.by(() => {
const n = companyName;
if (/^platform demo$/i.test(n) || /^demo$/i.test(n)) return "Platform Demo";
if (n === "Local Demo Co" || /^a1(\s|$)/i.test(n)) return "A1 Slovenija";
return n;
});
const impersonating = $derived(Boolean(me.impersonating));
const impersonatorEmail = $derived((me.impersonator?.email ?? "").trim() || "admin");
const currentSwitchable = $derived(users.find((u) => u.id === currentUserId) ?? null);
/** Prefer API label, then DB name, then email — never hang on raw @legacy.local alone when name/label exist. */
function displayLabel(user: SwitchableUser | null | undefined): string {
if (!user) return "";
const label = (user.label ?? "").trim();
if (label) return label;
const name = (user.name ?? "").trim();
if (name) return name;
return (user.email ?? "").trim();
}
const triggerLabel = $derived.by(() => {
const primary = displayLabel(currentSwitchable);
if (primary) {
return `${primary} · ${currentSwitchable?.company_label || companyHint}`;
}
const meName = (me.user?.name ?? "").trim();
if (meName) {
return `${meName} · ${companyHint}`;
}
if (currentEmail) {
return `${currentEmail} · ${companyHint}`;
}
return companyHint;
});
/** Compact label for narrow headers — full string stays in title / aria-label. */
const triggerLabelShort = $derived.by(() => {
const company = (currentSwitchable?.company_label || companyHint).trim();
if (company) return company;
return triggerLabel;
});
const grouped = $derived.by(() => {
const map = new Map<string, SwitchableUser[]>();
for (const u of users) {
const key = (u.company_label || u.company_name || i18n.t("switcher.unknownCompany")).trim();
const list = map.get(key) ?? [];
list.push(u);
map.set(key, list);
}
return [...map.entries()];
});
$effect(() => {
if (menuOpen) void ensureUsersLoaded();
});
async function ensureUsersLoaded() {
if (unavailable || loadingUsers) return;
// Reload when labels are missing (stale/old API payload) so hard-refresh isn't required after API restart.
const hasLabels = users.some((u) => Boolean((u.label ?? "").trim() || (u.name ?? "").trim()));
if (users.length > 0 && hasLabels) return;
loadingUsers = true;
loadError = "";
try {
const res = await api<{ users?: SwitchableUser[] }>("/api/admin/dev/switchable-users");
users = res.users ?? [];
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 403)) {
unavailable = true;
loadError = i18n.t("switcher.unavailable");
} else {
loadError = i18n.t("switcher.loadFailed");
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.loadUsersFailed") });
}
} finally {
loadingUsers = false;
}
}
async function switchToUser(userId: string) {
if (!userId || userId === currentUserId || switching) return;
switching = true;
try {
await api(`/api/admin/users/${userId}/impersonate`, { method: "POST", body: {} });
window.location.assign(window.location.pathname + window.location.search);
} catch (err) {
switching = false;
if (err instanceof ApiError && err.status === 404) {
unavailable = true;
}
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.switchFailed") });
}
}
async function returnToActor() {
if (!impersonating || switching) return;
switching = true;
try {
await api("/api/admin/dev/stop-impersonate", { method: "POST", body: {} });
window.location.assign("/dashboard");
} catch (err) {
switching = false;
notifyApiError(err, i18n.t("toast.requestFailed"), { title: i18n.t("toast.switcher.returnFailed") });
}
}
function optionTitle(user: SwitchableUser): string {
const parts = [displayLabel(user), user.subtitle, user.email, user.company_label || user.company_name];
return [...new Set(parts.filter(Boolean))].join(" · ");
}
</script>
{#if !unavailable}
<div class="w-full min-w-0 max-w-full">
<DropdownMenu
bind:open={menuOpen}
align="end"
class="max-h-[min(24rem,70vh)] min-w-[min(20rem,calc(100vw-1.5rem))] max-w-[min(30rem,calc(100vw-1.5rem))] overflow-y-auto"
>
{#snippet trigger({ open, toggle })}
<button
type="button"
class="inline-flex h-10 w-full max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60 sm:h-8 sm:px-2.5"
onclick={toggle}
disabled={switching}
aria-label={i18n.t("switcher.switchUserAria", { label: triggerLabel })}
aria-expanded={open}
aria-haspopup="menu"
data-tour="user-switcher"
title={triggerLabel}
>
{#if impersonating}
<UserRound class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
{:else}
<Building2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{/if}
<span class="min-w-0 truncate">{switching ? i18n.t("switcher.switching") : triggerLabelShort}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
{/snippet}
{#if impersonating}
<DropdownMenuItem onclick={() => void returnToActor()}>
<Undo2 class="h-3.5 w-3.5 text-primary" />
<span class="truncate">{i18n.t("switcher.returnTo", { email: impersonatorEmail })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/if}
<DropdownMenuLabel>{i18n.t("switcher.switchUser")}</DropdownMenuLabel>
{#if loadingUsers}
<div class="px-2 py-1.5 text-xs text-muted-foreground">{i18n.t("switcher.loadingUsers")}</div>
{:else if loadError}
<div class="px-2 py-1.5 text-xs text-destructive">{loadError}</div>
{:else if users.length === 0}
<div class="px-2 py-1.5 text-xs text-muted-foreground">{i18n.t("switcher.empty")}</div>
{:else}
{#each grouped as [groupName, groupUsers] (groupName)}
<DropdownMenuSeparator />
<DropdownMenuLabel>
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{groupName}</span>
</DropdownMenuLabel>
{#each groupUsers as user (user.id)}
<DropdownMenuItem
onclick={() => void switchToUser(user.id)}
disabled={user.id === currentUserId}
class={`items-start ${user.id === currentUserId ? "bg-accent/60" : ""} ${user.is_primary_a1 ? "border-l-2 border-amber-500/70" : ""}`}
>
{#if user.id === currentUserId}
<Check class="mt-0.5 h-3.5 w-3.5 text-primary" />
{:else if user.is_primary_a1}
<Star class="mt-0.5 h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
{:else}
<span class="mt-0.5 inline-block h-3.5 w-3.5"></span>
{/if}
<span class="min-w-0 flex-1" title={optionTitle(user)}>
<span class="block truncate font-medium leading-tight">{displayLabel(user)}</span>
{#if user.subtitle}
<span class="block truncate text-[11px] text-muted-foreground leading-tight">
{user.subtitle}
</span>
{:else if user.email && displayLabel(user) !== user.email}
<span class="block truncate text-[11px] text-muted-foreground leading-tight">
{user.email}
</span>
{/if}
</span>
</DropdownMenuItem>
{/each}
{/each}
{/if}
</DropdownMenu>
</div>
{:else}
<div
class="inline-flex h-8 max-w-[12rem] items-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium text-muted-foreground sm:max-w-[18rem] sm:px-2.5"
data-tour="company-switcher"
title={companyHint}
>
<Building2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{companyHint}</span>
</div>
{/if}