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

841 lines
25 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,
STAFF_ROLE_OPTIONS,
assignAdminPlan,
companyPlanBadge,
isStaffRoleApiUnavailable,
listAdminCompanies,
listAdminPlansForAssign,
listAdminUsers,
planOptionLabel,
setAdminStaffRole,
staffRoleBadgeVariant,
staffRoleLabel,
type AdminBillingPlan,
type AdminOrgCompany,
type AdminOrgUser,
type PlatformStaffRole
} from "$lib/admin-orgs";
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";
import { Building2, KeyRound, 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("");
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("");
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;
}
}
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;
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} />
{#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>
<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>
</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}
title="Set password"
description="Force-set a login password for this user (works for fake/legacy emails that cannot receive invites)."
>
<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">
<Label for="forced-password">New password</Label>
<Input
id="forced-password"
type="password"
autocomplete="new-password"
minlength={8}
required
bind:value={passwordValue}
/>
</div>
<Button type="submit" loading={busyUserId === passwordUser?.id}>Set password</Button>
</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>