Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
236 lines
8.4 KiB
Svelte
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}
|