This commit is contained in:
2026-08-16 11:37:42 +02:00
parent dc9ea628c1
commit 52f27e30fc
26 changed files with 1137 additions and 56 deletions
+119 -13
View File
@@ -10,6 +10,7 @@
PAGE_SIZE,
STAFF_ROLE_OPTIONS,
assignAdminPlan,
cloneAdminCompanyCatalog,
companyPlanBadge,
isStaffRoleApiUnavailable,
listAdminCompanies,
@@ -24,6 +25,7 @@
type AdminOrgUser,
type PlatformStaffRole
} from "$lib/admin-orgs";
import { authSession } from "$lib/auth-session.svelte";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
@@ -52,7 +54,7 @@
TabsList,
TabsTrigger
} from "$lib/components/ui";
import { Building2, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
import { Building2, Copy, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies";
@@ -88,11 +90,22 @@
let assignCompany = $state<AdminOrgCompany | null>(null);
let assignPlanId = $state("");
let cloneOpen = $state(false);
let cloneCompany = $state<AdminOrgCompany | null>(null);
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1);
const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE)));
const cloneDestLabel = $derived.by(() => {
const me = authSession.me;
const home = me?.staff_home_company;
if (home?.name) return home.name;
if (me?.company?.name) return me.company.name;
return i18n.t("admin.users.cloneDestFallback");
});
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
@@ -342,6 +355,44 @@
}
}
function openCloneDialog(company: AdminOrgCompany) {
cloneCompany = company;
cloneOpen = true;
error = "";
success = "";
}
async function confirmCloneCatalog() {
if (!cloneCompany) return;
busy = true;
error = "";
success = "";
try {
const me = authSession.me;
const destId =
me?.staff_home_company_id?.trim() ||
me?.staff_home_company?.id?.trim() ||
undefined;
const res = await cloneAdminCompanyCatalog(cloneCompany.id, {
destCompanyId: destId
});
const products = Number(res.counts?.raw_products ?? 0);
const categories = Number(res.counts?.categories ?? 0);
success = i18n.t("flash.admin.catalogCloned", {
source: cloneCompany.name,
dest: cloneDestLabel,
products: String(products),
categories: String(categories)
});
cloneOpen = false;
cloneCompany = null;
} catch (err) {
error = failureMessage(err, "Clone catalog failed");
} finally {
busy = false;
}
}
async function sendSetPasswordEmails(userId?: string) {
busy = true;
error = "";
@@ -712,14 +763,28 @@
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
<TableCell stickyRight>
<Button
size="sm"
onclick={() => openAssignDialog(company)}
aria-label={`Assign plan to ${company.name}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
</Button>
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
size="sm"
variant="outline"
onclick={() => openCloneDialog(company)}
aria-label={i18n.t("admin.users.cloneCatalogAria", {
name: company.name
})}
data-testid="admin-clone-catalog"
>
<Copy class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.cloneCatalog")}</span>
</Button>
<Button
size="sm"
onclick={() => openAssignDialog(company)}
aria-label={`Assign plan to ${company.name}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
</Button>
</div>
</TableCell>
</TableRow>
{/each}
@@ -758,8 +823,8 @@
<Dialog
bind:open={passwordOpen}
title="Set password"
description="Force-set a login password for this user (works for fake/legacy emails that cannot receive invites)."
title={i18n.t("admin.users.setPasswordTitle")}
description={i18n.t("admin.users.setPasswordDesc")}
>
<form class="space-y-4" onsubmit={saveForcedPassword}>
{#if error}
@@ -771,7 +836,7 @@
</p>
{/if}
<div class="space-y-2">
<Label for="forced-password">New password</Label>
<Label for="forced-password">{i18n.t("admin.users.newPassword")}</Label>
<Input
id="forced-password"
type="password"
@@ -781,7 +846,9 @@
bind:value={passwordValue}
/>
</div>
<Button type="submit" loading={busyUserId === passwordUser?.id}>Set password</Button>
<Button type="submit" loading={busyUserId === passwordUser?.id}
>{i18n.t("admin.users.setPasswordSubmit")}</Button
>
</form>
</Dialog>
@@ -838,3 +905,42 @@
<Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button>
</form>
</Dialog>
<Dialog
bind:open={cloneOpen}
title={i18n.t("admin.users.cloneCatalogTitle")}
description={i18n.t("admin.users.cloneCatalogDesc")}
>
<div class="space-y-4">
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if cloneCompany}
<p class="text-sm text-foreground">
{i18n.t("admin.users.cloneCatalogFrom", { name: cloneCompany.name })}
</p>
{/if}
<p class="text-sm text-muted-foreground">
{i18n.t("admin.users.cloneCatalogInto", { name: cloneDestLabel })}
</p>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.users.cloneCatalogWarning")}
</p>
<div class="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={busy}
onclick={() => {
cloneOpen = false;
cloneCompany = null;
}}
>
{i18n.t("admin.users.cloneCatalogCancel")}
</Button>
<Button type="button" loading={busy} onclick={() => confirmCloneCatalog()}>
{i18n.t("admin.users.cloneCatalogConfirm")}
</Button>
</div>
</div>
</Dialog>