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>
+10 -1
View File
@@ -37,6 +37,7 @@
startCheckout,
startCreditPackCheckout,
redirectToCheckout,
canSelfServePurchase,
type StripeStatus
} from "$lib/stripe-billing";
import { CREDIT_PACKS } from "$lib/components/pricing/credit-packs";
@@ -255,8 +256,12 @@
);
const atProductLimit = $derived(Boolean(credits?.at_product_limit) && !enterprise);
const nextUpgradePlan = $derived(nextSelfServeUpgradePlan(planName));
const purchasesEnabled = $derived(
canSelfServePurchase(stripe, { isPlatformAdmin })
);
const showQuickUpgrade = $derived(
canManageBilling &&
purchasesEnabled &&
!enterprise &&
!payg &&
!isLegacyPlan(plan) &&
@@ -450,6 +455,10 @@
<Alert message={error} />
<Alert tone="success" message={success} />
{#if canManageBilling && !purchasesEnabled}
<Alert tone="info" message={i18n.t("billing.checkoutUnavailable")} />
{/if}
{#if recovery}
<UpgradeBanner
tone={recovery.tone}
@@ -615,7 +624,7 @@
</section>
{/if}
{#if canManageBilling && !enterprise && planAssigned}
{#if canManageBilling && purchasesEnabled && !enterprise && planAssigned}
<section class="space-y-3 rounded-lg border border-border bg-card px-4 py-4">
<div>
<h2 class="text-base font-semibold tracking-tight">{i18n.t("billing.buyCreditPacks")}</h2>
+14 -2
View File
@@ -20,6 +20,7 @@
fetchStripeStatus,
redirectToCheckout,
startCheckout,
canSelfServePurchase,
type StripeStatus
} from "$lib/stripe-billing";
import type { CreditBalance, MeResponse } from "$lib/types";
@@ -74,6 +75,7 @@
let apiAvailable = $state(true);
let checkoutBusy = $state<string | null>(null);
let canManageBilling = $state(false);
let isPlatformAdmin = $state(false);
function apiFor(name: string): ApiPlan | undefined {
const key = name.trim().toLowerCase();
@@ -99,6 +101,7 @@
apiPlans = (plansPayload?.plans ?? []).filter((p) => isPublicProductPlan(p.name));
stripe = stripeRes;
canManageBilling = isCompanyAdmin(me);
isPlatformAdmin = Boolean(me.user?.is_platform_admin);
const bal: CreditBalance | null =
me.credits ?? (await api<CreditBalance>("/api/billing/credits").catch(() => null));
@@ -124,6 +127,7 @@
const currentName = $derived(hasPlan ? planNameOf(currentPlan).toLowerCase() : "");
const remainingLabel = $derived(formatCreditsRemaining(remainingCredits, hasPlan ? currentPlan : null));
const currentIsPayg = $derived(hasPlan && isPayAsYouGoPlan(currentPlan));
const purchasesEnabled = $derived(canSelfServePurchase(stripe, { isPlatformAdmin }));
const displayPlans = $derived.by(() => {
return PRICING_PLANS.map((marketing) => {
@@ -152,10 +156,14 @@
ctaMode = "link";
ctaHref = "/billing";
} else if (selfServe) {
if (canManageBilling) {
if (canManageBilling && purchasesEnabled) {
ctaText = i18n.t("plans.cta.upgradeTo", { name });
ctaMode = "checkout";
ctaHref = "/billing";
} else if (canManageBilling) {
ctaText = i18n.t("plans.cta.contactSales");
ctaMode = "link";
ctaHref = `${CONTACT_SALES_HREF}?source=plans`;
} else {
ctaText = i18n.t("common.askAdmin");
ctaMode = "link";
@@ -238,6 +246,10 @@
<Alert tone="info" message={i18n.t("plans.apiUnavailable")} />
{/if}
{#if canManageBilling && !purchasesEnabled}
<Alert tone="info" message={i18n.t("plans.checkoutUnavailable")} />
{/if}
<div class="space-y-4 text-center">
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("plans.title")}</h1>
<p class="mx-auto max-w-2xl text-xl text-muted-foreground">
@@ -260,7 +272,7 @@
{i18n.t("plans.sub.none")}
{/if}
</p>
{#if stripe?.configured && !stripe?.mock}
{#if purchasesEnabled && stripe?.configured && !stripe?.mock}
<p class="text-sm text-muted-foreground">{i18n.t("plans.stripeHint")}</p>
{/if}
</div>