2026-08-09 22:47:43 +02:00
|
|
|
<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,
|
|
|
|
|
STAFF_ROLE_OPTIONS,
|
|
|
|
|
assignAdminPlan,
|
2026-08-16 11:37:42 +02:00
|
|
|
cloneAdminCompanyCatalog,
|
2026-08-16 18:36:56 +02:00
|
|
|
syncAdminCompanyA1,
|
2026-08-09 22:47:43 +02:00
|
|
|
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";
|
2026-08-09 22:47:43 +02:00
|
|
|
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";
|
2026-08-09 22:47:43 +02:00
|
|
|
|
|
|
|
|
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("");
|
|
|
|
|
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);
|
2026-08-09 22:47:43 +02:00
|
|
|
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("");
|
2026-08-09 22:47:43 +02:00
|
|
|
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:36:56 +02:00
|
|
|
let syncOpen = $state(false);
|
|
|
|
|
let syncCompany = $state<AdminOrgCompany | null>(null);
|
2026-08-16 16:57:36 +02:00
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
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)));
|
|
|
|
|
|
2026-08-16 11:37:42 +02:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
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;
|
2026-08-09 22:47:43 +02:00
|
|
|
} 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 11:37:42 +02:00
|
|
|
function openCloneDialog(company: AdminOrgCompany) {
|
|
|
|
|
cloneCompany = company;
|
|
|
|
|
cloneOpen = true;
|
|
|
|
|
error = "";
|
|
|
|
|
success = "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:36:56 +02:00
|
|
|
function openSyncDialog(company: AdminOrgCompany) {
|
|
|
|
|
syncCompany = company;
|
|
|
|
|
syncOpen = true;
|
2026-08-16 16:57:36 +02:00
|
|
|
error = "";
|
|
|
|
|
success = "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 18:36:56 +02:00
|
|
|
async function confirmSyncA1() {
|
|
|
|
|
if (!syncCompany) return;
|
2026-08-16 16:57:36 +02:00
|
|
|
busy = true;
|
|
|
|
|
error = "";
|
|
|
|
|
success = "";
|
2026-08-16 18:36:56 +02:00
|
|
|
const targetName = syncCompany.name;
|
2026-08-16 16:57:36 +02:00
|
|
|
try {
|
2026-08-16 18:36:56 +02:00
|
|
|
const res = await syncAdminCompanyA1(syncCompany.id, {
|
2026-08-16 16:57:36 +02:00
|
|
|
reprocessSampleLimit: 25
|
|
|
|
|
});
|
|
|
|
|
const r = res.result;
|
2026-08-16 18:18:39 +02:00
|
|
|
const mappedWith = Number(r.mapped_with_category ?? 0);
|
|
|
|
|
const mappedWithout = Number(r.mapped_without_category ?? 0);
|
|
|
|
|
const mappedTotal = mappedWith + mappedWithout;
|
2026-08-16 18:36:56 +02:00
|
|
|
const dumpStatus = String(r.dump_status ?? (r.dump_found ? "ok" : "missing"));
|
|
|
|
|
success = i18n.t("flash.admin.syncA1Success", {
|
2026-08-16 16:57:36 +02:00
|
|
|
name: targetName,
|
|
|
|
|
prompts: String(r.prompts ?? r.category_prompts_updated ?? 0),
|
|
|
|
|
hashes: String(r.hashes ?? r.weak_hashes_cleared ?? 0),
|
2026-08-16 18:18:39 +02:00
|
|
|
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),
|
2026-08-16 18:36:56 +02:00
|
|
|
taxonomy: String(r.taxonomy_categories ?? 0),
|
|
|
|
|
dump_mapped: String(r.dump_mapped_updated ?? 0),
|
|
|
|
|
dump_status: dumpStatus
|
2026-08-16 16:57:36 +02:00
|
|
|
});
|
2026-08-16 18:36:56 +02:00
|
|
|
syncOpen = false;
|
|
|
|
|
syncCompany = null;
|
2026-08-16 16:57:36 +02:00
|
|
|
} catch (err) {
|
2026-08-16 18:36:56 +02:00
|
|
|
error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
|
2026-08-16 16:57:36 +02:00
|
|
|
} finally {
|
|
|
|
|
busy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 11:37:42 +02:00
|
|
|
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;
|
2026-08-16 18:18:39 +02:00
|
|
|
if (!destId && me?.company?.id && me.company.id === cloneCompany.id) {
|
|
|
|
|
error = i18n.t("flash.admin.cloneDestMissing");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-16 11:37:42 +02:00
|
|
|
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 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-09 22:47:43 +02:00
|
|
|
async function sendSetPasswordEmails(userId?: string) {
|
|
|
|
|
busy = true;
|
|
|
|
|
error = "";
|
|
|
|
|
success = "";
|
|
|
|
|
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;
|
|
|
|
|
}>("/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}`);
|
|
|
|
|
success = i18n.t("flash.admin.invitesSummary", { parts: parts.join(", "), smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff") });
|
|
|
|
|
if (res.token) {
|
|
|
|
|
success += " Invite link available — copy and share it securely (email delivery is off).";
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
error = failureMessage(err, "Send failed");
|
|
|
|
|
} finally {
|
|
|
|
|
busy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-08-09 22:47:43 +02:00
|
|
|
error = "";
|
|
|
|
|
success = "";
|
|
|
|
|
try {
|
2026-08-14 01:38:34 +02:00
|
|
|
const res = await api<{ email?: string }>(`/api/admin/users/${passwordUser.id}/dev-password`, {
|
2026-08-09 22:47:43 +02:00
|
|
|
method: "POST",
|
2026-08-14 01:38:34 +02:00
|
|
|
body: { password: pwd }
|
2026-08-09 22:47:43 +02:00
|
|
|
});
|
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 = "";
|
2026-08-09 22:47:43 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof ApiError && err.status === 404) {
|
2026-08-14 01:38:34 +02:00
|
|
|
userOps = false;
|
2026-08-09 22:47:43 +02:00
|
|
|
error = i18n.t("flash.admin.localPasswordUnavailable");
|
|
|
|
|
} else {
|
2026-08-14 01:38:34 +02:00
|
|
|
error = failureMessage(err, "Could not set password");
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
} 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;
|
2026-08-09 22:47:43 +02:00
|
|
|
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} />
|
|
|
|
|
{#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}
|
2026-08-09 22:47:43 +02:00
|
|
|
<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}`}
|
2026-08-09 22:47:43 +02:00
|
|
|
>
|
|
|
|
|
<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>
|
2026-08-09 22:47:43 +02:00
|
|
|
</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>
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
<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}
|
|
|
|
|
<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>
|
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}
|
|
|
|
|
<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-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>
|