Files
descrybe/apps/web/src/routes/admin/users/+page.svelte
T

1188 lines
35 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import {
PAGE_SIZE,
2026-08-16 18:49:02 +02:00
PLATFORM_DEMO_COMPANY_NAME,
STAFF_ROLE_OPTIONS,
assignAdminPlan,
2026-08-16 11:37:42 +02:00
cloneAdminCompanyCatalog,
2026-08-16 18:36:56 +02:00
syncAdminCompanyA1,
companyPlanBadge,
isStaffRoleApiUnavailable,
listAdminCompanies,
listAdminPlansForAssign,
listAdminUsers,
planOptionLabel,
setAdminStaffRole,
staffRoleBadgeVariant,
staffRoleLabel,
type AdminBillingPlan,
type AdminOrgCompany,
type AdminOrgUser,
type PlatformStaffRole
} from "$lib/admin-orgs";
2026-08-16 11:37:42 +02:00
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";
import EmptyState from "$lib/components/EmptyState.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "$lib/components/ui";
2026-08-16 18:36:56 +02:00
import { Building2, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies";
let loading = $state(true);
let accessDenied = $state(false);
let busy = $state(false);
let busyUserId = $state<string | null>(null);
let error = $state("");
let success = $state("");
2026-08-17 00:39:25 +02:00
let inviteAcceptLink = $state<string | null>(null);
let tab = $state<TabKey>("users");
let search = $state("");
let staffOnly = $state(false);
let withoutPlan = $state(false);
let withoutApiKeys = $state(false);
let users = $state<AdminOrgUser[]>([]);
let usersTotal = $state(0);
let usersOffset = $state(0);
let companies = $state<AdminOrgCompany[]>([]);
let companiesTotal = $state(0);
let companiesOffset = $state(0);
let plans = $state<AdminBillingPlan[]>([]);
2026-08-14 01:38:34 +02:00
let userOps = $state(false);
let staffRoleApiOk = $state(true);
2026-08-14 01:38:34 +02:00
let passwordOpen = $state(false);
let passwordUser = $state<AdminOrgUser | null>(null);
let passwordValue = $state("");
let roleOpen = $state(false);
let roleUser = $state<AdminOrgUser | null>(null);
let roleValue = $state<"" | PlatformStaffRole>("");
let assignOpen = $state(false);
let assignCompany = $state<AdminOrgCompany | null>(null);
let assignPlanId = $state("");
2026-08-16 11:37:42 +02:00
let cloneOpen = $state(false);
let cloneCompany = $state<AdminOrgCompany | null>(null);
2026-08-16 18:49:02 +02:00
let cloneDestId = $state("");
let cloneDestOptions = $state<AdminOrgCompany[]>([]);
2026-08-16 11:37:42 +02:00
2026-08-16 18:36:56 +02:00
let syncOpen = $state(false);
let syncCompany = $state<AdminOrgCompany | null>(null);
2026-08-17 00:39:25 +02:00
let syncWpCategoriesFile = $state<File | null>(null);
2026-08-16 16:57:36 +02:00
2026-08-16 18:49:02 +02:00
const cloneDestLabel = $derived.by(() => {
const selected = cloneDestOptions.find((c) => c.id === cloneDestId);
if (selected?.name) return selected.name;
return i18n.t("admin.users.cloneDestFallback");
});
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)));
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
loading = false;
return;
}
error = gate.message;
loading = false;
return;
}
const initialTab = page.url.searchParams.get("tab");
if (initialTab === "companies") tab = "companies";
if (page.url.searchParams.get("without_api_keys") === "1") {
withoutApiKeys = true;
tab = "companies";
}
if (page.url.searchParams.get("without_active_plan") === "1") {
withoutPlan = true;
tab = "companies";
}
try {
await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]);
2026-08-14 01:38:34 +02:00
// Platform admins can set passwords + impersonate (API enforces staff_role=admin for switch).
userOps = true;
} catch (err) {
error = failureMessage(err, "Failed to load directory");
} finally {
loading = false;
}
});
async function loadPlans() {
try {
plans = await listAdminPlansForAssign();
} catch {
plans = [];
}
}
async function reloadUsers() {
const res = await listAdminUsers({
limit: PAGE_SIZE,
offset: usersOffset,
q: search,
staff_only: staffOnly
});
users = res.users;
usersTotal = res.total;
usersOffset = res.offset;
}
async function reloadCompanies() {
const res = await listAdminCompanies({
limit: PAGE_SIZE,
offset: companiesOffset,
q: search,
without_active_plan: withoutPlan,
without_api_keys: withoutApiKeys
});
companies = res.companies;
companiesTotal = res.total;
companiesOffset = res.offset;
}
async function applySearch() {
busy = true;
error = "";
usersOffset = 0;
companiesOffset = 0;
try {
if (tab === "users") await reloadUsers();
else await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Search failed");
} finally {
busy = false;
}
}
async function changeUsersPage(delta: number) {
const next = Math.max(0, usersOffset + delta * PAGE_SIZE);
if (next >= usersTotal && usersTotal > 0) return;
usersOffset = next;
busy = true;
error = "";
try {
await reloadUsers();
} catch (err) {
error = failureMessage(err, "Failed to load users");
} finally {
busy = false;
}
}
async function changeCompaniesPage(delta: number) {
const next = Math.max(0, companiesOffset + delta * PAGE_SIZE);
if (next >= companiesTotal && companiesTotal > 0) return;
companiesOffset = next;
busy = true;
error = "";
try {
await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Failed to load companies");
} finally {
busy = false;
}
}
async function onTabChange(value: string) {
tab = value === "companies" ? "companies" : "users";
error = "";
busy = true;
try {
if (tab === "users") await reloadUsers();
else await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Failed to load");
} finally {
busy = false;
}
}
async function toggleStaffOnly() {
staffOnly = !staffOnly;
usersOffset = 0;
busy = true;
error = "";
try {
await reloadUsers();
} catch (err) {
error = failureMessage(err, "Failed to filter users");
} finally {
busy = false;
}
}
async function toggleWithoutPlan() {
const next = !withoutPlan;
withoutPlan = next;
companiesOffset = 0;
busy = true;
error = "";
try {
await reloadCompanies();
// Keep toggle in sync if a concurrent reload raced (should be rare).
withoutPlan = next;
} catch (err) {
withoutPlan = !next;
error = failureMessage(err, "Failed to filter companies");
} finally {
busy = false;
}
}
async function toggleWithoutApiKeys() {
const next = !withoutApiKeys;
withoutApiKeys = next;
companiesOffset = 0;
busy = true;
error = "";
try {
await reloadCompanies();
withoutApiKeys = next;
} catch (err) {
withoutApiKeys = !next;
error = failureMessage(err, "Failed to filter companies");
} finally {
busy = false;
}
}
function openRoleDialog(user: AdminOrgUser) {
roleUser = user;
const resolved = (user.staff_role || user.resolved_role || "") as "" | PlatformStaffRole;
roleValue =
resolved === "admin" || resolved === "developer" || resolved === "support_staff"
? resolved
: "";
roleOpen = true;
}
async function saveStaffRole(event: Event) {
event.preventDefault();
if (!roleUser) return;
busy = true;
error = "";
success = "";
try {
const updated = await setAdminStaffRole(roleUser.id, roleValue);
users = users.map((u) =>
u.id === updated.id
? {
...u,
...updated,
staff_role: updated.staff_role,
resolved_role: updated.resolved_role,
is_platform_admin: updated.is_platform_admin
}
: u
);
success = i18n.t("flash.admin.staffRoleUpdated", { email: updated.email });
roleOpen = false;
staffRoleApiOk = true;
} catch (err) {
if (isStaffRoleApiUnavailable(err)) {
staffRoleApiOk = false;
error = i18n.t("flash.admin.staffRoleUnavailable");
} else {
error = failureMessage(err, "Could not update staff role");
}
} finally {
busy = false;
}
}
function openAssignDialog(company: AdminOrgCompany) {
assignCompany = company;
assignPlanId = company.plan_id != null ? String(company.plan_id) : "";
assignOpen = true;
}
async function saveAssignPlan(event: Event) {
event.preventDefault();
if (!assignCompany || !assignPlanId) return;
busy = true;
error = "";
success = "";
try {
await assignAdminPlan({
company_id: assignCompany.id,
plan_id: Number(assignPlanId)
});
success = i18n.t("flash.admin.planAssigned", { name: assignCompany.name });
assignOpen = false;
await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Assign plan failed");
} finally {
busy = false;
}
}
2026-08-16 18:49:02 +02:00
async function openCloneDialog(company: AdminOrgCompany) {
2026-08-16 11:37:42 +02:00
cloneCompany = company;
cloneOpen = true;
error = "";
success = "";
2026-08-16 18:49:02 +02:00
cloneDestId = "";
const byId = new Map<string, AdminOrgCompany>();
for (const c of companies) {
if (c.id !== company.id) byId.set(c.id, c);
}
try {
const demoRes = await listAdminCompanies({
limit: PAGE_SIZE,
offset: 0,
q: PLATFORM_DEMO_COMPANY_NAME
});
for (const c of demoRes.companies) {
if (c.id !== company.id) byId.set(c.id, c);
}
} catch {
/* keep page companies as fallback */
}
const me = authSession.me;
const home = me?.staff_home_company;
if (home?.id && home.id !== company.id && home.name) {
byId.set(home.id, { id: home.id, name: home.name });
}
for (const c of me?.companies ?? []) {
if (c.id && c.id !== company.id && c.name) {
byId.set(c.id, { id: c.id, name: c.name });
}
}
const opts = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
cloneDestOptions = opts;
const demo =
opts.find((c) => c.name.trim().toLowerCase() === PLATFORM_DEMO_COMPANY_NAME.toLowerCase()) ??
null;
const homeId = (me?.staff_home_company_id || me?.staff_home_company?.id || "").trim();
cloneDestId =
demo?.id ||
(homeId && homeId !== company.id ? homeId : "") ||
opts[0]?.id ||
"";
2026-08-16 11:37:42 +02:00
}
2026-08-16 18:36:56 +02:00
function openSyncDialog(company: AdminOrgCompany) {
syncCompany = company;
2026-08-17 00:39:25 +02:00
syncWpCategoriesFile = null;
2026-08-16 18:36:56 +02:00
syncOpen = true;
2026-08-16 16:57:36 +02:00
error = "";
success = "";
}
2026-08-16 11:37:42 +02:00
async function confirmCloneCatalog() {
if (!cloneCompany) return;
2026-08-16 18:49:02 +02:00
const destId = cloneDestId.trim();
if (!destId || destId === cloneCompany.id) {
error = i18n.t("admin.users.cloneDestRequired");
return;
}
2026-08-16 11:37:42 +02:00
busy = true;
error = "";
success = "";
try {
const res = await cloneAdminCompanyCatalog(cloneCompany.id, {
destCompanyId: destId
});
const products = Number(res.counts?.raw_products ?? 0);
const categories = Number(res.counts?.categories ?? 0);
2026-08-16 18:18:39 +02:00
const mappedWith = Number(res.counts?.mapped_with_category ?? 0);
2026-08-16 12:00:28 +02:00
const activeDest = (res.active_company_id || res.dest_company_id || "").trim();
2026-08-16 11:37:42 +02:00
success = i18n.t("flash.admin.catalogCloned", {
source: cloneCompany.name,
dest: cloneDestLabel,
products: String(products),
2026-08-16 18:18:39 +02:00
categories: String(categories),
mapped_with: String(mappedWith)
2026-08-16 11:37:42 +02:00
});
cloneOpen = false;
cloneCompany = null;
2026-08-16 18:49:02 +02:00
cloneDestId = "";
cloneDestOptions = [];
2026-08-16 12:00:28 +02:00
if (activeDest) {
try {
await api("/api/auth/select-company", {
method: "POST",
body: { company_id: activeDest }
});
} catch {
/* clone handler may already have switched the session */
}
window.location.assign("/products");
return;
}
2026-08-16 11:37:42 +02:00
} catch (err) {
error = failureMessage(err, "Clone catalog failed");
} finally {
busy = false;
}
}
2026-08-16 18:49:02 +02:00
async function confirmSyncA1() {
if (!syncCompany) return;
busy = true;
error = "";
success = "";
const targetName = syncCompany.name;
try {
const res = await syncAdminCompanyA1(syncCompany.id, {
2026-08-17 00:39:25 +02:00
reprocessSampleLimit: 25,
wpProductCategoriesFile: syncWpCategoriesFile
2026-08-16 18:49:02 +02:00
});
const r = res.result;
const mappedWith = Number(r.mapped_with_category ?? 0);
const mappedWithout = Number(r.mapped_without_category ?? 0);
const mappedTotal = mappedWith + mappedWithout;
const dumpStatus = String(r.dump_status ?? (r.dump_found ? "ok" : "missing"));
success = i18n.t("flash.admin.syncA1Success", {
name: targetName,
prompts: String(r.prompts ?? r.category_prompts_updated ?? 0),
hashes: String(r.hashes ?? r.weak_hashes_cleared ?? 0),
categories: String(r.categories ?? r.categories_backfilled ?? 0),
mapped_backfilled: String(r.mapped_backfilled ?? r.mapped_categories_backfilled ?? 0),
mapped_with: String(mappedWith),
mapped_total: String(mappedTotal),
taxonomy: String(r.taxonomy_categories ?? 0),
dump_mapped: String(r.dump_mapped_updated ?? 0),
dump_status: dumpStatus
});
syncOpen = false;
syncCompany = null;
2026-08-17 00:39:25 +02:00
syncWpCategoriesFile = null;
2026-08-16 18:49:02 +02:00
} catch (err) {
error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
} finally {
busy = false;
}
}
async function sendSetPasswordEmails(userId?: string) {
busy = true;
error = "";
success = "";
2026-08-17 00:39:25 +02:00
inviteAcceptLink = null;
try {
const body = userId ? { user_id: userId } : {};
const res = await api<{
sent: number;
issued?: number;
skipped: number;
skipped_synthetic?: number;
skipped_rate_limited?: number;
smtp_enabled: boolean;
token?: string;
2026-08-17 00:39:25 +02:00
accept_url?: string;
}>("/api/admin/emails/set-password", { method: "POST", body });
const parts = [
`sent ${res.sent}`,
`issued ${res.issued ?? 0}`,
`skipped ${res.skipped}`
];
if (res.skipped_synthetic) parts.push(`skipped test accounts ${res.skipped_synthetic}`);
if (res.skipped_rate_limited) parts.push(`rate-limited ${res.skipped_rate_limited}`);
2026-08-17 00:39:25 +02:00
success = i18n.t("flash.admin.invitesSummary", {
parts: parts.join(", "),
smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff")
});
const link =
res.accept_url?.trim() ||
(res.token
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(res.token)}`
: "");
if (link) {
inviteAcceptLink = link;
success += " " + i18n.t("admin.users.inviteLinkReady");
}
} catch (err) {
error = failureMessage(err, "Send failed");
} finally {
busy = false;
}
}
2026-08-17 00:39:25 +02:00
async function copyInviteLink() {
if (!inviteAcceptLink) return;
try {
await navigator.clipboard.writeText(inviteAcceptLink);
success = i18n.t("admin.users.inviteLinkCopied");
error = "";
} catch {
error = i18n.t("flash.settings.copyFailed");
}
}
2026-08-14 01:38:34 +02:00
function openPasswordDialog(user: AdminOrgUser) {
passwordUser = user;
passwordValue = "";
passwordOpen = true;
}
async function saveForcedPassword(event: Event) {
event.preventDefault();
if (!passwordUser) return;
const pwd = passwordValue.trim();
if (pwd.length < 8) {
error = "Password must be at least 8 characters";
return;
}
busyUserId = passwordUser.id;
error = "";
success = "";
try {
2026-08-14 01:38:34 +02:00
const res = await api<{ email?: string }>(`/api/admin/users/${passwordUser.id}/dev-password`, {
method: "POST",
2026-08-14 01:38:34 +02:00
body: { password: pwd }
});
2026-08-14 01:38:34 +02:00
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? passwordUser.email });
users = users.map((u) =>
u.id === passwordUser!.id ? { ...u, must_set_password: false } : u
);
passwordOpen = false;
passwordUser = null;
passwordValue = "";
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
2026-08-14 01:38:34 +02:00
userOps = false;
error = i18n.t("flash.admin.localPasswordUnavailable");
} else {
2026-08-14 01:38:34 +02:00
error = failureMessage(err, "Could not set password");
}
} finally {
busyUserId = null;
}
}
async function switchToUser(userId: string) {
busyUserId = userId;
error = "";
success = "";
try {
await api(`/api/admin/users/${userId}/impersonate`, { method: "POST", body: {} });
success = i18n.t("flash.admin.switchedUser");
window.location.assign("/dashboard");
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
2026-08-14 01:38:34 +02:00
userOps = false;
error = i18n.t("flash.admin.switchUnavailable");
} else {
error = failureMessage(err, "Could not switch user");
}
busyUserId = null;
}
}
function displayRole(user: AdminOrgUser): string {
return staffRoleLabel(user.staff_role || user.resolved_role);
}
</script>
<PageShell
title={i18n.t("admin.users.title")}
description={i18n.t("admin.users.description")}
eyebrow={i18n.t("admin.users.eyebrow")}
>
{#snippet actions()}
<Button size="sm" variant="outline" loading={busy} onclick={() => sendSetPasswordEmails()}>
<UserPlus class="mr-2 h-4 w-4" />
Re-issue set-password invites
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
2026-08-17 00:39:25 +02:00
{#if inviteAcceptLink}
<div class="mb-4 flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 sm:flex-row sm:items-center">
<Input
value={inviteAcceptLink}
readonly
autocomplete="off"
spellcheck={false}
class="font-mono text-xs"
aria-label={i18n.t("admin.users.inviteLinkLabel")}
/>
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
<Copy class="mr-2 h-4 w-4" />
{i18n.t("admin.users.copyInviteLink")}
</Button>
</div>
{/if}
{#if !staffRoleApiOk}
<Alert
tone="info"
message={i18n.t("admin.users.staffRoleUnavailable")}
/>
{/if}
<div class="mb-4 flex flex-wrap items-end gap-3">
<div class="relative min-w-0 flex-1 basis-full sm:basis-auto">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={tab === "users" ? "Search users..." : "Search companies..."}
class="h-9 pl-8"
aria-label={tab === "users" ? "Search users" : "Search companies"}
bind:value={search}
onkeydown={(e) => {
if (e.key === "Enter") void applySearch();
}}
/>
</div>
<Button size="sm" variant="outline" loading={busy} onclick={() => applySearch()}>{i18n.t("admin.users.search")}</Button>
{#if tab === "users"}
<Button
size="sm"
variant={staffOnly ? "default" : "outline"}
aria-pressed={staffOnly}
onclick={() => toggleStaffOnly()}
>
Staff only
</Button>
{:else}
<Button
size="sm"
variant={withoutPlan ? "default" : "outline"}
aria-pressed={withoutPlan}
onclick={() => toggleWithoutPlan()}
>
{i18n.t("admin.users.filterWithoutPlan")}
</Button>
<Button
size="sm"
variant={withoutApiKeys ? "default" : "outline"}
aria-pressed={withoutApiKeys}
data-testid="admin-companies-without-api-keys"
onclick={() => toggleWithoutApiKeys()}
>
{i18n.t("admin.users.filterWithoutApiKeys")}
</Button>
{/if}
</div>
<Tabs
value={tab}
onValueChange={(v) => onTabChange(v)}
class="mb-8 w-full"
>
<TabsList class="mb-4 grid w-full max-w-md grid-cols-2">
<TabsTrigger value="users">{i18n.t("admin.users.tabUsers")}</TabsTrigger>
<TabsTrigger value="companies">{i18n.t("admin.users.tabCompanies")}</TabsTrigger>
</TabsList>
<TabsContent value="users" class="mt-0">
<Card>
<CardHeader class="pb-2">
<CardTitle>Users ({formatCredits(usersTotal)})</CardTitle>
<CardDescription>
Assign platform staff roles (Admin, Developer, or Support staff).
</CardDescription>
</CardHeader>
<CardContent>
{#if users.length === 0}
<EmptyState message={i18n.t("empty.admin.noUsersMatch")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.users.colName")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.users.colStaffRole")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colStatus")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colCreated")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.users.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each users as user}
{@const role = user.staff_role || user.resolved_role}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{user.name || "—"}</p>
<p class="text-xs text-muted-foreground">{user.email}</p>
<div class="mt-1 flex flex-wrap gap-1 md:hidden">
<Badge variant={staffRoleBadgeVariant(role)}>{displayRole(user)}</Badge>
{#if user.must_set_password}
<Badge variant="warning">{i18n.t("admin.users.mustSetPassword")}</Badge>
{/if}
{#if user.is_active === false}
<Badge variant="outline">{i18n.t("admin.users.inactive")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden md:table-cell">
<Badge variant={staffRoleBadgeVariant(role)}>{displayRole(user)}</Badge>
{#if user.must_set_password}
<Badge variant="warning" class="ml-1">{i18n.t("admin.users.mustSetPassword")}</Badge>
{/if}
</TableCell>
<TableCell class="hidden lg:table-cell">
{#if user.is_active !== false}
<span class="inline-flex items-center gap-2 text-sm">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
Active
</span>
{:else}
<Badge variant="outline">{i18n.t("admin.users.inactive")}</Badge>
{/if}
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{formatDate(user.created_at)}</TableCell>
<TableCell stickyRight>
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
size="sm"
variant="outline"
onclick={() => openRoleDialog(user)}
aria-label={`Staff role for ${user.email}`}
>
<Shield class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.colStaffRole")}</span>
</Button>
{#if user.must_set_password}
<Button
size="sm"
variant="outline"
loading={busy}
onclick={() => sendSetPasswordEmails(user.id)}
aria-label={`Re-issue invite for ${user.email}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.reissueInvite")}</span>
</Button>
{/if}
2026-08-14 01:38:34 +02:00
{#if userOps}
<Button
size="sm"
variant="outline"
loading={busyUserId === user.id}
2026-08-14 01:38:34 +02:00
onclick={() => openPasswordDialog(user)}
aria-label={`Set password for ${user.email}`}
>
<KeyRound class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.setLocalPassword")}</span>
</Button>
<Button
size="sm"
loading={busyUserId === user.id}
onclick={() => switchToUser(user.id)}
aria-label={`Switch to ${user.email}`}
>
<Users class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.switchToUser")}</span>
</Button>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
Page {usersPage} of {usersPages} · {formatCredits(usersTotal)} total
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={usersOffset <= 0 || busy}
onclick={() => changeUsersPage(-1)}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={usersOffset + PAGE_SIZE >= usersTotal || busy}
onclick={() => changeUsersPage(1)}
>
Next
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="companies" class="mt-0">
<Card>
<CardHeader class="pb-2">
<div class="flex items-center gap-2">
<Building2 class="h-5 w-5 text-muted-foreground" />
<CardTitle>Companies ({formatCredits(companiesTotal)})</CardTitle>
</div>
<CardDescription>
View active plan (public / legacy / custom) and assign a plan. Credits from company balance.
</CardDescription>
</CardHeader>
<CardContent>
{#if companies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesMatchFilter")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.users.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.users.colPlan")}</TableHead>
<TableHead class="hidden sm:table-cell">{i18n.t("admin.users.colCredits")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colLanguage")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.users.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each companies as company}
{@const planBadge = companyPlanBadge(company)}
{@const total = Number(company.total_credits ?? 0)}
{@const used = Number(company.used_credits ?? 0)}
{@const remaining = Math.max(total - used, 0)}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{company.name}</p>
<p class="truncate font-mono text-xs text-muted-foreground" title={company.id}>
{company.id}
</p>
<p class="mt-1 text-xs tabular-nums text-muted-foreground sm:hidden">
{formatCredits(remaining)} / {formatCredits(total)} credits
</p>
</TableCell>
<TableCell>
<div class="flex flex-wrap items-center gap-1.5">
<Badge variant={planBadge.variant}>{planBadge.label}</Badge>
{#if company.has_api_key === false}
<Badge variant="warning">{i18n.t("admin.users.needsApiKeyReissue")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden tabular-nums text-foreground sm:table-cell">
{formatCredits(remaining)}
<span class="text-xs text-muted-foreground">
/ {formatCredits(total)}
</span>
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
<TableCell stickyRight>
2026-08-16 16:57:36 +02:00
<div class="flex flex-wrap items-center justify-end gap-1">
2026-08-16 18:36:56 +02:00
<Button
size="sm"
variant="outline"
onclick={() => openSyncDialog(company)}
aria-label={i18n.t("admin.users.syncA1Aria", {
name: company.name
})}
data-testid="admin-sync-a1"
>
<RefreshCw class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.syncA1")}</span>
</Button>
2026-08-16 16:57:36 +02:00
<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}
</TableBody>
</TableShell>
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
Page {companiesPage} of {companiesPages} · {formatCredits(companiesTotal)} total
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={companiesOffset <= 0 || busy}
onclick={() => changeCompaniesPage(-1)}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={companiesOffset + PAGE_SIZE >= companiesTotal || busy}
onclick={() => changeCompaniesPage(1)}
>
Next
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
</PageShell>
2026-08-14 01:38:34 +02:00
<Dialog
bind:open={passwordOpen}
2026-08-16 11:37:42 +02:00
title={i18n.t("admin.users.setPasswordTitle")}
description={i18n.t("admin.users.setPasswordDesc")}
2026-08-14 01:38:34 +02:00
>
<form class="space-y-4" onsubmit={saveForcedPassword}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if passwordUser}
<p class="text-sm text-muted-foreground">
{passwordUser.name || "—"} · {passwordUser.email}
</p>
{/if}
<div class="space-y-2">
2026-08-16 11:37:42 +02:00
<Label for="forced-password">{i18n.t("admin.users.newPassword")}</Label>
2026-08-14 01:38:34 +02:00
<Input
id="forced-password"
type="password"
autocomplete="new-password"
minlength={8}
required
bind:value={passwordValue}
/>
</div>
2026-08-16 11:37:42 +02:00
<Button type="submit" loading={busyUserId === passwordUser?.id}
>{i18n.t("admin.users.setPasswordSubmit")}</Button
>
2026-08-14 01:38:34 +02:00
</form>
</Dialog>
<Dialog
bind:open={roleOpen}
title={i18n.t("admin.users.assignRoleTitle")}
description={i18n.t("admin.users.assignRoleDesc")}
>
<form class="space-y-4" onsubmit={saveStaffRole}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if roleUser}
<p class="text-sm text-muted-foreground">
{roleUser.name || "—"} · {roleUser.email}
</p>
{/if}
<div class="space-y-2">
<Label for="staff-role">{i18n.t("admin.users.staffRole")}</Label>
<Select id="staff-role" bind:value={roleValue}>
{#each STAFF_ROLE_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</Select>
</div>
<p class="text-xs text-muted-foreground">
You cannot change your own staff role. Clearing a role removes platform admin access.
</p>
<Button type="submit" loading={busy}>{i18n.t("admin.users.saveRole")}</Button>
</form>
</Dialog>
<Dialog
bind:open={assignOpen}
title={i18n.t("admin.users.assignPlanTitle")}
description={i18n.t("admin.users.assignPlanDesc")}
>
<form class="space-y-4" onsubmit={saveAssignPlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if assignCompany}
<p class="text-sm text-muted-foreground">{assignCompany.name}</p>
{/if}
<div class="space-y-2">
<Label for="assign-plan">{i18n.t("admin.users.colPlan")}</Label>
<Select id="assign-plan" bind:value={assignPlanId} required>
<option value="">{i18n.t("admin.users.selectPlan")}</option>
{#each plans as p}
<option value={String(p.id)}>{planOptionLabel(p)}</option>
{/each}
</Select>
</div>
<Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button>
</form>
</Dialog>
2026-08-16 11:37:42 +02:00
<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}
2026-08-16 18:49:02 +02:00
<div class="space-y-2">
<Label for="clone-dest">{i18n.t("admin.users.cloneDestLabel")}</Label>
<Select id="clone-dest" bind:value={cloneDestId} data-testid="admin-clone-dest">
<option value="">{i18n.t("admin.users.cloneDestSelect")}</option>
{#each cloneDestOptions as dest}
<option value={dest.id}>{dest.name}</option>
{/each}
</Select>
</div>
2026-08-16 11:37:42 +02:00
<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;
2026-08-16 18:49:02 +02:00
cloneDestId = "";
cloneDestOptions = [];
2026-08-16 11:37:42 +02:00
}}
>
{i18n.t("admin.users.cloneCatalogCancel")}
</Button>
2026-08-16 18:49:02 +02:00
<Button
type="button"
loading={busy}
disabled={!cloneDestId || cloneDestId === cloneCompany?.id}
onclick={() => confirmCloneCatalog()}
>
2026-08-16 11:37:42 +02:00
{i18n.t("admin.users.cloneCatalogConfirm")}
</Button>
</div>
</div>
</Dialog>
2026-08-16 16:57:36 +02:00
<Dialog
2026-08-16 18:36:56 +02:00
bind:open={syncOpen}
title={i18n.t("admin.users.syncA1Title")}
description={i18n.t("admin.users.syncA1Desc")}
2026-08-16 16:57:36 +02:00
>
<div class="space-y-4">
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
2026-08-16 18:36:56 +02:00
{#if syncCompany}
2026-08-16 16:57:36 +02:00
<p class="text-sm text-foreground">
2026-08-16 18:36:56 +02:00
{i18n.t("admin.users.syncA1Company", { name: syncCompany.name })}
2026-08-16 16:57:36 +02:00
</p>
{/if}
2026-08-17 00:39:25 +02:00
<div class="space-y-2">
<label class="block text-sm font-medium text-foreground" for="sync-a1-wp-categories">
{i18n.t("admin.users.syncA1WpUpload")}
</label>
<input
id="sync-a1-wp-categories"
type="file"
accept=".sql,text/plain,application/sql"
class="block w-full text-sm text-foreground file:mr-3 file:rounded-md file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-sm file:font-medium"
disabled={busy}
onchange={(e) => {
const input = e.currentTarget;
syncWpCategoriesFile = input.files?.[0] ?? null;
}}
/>
<p class="text-xs text-muted-foreground">{i18n.t("admin.users.syncA1WpUploadHint")}</p>
{#if syncWpCategoriesFile}
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{syncWpCategoriesFile.name}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onclick={() => {
syncWpCategoriesFile = null;
}}
>
{i18n.t("admin.users.syncA1WpUploadClear")}
</Button>
</div>
{/if}
</div>
2026-08-16 16:57:36 +02:00
<p class="text-sm text-muted-foreground">
2026-08-16 18:36:56 +02:00
{i18n.t("admin.users.syncA1Warning")}
2026-08-16 16:57:36 +02:00
</p>
<div class="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={busy}
onclick={() => {
2026-08-16 18:36:56 +02:00
syncOpen = false;
syncCompany = null;
2026-08-17 00:39:25 +02:00
syncWpCategoriesFile = null;
2026-08-16 16:57:36 +02:00
}}
>
2026-08-16 18:36:56 +02:00
{i18n.t("admin.users.syncA1Cancel")}
2026-08-16 16:57:36 +02:00
</Button>
2026-08-16 18:36:56 +02:00
<Button type="button" loading={busy} onclick={() => confirmSyncA1()}>
{i18n.t("admin.users.syncA1Confirm")}
2026-08-16 16:57:36 +02:00
</Button>
</div>
</div>
</Dialog>