Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions clients/admin/src/api/files.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { apiFetch } from "@/lib/api-client";
import type { PagedResponse } from "@/lib/api-types";

// Mirrors FSH.Modules.Files.Domain.Visibility — Public/Private numeric codes
// match the server's int? Visibility shape on the FileAssetDto.
// Mirrors FSH.Modules.Files.Domain.Visibility — the server serializes this
// enum as its string name (JsonStringEnumConverter) on the FileAssetDto and
// accepts the same string names on request bodies.
export const Visibility = {
Public: 0,
Private: 1,
Public: "Public",
Private: "Private",
} as const;
export type VisibilityValue = (typeof Visibility)[keyof typeof Visibility];

// Mirrors FSH.Modules.Files.Domain.FileAssetStatus.
// Mirrors FSH.Modules.Files.Domain.FileAssetStatus — serialized as string name.
export const FileAssetStatus = {
PendingUpload: 0,
Available: 1,
Quarantined: 2,
PendingUpload: "PendingUpload",
Available: "Available",
Quarantined: "Quarantined",
} as const;
export type FileAssetStatusValue = (typeof FileAssetStatus)[keyof typeof FileAssetStatus];

Expand Down
47 changes: 33 additions & 14 deletions clients/admin/src/pages/billing/invoice-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { Input } from "@/components/ui/input";
import { EntityPageHeader, SettingsSection, Field } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
import { useAuth } from "@/auth/use-auth";
import { BillingPermissions } from "@/lib/permissions";

// ─── helpers ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -72,6 +74,9 @@ export function InvoiceDetailPage() {
const { invoiceId = "" } = useParams<{ invoiceId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
// Issue / mark-paid / void and PDF download all require Billing.Manage on the server.
const canManageBilling = (currentUser?.permissions ?? []).includes(BillingPermissions.Manage);

const query = useQuery({
queryKey: ["billing", "invoice", invoiceId],
Expand Down Expand Up @@ -179,18 +184,20 @@ export function InvoiceDetailPage() {
</span>
}
>
<Button
variant="outline"
size="sm"
onClick={() =>
downloadMutation.mutate({ id: invoice.id, number: invoice.invoiceNumber })
}
disabled={downloadMutation.isPending}
title="Download this invoice as a PDF"
>
<Download className="mr-1.5 h-3.5 w-3.5" />
{downloadMutation.isPending ? "Preparing…" : "Download PDF"}
</Button>
{canManageBilling && (
<Button
variant="outline"
size="sm"
onClick={() =>
downloadMutation.mutate({ id: invoice.id, number: invoice.invoiceNumber })
}
disabled={downloadMutation.isPending}
title="Download this invoice as a PDF"
>
<Download className="mr-1.5 h-3.5 w-3.5" />
{downloadMutation.isPending ? "Preparing…" : "Download PDF"}
</Button>
)}
</EntityPageHeader>
) : null}
</div>
Expand All @@ -202,10 +209,16 @@ export function InvoiceDetailPage() {
description={
invoice
? `${invoice.lineItems.length} line${invoice.lineItems.length === 1 ? "" : "s"}`
: "Loading…"
: query.isError
? "Unavailable"
: "Loading…"
}
>
{query.isLoading ? (
{query.isError ? (
<div className="py-8 text-center text-sm text-[var(--color-destructive)]">
{describe(query.error, "Failed to load line items.")}
</div>
) : query.isLoading ? (
<ul className="-mx-5 divide-y divide-[var(--color-border)] border-t border-[var(--color-border)]">
{Array.from({ length: 2 }).map((_, i) => (
<li key={i} className="px-5 py-4">
Expand Down Expand Up @@ -239,6 +252,10 @@ export function InvoiceDetailPage() {
<div className="space-y-4">
{invoice && (
<>
{/* Issue / Mark-paid / Void all mutate invoice state — gated behind
Billing.Manage. View-only users still see read-only Notes below. */}
{canManageBilling && (
<>
{/* Issue */}
<SettingsSection
icon={Send}
Expand Down Expand Up @@ -324,6 +341,8 @@ export function InvoiceDetailPage() {
</Button>
</div>
</SettingsSection>
</>
)}

{invoice.notes && (
<SettingsSection title="Notes">
Expand Down
30 changes: 19 additions & 11 deletions clients/admin/src/pages/billing/plans-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { StatStrip, Stat, SettingsSection } from "@/components/list";
import { PlanFormDialog } from "@/components/billing/plan-form-dialog";
import { ApiRequestError } from "@/lib/api-client";
import { useAuth } from "@/auth/use-auth";
import { BillingPermissions } from "@/lib/permissions";

// ─── helpers ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -38,6 +40,8 @@ function describe(err: unknown): string {
export function PlansListPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPlan, setEditingPlan] = useState<BillingPlanDto | undefined>(undefined);
const { user: currentUser } = useAuth();
const canManageBilling = (currentUser?.permissions ?? []).includes(BillingPermissions.Manage);

const openCreate = () => {
setEditingPlan(undefined);
Expand Down Expand Up @@ -102,9 +106,11 @@ export function PlansListPage() {
title="All plans"
description="Pricing schedule used by tenant subscriptions and invoice generation."
footer={
<Button onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" /> New plan
</Button>
canManageBilling ? (
<Button onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" /> New plan
</Button>
) : undefined
}
>
{query.isError && (
Expand Down Expand Up @@ -164,14 +170,16 @@ export function PlansListPage() {
{plan.interval === "Yearly" ? "per year" : "per month"}
</div>
</div>
<Button
variant="ghost"
size="icon"
aria-label={`Edit ${plan.name}`}
onClick={() => openEdit(plan)}
>
<Pencil className="h-4 w-4" />
</Button>
{canManageBilling && (
<Button
variant="ghost"
size="icon"
aria-label={`Edit ${plan.name}`}
onClick={() => openEdit(plan)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</div>
</li>
))}
Expand Down
73 changes: 43 additions & 30 deletions clients/admin/src/pages/tenants/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ export function TenantDetailPage() {
const [activationConfirmOpen, setActivationConfirmOpen] = useState(false);
const permissions = currentUser?.permissions ?? [];
const canImpersonate = permissions.includes(IdentityPermissions.Users.Impersonate);
// Same gate as Renew — adjusting validity is a root-operator subscription action.
// Renew / change plan + adjust validity are root-operator subscription actions.
const canManageSubscription = permissions.includes(
MultitenancyPermissions.Tenants.UpgradeSubscription,
);
// Activation toggle + retry-provisioning are tenant-update operations.
const canUpdateTenant = permissions.includes(MultitenancyPermissions.Tenants.Update);

const tenantQuery = useQuery({
queryKey: ["tenant", id],
Expand Down Expand Up @@ -197,15 +199,17 @@ export function TenantDetailPage() {
Impersonate user
</Button>
)}
<Button
variant="outline"
onClick={() => setRenewOpen(true)}
className="shrink-0"
title="Extend validity by one plan term, or switch plans"
>
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />
Renew / change plan
</Button>
{canManageSubscription && (
<Button
variant="outline"
onClick={() => setRenewOpen(true)}
className="shrink-0"
title="Extend validity by one plan term, or switch plans"
>
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />
Renew / change plan
</Button>
)}
{canManageSubscription && (
<Button
variant="outline"
Expand All @@ -217,18 +221,20 @@ export function TenantDetailPage() {
Adjust validity
</Button>
)}
<Button
variant={tenant.isActive ? "outline" : "default"}
onClick={() => setActivationConfirmOpen(true)}
disabled={activationMutation.isPending}
className="shrink-0"
>
{activationMutation.isPending
? "Updating…"
: tenant.isActive
? "Deactivate tenant"
: "Activate tenant"}
</Button>
{canUpdateTenant && (
<Button
variant={tenant.isActive ? "outline" : "default"}
onClick={() => setActivationConfirmOpen(true)}
disabled={activationMutation.isPending}
className="shrink-0"
>
{activationMutation.isPending
? "Updating…"
: tenant.isActive
? "Deactivate tenant"
: "Activate tenant"}
</Button>
)}
</div>
</div>
</SettingsSection>
Expand All @@ -240,13 +246,15 @@ export function TenantDetailPage() {
tenantName={tenant.name}
/>

<RenewTenantDialog
open={renewOpen}
onOpenChange={setRenewOpen}
tenantId={tenant.id}
currentPlanKey={tenant.plan}
validUpto={tenant.validUpto}
/>
{canManageSubscription && (
<RenewTenantDialog
open={renewOpen}
onOpenChange={setRenewOpen}
tenantId={tenant.id}
currentPlanKey={tenant.plan}
validUpto={tenant.validUpto}
/>
)}

{canManageSubscription && (
<AdjustValidityDialog
Expand All @@ -257,6 +265,7 @@ export function TenantDetailPage() {
/>
)}

{canUpdateTenant && (
<ConfirmDialog
open={activationConfirmOpen}
onOpenChange={setActivationConfirmOpen}
Expand All @@ -280,6 +289,7 @@ export function TenantDetailPage() {
pending={activationMutation.isPending}
onConfirm={() => activationMutation.mutate(!tenant.isActive)}
/>
)}

<ActiveGrantsCard tenantId={tenant.id} />

Expand Down Expand Up @@ -334,6 +344,7 @@ export function TenantDetailPage() {
notTracked={provisioningNotTracked}
onRetry={() => retryMutation.mutate()}
retryPending={retryMutation.isPending}
canRetry={canUpdateTenant}
/>
</SettingsSection>
</>
Expand Down Expand Up @@ -402,6 +413,7 @@ function ProvisioningPanel({
notTracked = false,
onRetry,
retryPending,
canRetry = false,
}: {
steps: TenantProvisioningStep[];
status?: string;
Expand All @@ -412,6 +424,7 @@ function ProvisioningPanel({
notTracked?: boolean;
onRetry: () => void;
retryPending: boolean;
canRetry?: boolean;
}) {
const overall = notTracked ? "Not tracked" : status ?? (loading ? "Loading" : "Unknown");

Expand All @@ -438,7 +451,7 @@ function ProvisioningPanel({
: overall}
</Badge>
</div>
{status === "Failed" && (
{status === "Failed" && canRetry && (
<Button size="sm" variant="outline" onClick={onRetry} disabled={retryPending}>
<RefreshCw className={cn("mr-1.5 h-3.5 w-3.5", retryPending && "animate-spin")} />
{retryPending ? "Re-queuing…" : "Retry provisioning"}
Expand Down
20 changes: 14 additions & 6 deletions clients/admin/src/pages/tenants/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { EntityPageHeader, ErrorBand } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
import { CreateTenantDialog } from "@/components/tenants/create-tenant-dialog";
import { useAuth } from "@/auth/use-auth";
import { MultitenancyPermissions } from "@/lib/permissions";

const PAGE_SIZE = 12;

Expand All @@ -24,6 +26,10 @@ export function TenantsListPage() {
const [pageNumber, setPageNumber] = useState(1);
const [createOpen, setCreateOpen] = useState(false);
const navigate = useNavigate();
const { user: currentUser } = useAuth();
const canCreateTenant = (currentUser?.permissions ?? []).includes(
MultitenancyPermissions.Tenants.Create,
);

const query = useQuery({
queryKey: ["tenants", { pageNumber, pageSize: PAGE_SIZE }],
Expand Down Expand Up @@ -55,12 +61,14 @@ export function TenantsListPage() {
: "Loading the registry…"
}
>
<Button
onClick={() => setCreateOpen(true)}
className="h-9 flex-1 gap-1.5 rounded-lg px-4 text-[13px] font-semibold sm:flex-none"
>
<Plus className="size-4" /> New tenant
</Button>
{canCreateTenant && (
<Button
onClick={() => setCreateOpen(true)}
className="h-9 flex-1 gap-1.5 rounded-lg px-4 text-[13px] font-semibold sm:flex-none"
>
<Plus className="size-4" /> New tenant
</Button>
)}
</EntityPageHeader>

{query.isError && (
Expand Down
4 changes: 2 additions & 2 deletions clients/admin/tests/audits/audits.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { mockJsonResponse } from "../helpers/api-mocks";
const AUDIT_ROW = {
id: "aud-1111-2222-3333",
occurredAtUtc: "2026-05-20T14:22:01Z",
eventType: 2, // Security (int form — the API boundary normalizes it)
severity: 5, // Error
eventType: "Security", // string name — the real API contract
severity: "Error",
tenantId: "root",
userId: "u-77",
userName: "rootadmin",
Expand Down
Loading
Loading