+
+ )
+}
diff --git a/frontend/src/features/tenants/components/TenantCard.tsx b/frontend/src/features/tenants/components/TenantCard.tsx
new file mode 100644
index 000000000..9f35f4c28
--- /dev/null
+++ b/frontend/src/features/tenants/components/TenantCard.tsx
@@ -0,0 +1,320 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { TFunction } from 'i18next'
+import {
+ Building2,
+ Globe,
+ Lock,
+ Loader2,
+ LogIn,
+ Pencil,
+ Power,
+ ShieldCheck,
+ Trash2,
+} from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { setSupportTenant } from '@/shared/lib/current-tenant'
+import { fetchTenantStats } from '../services/tenants-http.service'
+import type { SupportAccess, Tenant, TenantStats, TenantStatus } from '../types/tenant.types'
+
+/**
+ * Enter the tenant, then reload rather than navigate.
+ *
+ * Everything already fetched — react-query caches, the branding, the
+ * notification feed — belongs to the operator's own tenant. A soft navigation
+ * would leave that on screen next to the customer's data, which is exactly the
+ * confusion a support session must not create.
+ */
+function enterTenant(tenant: Tenant): void {
+ setSupportTenant({
+ id: tenant.id,
+ name: tenant.name,
+ access: tenant.supportAccess === 'FULL' ? 'FULL' : 'READ',
+ })
+ window.location.assign('/home')
+}
+
+// Deterministic hue per tenant, so each card keeps its own accent across loads.
+function hueOf(s: string): number {
+ let h = 0
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360
+ return h
+}
+
+const RING_TONE: Record = {
+ sky: 'stroke-sky-500',
+ violet: 'stroke-violet-500',
+ amber: 'stroke-amber-500',
+ emerald: 'stroke-emerald-500',
+}
+
+/**
+ * One counter as a dial.
+ *
+ * `ratio` fills the arc proportionally and is only passed where a real
+ * denominator exists. Everywhere else the ring is a frame around the number,
+ * not a measurement — an arc drawn from an invented maximum would read as a
+ * percentage of nothing.
+ */
+function Ring({
+ label,
+ value,
+ tone,
+ ratio,
+ caption,
+}: {
+ label: string
+ value: number | null | undefined
+ tone: keyof typeof RING_TONE
+ ratio?: number
+ caption?: string
+}) {
+ const missing = value == null
+ const circumference = 2 * Math.PI * 22
+ const filled = ratio == null ? 1 : ratio
+
+ return (
+
+
+
+
+ {/* An unreachable subsystem shows a dash. Zero is a real answer and
+ has to look different from "we could not ask". */}
+ {value ?? '—'}
+
+
+ {!readable ? (
+ // Not a display rule of ours: the tenant chose this, and the backend
+ // answers 403 to any read we attempt until they change it.
+
+
+ setConfirmation(e.target.value)}
+ placeholder={tenant.name}
+ />
+
+
+ )
+}
diff --git a/frontend/src/features/tenants/components/tenant-error.ts b/frontend/src/features/tenants/components/tenant-error.ts
new file mode 100644
index 000000000..3ce157c21
--- /dev/null
+++ b/frontend/src/features/tenants/components/tenant-error.ts
@@ -0,0 +1,12 @@
+import type { TFunction } from 'i18next'
+import { TenantsHttpError } from '../services/tenants-http.service'
+
+export function tenantError(err: unknown, t: TFunction): string {
+ if (err instanceof TenantsHttpError) {
+ if (err.status === 409) return t('tenants.toast.domainInUse')
+ if (err.status === 403) return t('tenants.toast.noPermission')
+ if (err.status === 404) return t('tenants.toast.notFound')
+ if (err.status === 400) return err.message || t('tenants.toast.invalidRequest')
+ }
+ return err instanceof Error ? err.message : t('tenants.toast.operationFailed')
+}
diff --git a/frontend/src/features/tenants/pages/TenantsPage.tsx b/frontend/src/features/tenants/pages/TenantsPage.tsx
index 914c8065e..c3541529b 100644
--- a/frontend/src/features/tenants/pages/TenantsPage.tsx
+++ b/frontend/src/features/tenants/pages/TenantsPage.tsx
@@ -1,42 +1,26 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import type { TFunction } from 'i18next'
import {
AlertTriangle,
Building2,
- Check,
- Globe,
Loader2,
- Lock,
- LogIn,
- Pencil,
Plus,
- Power,
Search,
- ShieldCheck,
- Trash2,
- X,
- type LucideIcon,
} from 'lucide-react'
-import { toast } from 'sonner'
import { cn } from '@/shared/lib/utils'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { useAuth } from '@/features/auth'
-import { setSupportTenant } from '@/shared/lib/current-tenant'
import {
canReadTenant,
- fetchTenantStats,
- TenantsHttpError,
tenantsHttpService,
} from '../services/tenants-http.service'
-import type {
- CreateTenantRequest,
- SupportAccess,
- Tenant,
- TenantStats,
- TenantStatus,
-} from '../types/tenant.types'
+import type { Tenant } from '../types/tenant.types'
+import { TenantCard } from '../components/TenantCard'
+import { CreateTenantDialog } from '../components/CreateTenantDialog'
+import { EditTenantDialog } from '../components/EditTenantDialog'
+import { TerminateDialog } from '../components/TerminateDialog'
+import { PermanentDeleteDialog } from '../components/PermanentDeleteDialog'
/* ─────────────────────────────────────────────────────────────────────────
* Page
@@ -51,6 +35,7 @@ export function TenantsPage() {
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
const [terminating, setTerminating] = useState(null)
+ const [deletingPermanently, setDeletingPermanently] = useState(null)
const load = useCallback(async () => {
setError(false)
@@ -144,6 +129,7 @@ export function TenantsPage() {
readable={canReadTenant(tenant)}
onEdit={() => setEditing(tenant)}
onTerminate={() => setTerminating(tenant)}
+ onDelete={() => setDeletingPermanently(tenant)}
/>
))}
{/* Sits in the grid rather than in the header: adding a customer is
@@ -181,6 +167,16 @@ export function TenantsPage() {
}}
/>
)}
+ {deletingPermanently && (
+ setDeletingPermanently(null)}
+ onDone={() => {
+ setDeletingPermanently(null)
+ void load()
+ }}
+ />
+ )}
)
}
@@ -213,642 +209,9 @@ function AddTenantCard({ onClick }: { onClick: () => void }) {
function StatChip({ label, value }: { label: string; value: number }) {
return (
-
+
{value}{label}
)
}
-
-/* ─────────────────────────────────────────────────────────────────────────
- * Card
- * ───────────────────────────────────────────────────────────────────────── */
-
-/**
- * Enter the tenant, then reload rather than navigate.
- *
- * Everything already fetched — react-query caches, the branding, the
- * notification feed — belongs to the operator's own tenant. A soft navigation
- * would leave that on screen next to the customer's data, which is exactly the
- * confusion a support session must not create.
- */
-function enterTenant(tenant: Tenant): void {
- setSupportTenant({
- id: tenant.id,
- name: tenant.name,
- access: tenant.supportAccess === 'FULL' ? 'FULL' : 'READ',
- })
- window.location.assign('/home')
-}
-
-// Deterministic hue per tenant, so each card keeps its own accent across loads.
-function hueOf(s: string): number {
- let h = 0
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360
- return h
-}
-
-function TenantCard({
- tenant,
- readable,
- onEdit,
- onTerminate,
-}: {
- tenant: Tenant
- readable: boolean
- onEdit: () => void
- onTerminate: () => void
-}) {
- const { t } = useTranslation()
- const [stats, setStats] = useState(null)
- const [loadingStats, setLoadingStats] = useState(readable)
-
- useEffect(() => {
- if (!readable) {
- setLoadingStats(false)
- return
- }
- let cancelled = false
- setLoadingStats(true)
- fetchTenantStats(tenant.id)
- .then((s) => {
- if (!cancelled) setStats(s)
- })
- .finally(() => {
- if (!cancelled) setLoadingStats(false)
- })
- return () => {
- cancelled = true
- }
- }, [tenant.id, readable])
-
- const hue = hueOf(tenant.name || tenant.domain)
- const initial = (tenant.name || tenant.domain).charAt(0).toUpperCase()
- const terminated = tenant.status === 'TERMINATED'
-
- return (
-
-
-
-
-
- {initial || }
-
-
-
-
{tenant.name}
-
-
- {tenant.domain}
-
-
-
-
-
- {!terminated && (
-
- )}
-
-
-
-
-
-
- {readable && !terminated && (
-
- )}
-
-
-
- {!readable ? (
- // Not a display rule of ours: the tenant chose this, and the backend
- // answers 403 to any read we attempt until they change it.
-
- )
-}
-
-// A limit of 0 or less is the backend saying "no cap of its own": the tenant
-// simply spends against whatever the instance licence allows.
-function aiHint(stats: TenantStats | null, t: TFunction): string | undefined {
- if (!stats?.ai) return undefined
- return stats.ai.limit > 0 ? `/ ${stats.ai.limit}` : t('tenants.stats.noLimit')
-}
-
-const RING_TONE: Record = {
- sky: 'stroke-sky-500',
- violet: 'stroke-violet-500',
- amber: 'stroke-amber-500',
- emerald: 'stroke-emerald-500',
-}
-
-/**
- * One counter as a dial.
- *
- * `ratio` fills the arc proportionally and is only passed where a real
- * denominator exists. Everywhere else the ring is a frame around the number,
- * not a measurement — an arc drawn from an invented maximum would read as a
- * percentage of nothing.
- */
-function Ring({
- label,
- value,
- tone,
- ratio,
- caption,
-}: {
- label: string
- value: number | null | undefined
- tone: keyof typeof RING_TONE
- ratio?: number
- caption?: string
-}) {
- const missing = value == null
- const circumference = 2 * Math.PI * 22
- const filled = ratio == null ? 1 : ratio
-
- return (
-
-
-
-
- {/* An unreachable subsystem shows a dash. Zero is a real answer and
- has to look different from "we could not ask". */}
- {value ?? '—'}
-
-