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.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user