diff --git a/clients/admin/src/api/billing.ts b/clients/admin/src/api/billing.ts index 5fd2e43fab..2a0941fd77 100644 --- a/clients/admin/src/api/billing.ts +++ b/clients/admin/src/api/billing.ts @@ -1,5 +1,7 @@ -import { apiFetch } from "@/lib/api-client"; +import { apiFetch, ApiRequestError } from "@/lib/api-client"; import type { PagedResponse } from "@/lib/api-types"; +import { env } from "@/env"; +import { tokenStore } from "@/auth/token-store"; // ─── shared enums ──────────────────────────────────────────────────── @@ -182,6 +184,48 @@ export function getInvoice(invoiceId: string): Promise { return apiFetch(`/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}`); } +/** + * Fetch the invoice PDF as a blob and trigger a browser download named + * `{invoiceNumber}.pdf`. The endpoint streams `application/pdf`, so it can't + * go through `apiFetch` (which only parses JSON). We replicate apiFetch's + * auth + tenant headers by hand so cross-tenant viewing works identically to + * how the detail page loads the invoice via `getInvoice`. + */ +export async function downloadInvoicePdf(invoiceId: string, invoiceNumber: string): Promise { + const headers = new Headers({ Accept: "application/pdf" }); + + const accessToken = tokenStore.getAccessToken(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + const tenant = tokenStore.getTenant() ?? env.defaultTenant; + if (tenant) { + headers.set("tenant", tenant); + } + + const response = await fetch( + `${env.apiBase}/api/v1/billing/invoices/${encodeURIComponent(invoiceId)}/pdf`, + { headers }, + ); + + if (!response.ok) { + throw new ApiRequestError(response.status, `Failed to download invoice PDF (${response.status})`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + const link = document.createElement("a"); + link.href = objectUrl; + link.download = `${invoiceNumber}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + export function generateInvoices(periodYear: number, periodMonth: number): Promise<{ generated: number }> { return apiFetch<{ generated: number }>(`/api/v1/billing/invoices/generate`, { method: "POST", diff --git a/clients/admin/src/api/tenants.ts b/clients/admin/src/api/tenants.ts index 2337aeb96d..bce9ca0042 100644 --- a/clients/admin/src/api/tenants.ts +++ b/clients/admin/src/api/tenants.ts @@ -42,6 +42,11 @@ export type RenewTenantResponse = { planChanged: boolean; }; +export type AdjustTenantValidityResponse = { + tenantId: string; + validUpto: string; +}; + export type CreateTenantResponse = { id: string; provisioningCorrelationId?: string; @@ -112,6 +117,18 @@ export async function renewTenant(id: string, planKey?: string | null): Promise< }); } +/** + * Operator override: set a tenant's ValidUpto directly with NO invoice + * (comp/correction). Backdating is allowed server-side. Root-operator only — + * gated by MultitenancyPermissions.Tenants.UpgradeSubscription, same as renew. + */ +export async function adjustTenantValidity(id: string, validUpto: string): Promise { + return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/adjust-validity`, { + method: "POST", + body: JSON.stringify({ tenantId: id, validUpto }), + }); +} + export async function changeTenantActivation(id: string, isActive: boolean): Promise { return apiFetch(`/api/v1/tenants/${encodeURIComponent(id)}/activation`, { method: "POST", diff --git a/clients/admin/src/components/billing/plan-form-dialog.tsx b/clients/admin/src/components/billing/plan-form-dialog.tsx index 8ae2d7b8fe..08c603731c 100644 --- a/clients/admin/src/components/billing/plan-form-dialog.tsx +++ b/clients/admin/src/components/billing/plan-form-dialog.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; import { CreditCard, Gauge } from "lucide-react"; import { toast } from "sonner"; import { @@ -25,6 +26,24 @@ import { ApiRequestError } from "@/lib/api-client"; const PLAN_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; +// A money/rate field is a free-text decimal string. These refinements run +// client-side so a negative price is rejected before any network call (the +// server also rejects it, but we don't rely on that). +const NON_NEGATIVE_MSG = "Must be a non-negative number."; + +/** Required non-negative decimal (e.g. monthly base price). */ +const requiredNonNegative = z + .string() + .trim() + .min(1, "Required.") + .refine((v) => Number.isFinite(Number(v)) && Number(v) >= 0, NON_NEGATIVE_MSG); + +/** Optional non-negative decimal (blank allowed → omitted). */ +const optionalNonNegative = z + .string() + .trim() + .refine((v) => v === "" || (Number.isFinite(Number(v)) && Number(v) >= 0), NON_NEGATIVE_MSG); + const INTERVAL_OPTIONS: SelectOption[] = [ { value: "Monthly", label: "Monthly", hint: "billed every month" }, { value: "Yearly", label: "Yearly", hint: "billed every 12 months" }, @@ -46,6 +65,8 @@ function toOverageNumbers(state: OverageState): Record | null { const raw = state[key]; if (raw === undefined || raw.trim() === "") continue; const n = Number(raw); + // Submission is blocked upstream when a value is invalid, so anything that + // reaches here is a non-negative finite number. if (!Number.isFinite(n) || n < 0) continue; out[key] = n; any = true; @@ -53,6 +74,12 @@ function toOverageNumbers(state: OverageState): Record | null { return any ? out : null; } +/** First validation message for a value against a schema, or undefined when valid. */ +function fieldError(schema: z.ZodTypeAny, value: string): string | undefined { + const result = schema.safeParse(value); + return result.success ? undefined : result.error.issues[0]?.message; +} + function describe(err: unknown, fallback: string): string { if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; if (err instanceof Error) return err.message; @@ -126,11 +153,28 @@ export function PlanFormDialog({ const keyInvalid = !isEdit && key.length > 0 && !PLAN_KEY_PATTERN.test(key); const priceNum = Number(monthlyBasePrice); - const priceInvalid = monthlyBasePrice.length > 0 && (!Number.isFinite(priceNum) || priceNum < 0); + // Only surface the price error once something's been typed; submit-time + // validation (onSubmit) still blocks an empty required field. + const priceError = + monthlyBasePrice.length > 0 ? fieldError(requiredNonNegative, monthlyBasePrice) : undefined; const annualNum = Number(annualPrice); - const annualInvalid = annualPrice.trim().length > 0 && (!Number.isFinite(annualNum) || annualNum < 0); + const annualError = fieldError(optionalNonNegative, annualPrice); const annualPricePayload = interval === "Yearly" && annualPrice.trim().length > 0 ? annualNum : null; + // Per-resource overage validation — a negative or non-numeric rate blocks submit. + const overageErrors = useMemo(() => { + const out: Partial> = {}; + for (const { key: resKey } of OVERAGE_RESOURCES) { + const err = fieldError(optionalNonNegative, overage[resKey] ?? ""); + if (err) out[resKey] = err; + } + return out; + }, [overage]); + const hasOverageError = Object.keys(overageErrors).length > 0; + // Aggregate validity for disabling submit. Monthly price is required + non-negative. + const pricingInvalid = + !!fieldError(requiredNonNegative, monthlyBasePrice) || !!annualError || hasOverageError; + const onClose = () => onOpenChange(false); const createMutation = useMutation({ @@ -157,7 +201,7 @@ export function PlanFormDialog({ const onSubmit = (e: FormEvent) => { e.preventDefault(); - if (priceInvalid || annualInvalid) return; + if (pricingInvalid) return; const overageRates = toOverageNumbers(overage); if (isEdit && plan) { @@ -252,7 +296,7 @@ export function PlanFormDialog({ label="Monthly base price" hint="Canonical monthly rate; the term price for monthly plans." required - error={priceInvalid ? "Must be a non-negative number." : undefined} + error={priceError} >
{OVERAGE_RESOURCES.map((res) => ( - + Cancel - diff --git a/clients/admin/src/components/layout/topbar.tsx b/clients/admin/src/components/layout/topbar.tsx index 31359e5db0..1dff6c00d0 100644 --- a/clients/admin/src/components/layout/topbar.tsx +++ b/clients/admin/src/components/layout/topbar.tsx @@ -101,31 +101,6 @@ function SimpleMenuItem({ ); } -// ───────────────────────────────────────────────────────────────────────────── -// TenantChip — tenant indicator in the topbar right zone. -// ───────────────────────────────────────────────────────────────────────────── - -function TenantChip({ tenant }: { tenant?: string }) { - return ( -
- - - tenant - - - {tenant ?? "—"} - -
- ); -} - // ───────────────────────────────────────────────────────────────────────────── // Topbar // ───────────────────────────────────────────────────────────────────────────── @@ -155,9 +130,6 @@ export function Topbar() { {/* Spacer pushes right-side actions to the trailing edge */}
- {/* Tenant chip */} - - {/* Notification bell */} diff --git a/clients/admin/src/components/tenants/adjust-validity-dialog.tsx b/clients/admin/src/components/tenants/adjust-validity-dialog.tsx new file mode 100644 index 0000000000..af982a91a7 --- /dev/null +++ b/clients/admin/src/components/tenants/adjust-validity-dialog.tsx @@ -0,0 +1,163 @@ +import { useEffect } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CalendarCog } from "lucide-react"; +import { toast } from "sonner"; +import { adjustTenantValidity } from "@/api/tenants"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/list"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +// A `type="date"` input yields a `YYYY-MM-DD` string. zod validates the shape +// and that it parses to a real calendar date. +const schema = z.object({ + validUpto: z + .string() + .min(1, "Pick a date.") + .refine((v) => !Number.isNaN(new Date(v).getTime()), "Enter a valid date."), +}); + +type FormValues = z.infer; + +function describe(err: unknown, fallback: string): string { + if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; + if (err instanceof Error) return err.message; + return fallback; +} + +function formatDate(value?: string | null): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(); +} + +/** `YYYY-MM-DD` (the native date input value) for an ISO/date string, for prefill. */ +function toDateInputValue(value?: string | null): string { + if (!value) return ""; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + return d.toISOString().slice(0, 10); +} + +/** + * Operator override that sets a tenant's ValidUpto directly with NO invoice — + * a comp/correction, distinct from Renew (which issues a term invoice). + * Backdating is permitted server-side. Root-operator only. + */ +export function AdjustValidityDialog({ + open, + onOpenChange, + tenantId, + validUpto, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + tenantId: string; + validUpto?: string; +}) { + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { validUpto: "" }, + }); + + // Prefill with the tenant's current validity each time the dialog opens. + useEffect(() => { + if (open) reset({ validUpto: toDateInputValue(validUpto) }); + }, [open, validUpto, reset]); + + const mutation = useMutation({ + // Pass the date via mutate(arg) — never close over form state at submit time. + mutationFn: (value: string) => adjustTenantValidity(tenantId, new Date(value).toISOString()), + onSuccess: (result) => { + toast.success("Validity adjusted", { + description: `Valid until ${formatDate(result.validUpto)}. No invoice was issued.`, + }); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + handleClose(); + }, + onError: (err) => toast.error("Adjust failed", { description: describe(err, "Could not adjust validity.") }), + }); + + function handleClose() { + reset({ validUpto: "" }); + onOpenChange(false); + } + + const onSubmit = handleSubmit((values) => mutation.mutate(values.validUpto)); + const submitting = isSubmitting || mutation.isPending; + + return ( + { + if (!o) handleClose(); + else onOpenChange(true); + }} + > + + +
+ + + + Adjust validity +
+ + Set this tenant's expiry date directly — an operator override with{" "} + no invoice. Use for comps or + corrections; renewals that should bill belong in Renew. Currently valid until{" "} + {formatDate(validUpto)}. + +
+ +
+ + + + + + + + + + +
+
+
+ ); +} diff --git a/clients/admin/src/components/ui/confirm-dialog.tsx b/clients/admin/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000000..0b7bfad0c6 --- /dev/null +++ b/clients/admin/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/cn"; + +/** + * A reusable confirmation dialog for important / irreversible actions. Replaces ad-hoc + * window.confirm calls with a styled, accessible Radix dialog. The confirm button shows a pending + * state while the action runs and the dialog stays open until the caller closes it. + */ +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + onConfirm, + destructive = false, + pending = false, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + destructive?: boolean; + pending?: boolean; +}) { + return ( + (pending ? undefined : onOpenChange(o))}> + + +
+ + + + {title} +
+
+ + + {description} + + + + + + +
+
+ ); +} diff --git a/clients/admin/src/pages/billing/invoice-detail.tsx b/clients/admin/src/pages/billing/invoice-detail.tsx index 32b46c3ffc..93851479ae 100644 --- a/clients/admin/src/pages/billing/invoice-detail.tsx +++ b/clients/admin/src/pages/billing/invoice-detail.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Ban, CheckCircle2, FileText, Send } from "lucide-react"; +import { ArrowLeft, Ban, CheckCircle2, Download, FileText, Send } from "lucide-react"; import { toast } from "sonner"; import { + downloadInvoicePdf, getInvoice, issueInvoice, markInvoicePaid, @@ -89,6 +90,13 @@ export function InvoiceDetailPage() { const [dueAt, setDueAt] = useState(""); const [voidReason, setVoidReason] = useState(""); + // Pass id + number via mutate(arg) — never close over invoice state, which + // could be stale if the query refetched between render and click. + const downloadMutation = useMutation({ + mutationFn: ({ id, number }: { id: string; number: string }) => downloadInvoicePdf(id, number), + onError: (err) => toast.error("Download failed", { description: describe(err, "Could not download the invoice PDF.") }), + }); + const issueMutation = useMutation({ mutationFn: () => issueInvoice(invoiceId, dueAt ? new Date(dueAt).toISOString() : null), onSuccess: () => { @@ -170,7 +178,20 @@ export function InvoiceDetailPage() { } - /> + > + + ) : null}
diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index fdccd60670..731cacc11a 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, Building2, CalendarClock, + CalendarCog, CheckCircle2, CircleDashed, ClipboardList, @@ -24,7 +25,9 @@ import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog import { ActiveGrantsCard } from "@/components/impersonation/active-grants-card"; import { TenantBrandingCard } from "@/components/tenants/tenant-branding-card"; import { RenewTenantDialog } from "@/components/tenants/renew-tenant-dialog"; -import { IdentityPermissions } from "@/lib/permissions"; +import { AdjustValidityDialog } from "@/components/tenants/adjust-validity-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { IdentityPermissions, MultitenancyPermissions } from "@/lib/permissions"; import { changeTenantActivation, getTenantProvisioningStatus, @@ -51,8 +54,13 @@ export function TenantDetailPage() { const { user: currentUser } = useAuth(); const [impersonateOpen, setImpersonateOpen] = useState(false); const [renewOpen, setRenewOpen] = useState(false); - const canImpersonate = (currentUser?.permissions ?? []).includes( - IdentityPermissions.Users.Impersonate, + const [adjustOpen, setAdjustOpen] = useState(false); + 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. + const canManageSubscription = permissions.includes( + MultitenancyPermissions.Tenants.UpgradeSubscription, ); const tenantQuery = useQuery({ @@ -85,6 +93,7 @@ export function TenantDetailPage() { mutationFn: (isActive: boolean) => changeTenantActivation(id, isActive), onSuccess: (result) => { toast.success(result.isActive ? "Tenant activated" : "Tenant deactivated"); + setActivationConfirmOpen(false); queryClient.invalidateQueries({ queryKey: ["tenant", id] }); queryClient.invalidateQueries({ queryKey: ["tenants"] }); }, @@ -197,9 +206,20 @@ export function TenantDetailPage() { Renew / change plan + {canManageSubscription && ( + + )} +
+ ); +} diff --git a/clients/dashboard/src/components/layout/nav-data.ts b/clients/dashboard/src/components/layout/nav-data.ts index 4776b6ff72..6b5f7f7290 100644 --- a/clients/dashboard/src/components/layout/nav-data.ts +++ b/clients/dashboard/src/components/layout/nav-data.ts @@ -1,5 +1,6 @@ import { Activity, + CreditCard, FolderOpen, FolderTree, HeartPulse, @@ -22,6 +23,13 @@ export type NavSpec = { to: string; label: string; icon: React.ComponentType<{ className?: string }>; + /** + * Permission required to see this item. Items without a `perm` are visible to + * every authenticated tenant user; gated items are hidden when the current + * user (or impersonated user) lacks the permission, so they never land on a + * page the API will reject with 403. + */ + perm?: string; }; export type NavSection = { @@ -53,6 +61,7 @@ export const sections: NavSection[] = [ icon: Activity, items: [ { to: "/activity", label: "Live activity", icon: Activity }, + { to: "/subscription", label: "Subscription", icon: CreditCard }, { to: "/invoices", label: "Invoices", icon: Receipt }, ], }, @@ -90,13 +99,30 @@ export const sections: NavSection[] = [ icon: HeartPulse, items: [ { to: "/system/health", label: "Health", icon: HeartPulse }, - { to: "/system/audits", label: "Audit trail", icon: ScrollText }, - { to: "/system/sessions", label: "Sessions", icon: Wifi }, + { to: "/system/audits", label: "Audit trail", icon: ScrollText, perm: "Permissions.AuditTrails.View" }, + { to: "/system/sessions", label: "Sessions", icon: Wifi, perm: "Permissions.Sessions.ViewAll" }, { to: "/system/trash", label: "Trash", icon: Trash2 }, ], }, ]; +/** True when the item is ungated, or the user holds its required permission. */ +function isNavItemVisible(item: NavSpec, permissions: readonly string[]): boolean { + return !item.perm || permissions.includes(item.perm); +} + +/** Drop items the user can't access, then drop any section left empty. */ +export function visibleSections(permissions: readonly string[]): NavSection[] { + return sections + .map((s) => ({ ...s, items: s.items.filter((i) => isNavItemVisible(i, permissions)) })) + .filter((s) => s.items.length > 0); +} + +/** Filter a flat nav list (top/bottom) by permission. */ +export function visibleItems(items: NavSpec[], permissions: readonly string[]): NavSpec[] { + return items.filter((i) => isNavItemVisible(i, permissions)); +} + /** Find the section whose items contain the given path (best prefix match). */ export function findSectionForPath(pathname: string): string | null { let bestId: string | null = null; diff --git a/clients/dashboard/src/components/layout/sidebar.tsx b/clients/dashboard/src/components/layout/sidebar.tsx index 0bdf8c9921..710705b04b 100644 --- a/clients/dashboard/src/components/layout/sidebar.tsx +++ b/clients/dashboard/src/components/layout/sidebar.tsx @@ -6,11 +6,13 @@ import { PanelLeftOpen, } from "lucide-react"; import { cn } from "@/lib/cn"; +import { useAuth } from "@/auth/use-auth"; import { findSectionForPath, - sections, topNavBottom, topNavTop, + visibleItems, + visibleSections, type NavSection, type NavSpec, } from "@/components/layout/nav-data"; @@ -186,6 +188,14 @@ export function SidebarNavBody({ * drawer to close itself on navigation. */ onNavigate?: () => void; }) { + // Hide nav entries the current (or impersonated) user lacks permission for, + // so they can't navigate to a page the API will reject with 403. + const { user } = useAuth(); + const perms = user?.permissions ?? []; + const navTop = visibleItems(topNavTop, perms); + const navSections = visibleSections(perms); + const navBottom = visibleItems(topNavBottom, perms); + return ( /* Nav scrolls vertically when item count exceeds available height. `overflow-x: clip` keeps the collapsed-mode hover tooltips from @@ -194,7 +204,7 @@ export function SidebarNavBody({