@burdenoff/microfe-admin 2026.608.1 → 2026.624.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/AdminRoutes.js +14 -10
- package/dist/admin/AdminRoutes.js.map +1 -1
- package/dist/admin/components/AccessDenied.js +2 -2
- package/dist/admin/components/AccessDenied.js.map +1 -1
- package/dist/admin/components/ActivityFeed.js +4 -4
- package/dist/admin/components/ActivityFeed.js.map +1 -1
- package/dist/admin/components/Charts.js +4 -4
- package/dist/admin/components/Charts.js.map +1 -1
- package/dist/admin/components/ConfirmDialog.js +44 -25
- package/dist/admin/components/ConfirmDialog.js.map +1 -1
- package/dist/admin/components/DataTable.js +7 -7
- package/dist/admin/components/DataTable.js.map +1 -1
- package/dist/admin/components/FormSheet.js +7 -7
- package/dist/admin/components/FormSheet.js.map +1 -1
- package/dist/admin/components/MetricCard.js +4 -4
- package/dist/admin/components/MetricCard.js.map +1 -1
- package/dist/admin/components/PageLayout.js +3 -3
- package/dist/admin/components/PageLayout.js.map +1 -1
- package/dist/admin/components/PermissionGuard.js +1 -1
- package/dist/admin/components/PermissionGuard.js.map +1 -1
- package/dist/admin/components/RowActions.js +2 -2
- package/dist/admin/components/RowActions.js.map +1 -1
- package/dist/admin/components/TenantMembersPanel.js +15 -15
- package/dist/admin/components/TenantMembersPanel.js.map +1 -1
- package/dist/admin/components/TenantSsoPanel.js +14 -14
- package/dist/admin/components/TenantSsoPanel.js.map +1 -1
- package/dist/admin/components/Toast.js +9 -9
- package/dist/admin/components/Toast.js.map +1 -1
- package/dist/admin/components/forms/SubscriptionForm.js +10 -10
- package/dist/admin/components/forms/SubscriptionForm.js.map +1 -1
- package/dist/admin/components/forms/TenantForm.js +8 -8
- package/dist/admin/components/forms/TenantForm.js.map +1 -1
- package/dist/admin/components/forms/TicketForm.js +2 -2
- package/dist/admin/components/forms/TicketForm.js.map +1 -1
- package/dist/admin/components/forms/UserForm.js +8 -8
- package/dist/admin/components/forms/UserForm.js.map +1 -1
- package/dist/admin/components/ui.js +16 -16
- package/dist/admin/components/ui.js.map +1 -1
- package/dist/admin/constants/index.js +8 -8
- package/dist/admin/constants/index.js.map +1 -1
- package/dist/admin/pages/AuditLogsPage.js +8 -8
- package/dist/admin/pages/AuditLogsPage.js.map +1 -1
- package/dist/admin/pages/CommunicationsPage.js +2 -2
- package/dist/admin/pages/CommunicationsPage.js.map +1 -1
- package/dist/admin/pages/ContentPagesPage.js +661 -0
- package/dist/admin/pages/ContentPagesPage.js.map +1 -0
- package/dist/admin/pages/FeatureFlagsPage.js +4 -4
- package/dist/admin/pages/FeatureFlagsPage.js.map +1 -1
- package/dist/admin/pages/OrganizationsListPage.js +6 -6
- package/dist/admin/pages/OrganizationsListPage.js.map +1 -1
- package/dist/admin/pages/ProductsPage.js +7 -7
- package/dist/admin/pages/ProductsPage.js.map +1 -1
- package/dist/admin/pages/ReferralsPage.js +23 -23
- package/dist/admin/pages/ReferralsPage.js.map +1 -1
- package/dist/admin/pages/RolesPermissionsPage.js +8 -8
- package/dist/admin/pages/RolesPermissionsPage.js.map +1 -1
- package/dist/admin/pages/SREPage.js +8 -8
- package/dist/admin/pages/SREPage.js.map +1 -1
- package/dist/admin/pages/SandboxSettingsPage.js +20 -20
- package/dist/admin/pages/SandboxSettingsPage.js.map +1 -1
- package/dist/admin/pages/SettingsPage.js +20 -20
- package/dist/admin/pages/SettingsPage.js.map +1 -1
- package/dist/admin/pages/SyncFailuresPage.js +11 -11
- package/dist/admin/pages/SyncFailuresPage.js.map +1 -1
- package/dist/admin/pages/TenantDetailPage.js +22 -22
- package/dist/admin/pages/TenantDetailPage.js.map +1 -1
- package/dist/admin/pages/TenantFormRoutePage.js +2 -2
- package/dist/admin/pages/TenantFormRoutePage.js.map +1 -1
- package/dist/admin/pages/TenantsListPage.js +5 -5
- package/dist/admin/pages/TenantsListPage.js.map +1 -1
- package/dist/admin/pages/UsersListPage.js +6 -6
- package/dist/admin/pages/UsersListPage.js.map +1 -1
- package/dist/admin/pages/WaitlistPage.js +7 -7
- package/dist/admin/pages/WaitlistPage.js.map +1 -1
- package/dist/admin/pages/WorkflowsPage.js +3 -3
- package/dist/admin/pages/WorkflowsPage.js.map +1 -1
- package/dist/admin/pages/WorkspacesListPage.js +8 -8
- package/dist/admin/pages/WorkspacesListPage.js.map +1 -1
- package/dist/admin/pages/index.js +1 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SyncFailuresPage.js","names":[],"sources":["../../../src/admin/pages/SyncFailuresPage.tsx"],"sourcesContent":["import { useCallback, useMemo, useState } from 'react';\nimport { RefreshCw, AlertOctagon, Clock, CheckCircle2, RotateCw } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n MetricCard,\n MetricsGrid,\n Card,\n CardContent,\n Badge,\n Button,\n DataTable,\n Pagination,\n EmptyState,\n PermissionGuard,\n ConfirmDialog,\n useToast,\n} from '../components';\nimport { formatDateTime, formatRelativeTime } from '../utils/formatters';\nimport { SyncOutboxStatus } from '../../generated/global-types';\nimport {\n useListSyncOutboxEntriesQuery,\n useSyncOutboxStatsQuery,\n useRetrySyncOutboxEntryMutation,\n type ListSyncOutboxEntriesQuery,\n} from '../../generated/global-operations';\n\nconst PAGE_SIZE = 25;\n\n// Single source of truth for the underlying string values. Authored as a\n// const-tuple so TypeScript derives both the union type and a runtime guard.\n// The mapping below is type-checked: if codegen ever drops or renames a\n// SyncOutboxStatus enum member, the build fails at STATUS_TO_GQL, not at\n// runtime.\nconst STATUS_VALUES = ['PENDING', 'RETRYING', 'SUCCESS', 'PERMANENTLY_FAILED'] as const;\ntype StatusValue = (typeof STATUS_VALUES)[number];\ntype StatusFilter = 'all' | StatusValue;\n\ntype OutboxEntry = ListSyncOutboxEntriesQuery['listSyncOutboxEntries']['items'][number];\n\n// Typed bridge between our boundary string union and the codegen-generated\n// enum. Explicit mapping makes the codegen casing dependency a compile-time\n// constraint instead of a runtime cast.\nconst STATUS_TO_GQL: Record<StatusValue, SyncOutboxStatus> = {\n PENDING: SyncOutboxStatus.Pending,\n RETRYING: SyncOutboxStatus.Retrying,\n SUCCESS: SyncOutboxStatus.Success,\n PERMANENTLY_FAILED: SyncOutboxStatus.PermanentlyFailed,\n};\n\n// Reverse map: codegen enum value → boundary string. Falls back to\n// PERMANENTLY_FAILED if codegen ever emits an unknown value — a loud-but-safe\n// default that surfaces the drift without breaking the page.\nfunction asStatusValue(s: SyncOutboxStatus): StatusValue {\n const raw = String(s) as StatusValue;\n return (STATUS_VALUES as readonly string[]).includes(raw) ? raw : 'PERMANENTLY_FAILED';\n}\n\nconst STATUS_VARIANTS: Record<StatusValue, string> = {\n PENDING: 'bg-warning/10 text-warning border-warning/30',\n RETRYING: 'bg-warning/10 text-warning border-warning/30',\n SUCCESS: 'bg-success/10 text-success border-success/30',\n PERMANENTLY_FAILED: 'bg-destructive/10 text-destructive border-destructive/30',\n};\n\nconst STATUS_LABELS: Record<StatusValue, string> = {\n PENDING: 'Pending',\n RETRYING: 'Retrying',\n SUCCESS: 'Success',\n PERMANENTLY_FAILED: 'Permanently failed',\n};\n\nfunction StatusBadge({ status }: { status: SyncOutboxStatus }) {\n const key = asStatusValue(status);\n return (\n <Badge variant=\"outline\" className={STATUS_VARIANTS[key]}>\n {STATUS_LABELS[key]}\n </Badge>\n );\n}\n\n/**\n * Sync Failures admin page.\n *\n * Shows the state of the cross-layer sync outbox so a super-admin can spot\n * users/workspaces that didn't get fully provisioned (e.g. signup happened\n * while a downstream service was down) and manually requeue them once the\n * underlying problem is fixed.\n *\n * Manual retry only flips the row to RETRYING; the background reconciler in\n * global-tenant-svc actually performs the sync on its next tick. That keeps\n * the existing single-writer retry semantics intact.\n */\nexport function SyncFailuresPage() {\n const {\n canViewSyncFailures,\n canManageSyncFailures,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const toast = useToast();\n\n const [page, setPage] = useState(1);\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('PERMANENTLY_FAILED');\n const [confirmRetryId, setConfirmRetryId] = useState<string | null>(null);\n const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);\n\n const filter = useMemo(() => {\n if (statusFilter === 'all') return undefined;\n // Explicit, type-checked mapping — replaces the previous `as unknown as`\n // double-cast. If codegen ever renames a SyncOutboxStatus enum member,\n // the build fails at STATUS_TO_GQL above, not silently at runtime.\n return { statuses: [STATUS_TO_GQL[statusFilter]] };\n }, [statusFilter]);\n\n const skipQueries = permissionsLoading || !canViewSyncFailures;\n\n const {\n data: listData,\n loading: listLoading,\n error: listError,\n refetch: refetchList,\n } = useListSyncOutboxEntriesQuery({\n variables: {\n filter,\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const {\n data: statsData,\n loading: statsLoading,\n refetch: refetchStats,\n } = useSyncOutboxStatsQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const [retryEntry, { loading: retrying }] = useRetrySyncOutboxEntryMutation({\n onCompleted: (data) => {\n const result = data.retrySyncOutboxEntry;\n if (result.success) {\n toast.success(\n 'Requeued for retry',\n result.message ?? 'The reconciler will pick it up on the next tick.'\n );\n void refetchList();\n void refetchStats();\n } else {\n toast.error('Retry failed', result.message ?? 'Could not requeue this entry.');\n }\n },\n onError: (err) => {\n toast.error('Retry failed', err.message);\n },\n });\n\n const handleRefresh = useCallback(() => {\n void refetchList();\n void refetchStats();\n }, [refetchList, refetchStats]);\n\n const handleConfirmRetry = useCallback(async () => {\n if (!confirmRetryId) return;\n // Apollo's mutate promise rejects on network/GraphQL error; without the\n // finally the dialog would stay open and ConfirmDialog's awaited\n // onConfirm would see an unhandled rejection. The onError toast still\n // surfaces the failure to the user.\n try {\n await retryEntry({ variables: { id: confirmRetryId } });\n } finally {\n setConfirmRetryId(null);\n }\n }, [confirmRetryId, retryEntry]);\n\n const entries = listData?.listSyncOutboxEntries.items ?? [];\n const total = listData?.listSyncOutboxEntries.total ?? 0;\n const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));\n const stats = statsData?.syncOutboxStats;\n\n const columns = useMemo(\n () => [\n {\n key: 'operation',\n header: 'Operation',\n width: '180px',\n render: (entry: OutboxEntry) => (\n <code className=\"text-xs bg-muted px-1.5 py-0.5 rounded\">{entry.operation}</code>\n ),\n },\n {\n key: 'workspaceId',\n header: 'Workspace',\n render: (entry: OutboxEntry) => (\n <span className=\"font-mono text-xs text-muted-foreground\">{entry.workspaceId}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n width: '170px',\n render: (entry: OutboxEntry) => <StatusBadge status={entry.status} />,\n },\n {\n key: 'attempts',\n header: 'Attempts',\n width: '100px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm\">\n {entry.attempts}\n <span className=\"text-muted-foreground\">/{entry.maxAttempts}</span>\n </span>\n ),\n },\n {\n key: 'nextRetryAt',\n header: 'Next retry',\n width: '160px',\n render: (entry: OutboxEntry) =>\n entry.nextRetryAt ? (\n <span className=\"text-sm text-muted-foreground\">\n {formatRelativeTime(entry.nextRetryAt)}\n </span>\n ) : (\n <span className=\"text-xs text-muted-foreground\">—</span>\n ),\n },\n {\n key: 'createdAt',\n header: 'Created',\n width: '160px',\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm text-muted-foreground\">\n {formatRelativeTime(entry.createdAt)}\n </span>\n ),\n },\n {\n key: 'actions',\n header: '',\n width: '120px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => {\n // Retry is only meaningful for entries that have exhausted automatic\n // retries. PENDING/RETRYING entries already get picked up by the\n // background reconciler. SUCCESS entries don't need retrying.\n const isRetryable = asStatusValue(entry.status) === 'PERMANENTLY_FAILED';\n if (!canManageSyncFailures || !isRetryable) {\n return null;\n }\n return (\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={(e) => {\n e.stopPropagation();\n setConfirmRetryId(entry.id);\n }}\n disabled={retrying}\n >\n <RotateCw className=\"mr-1.5 h-3.5 w-3.5\" />\n Retry\n </Button>\n );\n },\n },\n ],\n [canManageSyncFailures, retrying]\n );\n\n return (\n <PermissionGuard\n allowed={canViewSyncFailures}\n isLoading={permissionsLoading}\n pageTitle=\"Sync Failures\"\n denyMessage=\"Only super-admins can view cross-layer sync failures.\"\n >\n <PageLayout\n title=\"Sync Failures\"\n description=\"Cross-layer sync operations (global → workspace) that did not complete. Automatic retries run in the background; use the manual retry once the underlying issue is fixed.\"\n actions={\n <Button variant=\"outline\" onClick={handleRefresh} disabled={listLoading || statsLoading}>\n <RefreshCw className={`mr-2 h-4 w-4 ${listLoading ? 'animate-spin' : ''}`} />\n Refresh\n </Button>\n }\n >\n <div className=\"space-y-6\">\n {/* Stats */}\n <MetricsGrid>\n <MetricCard\n title=\"Pending\"\n value={stats?.pending ?? 0}\n icon={<Clock className=\"h-5 w-5\" />}\n subtitle=\"Waiting for first retry\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Retrying\"\n value={stats?.retrying ?? 0}\n icon={<RotateCw className=\"h-5 w-5\" />}\n subtitle=\"Automatic backoff in progress\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Permanently failed\"\n value={stats?.permanentlyFailed ?? 0}\n icon={<AlertOctagon className=\"h-5 w-5\" />}\n subtitle=\"Exhausted automatic retries\"\n priority={stats?.permanentlyFailed ? 'critical' : 'normal'}\n loading={statsLoading}\n />\n <MetricCard\n title=\"Successful\"\n value={stats?.successful ?? 0}\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n subtitle=\"Synced (auto-purged after 7 days)\"\n loading={statsLoading}\n />\n </MetricsGrid>\n\n {/* Filters */}\n <div className=\"flex flex-wrap items-center gap-2\">\n {(\n [\n ['PERMANENTLY_FAILED', 'Permanently failed'],\n ['RETRYING', 'Retrying'],\n ['PENDING', 'Pending'],\n ['SUCCESS', 'Successful'],\n ['all', 'All'],\n ] as Array<[StatusFilter, string]>\n ).map(([value, label]) => (\n <Button\n key={value}\n variant={statusFilter === value ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => {\n setStatusFilter(value);\n setPage(1);\n // Clear any expanded error detail card — the row it referenced\n // may not be in the new result set.\n setExpandedErrorId(null);\n }}\n >\n {label}\n </Button>\n ))}\n </div>\n\n {/* Error banner */}\n {listError && (\n <Card>\n <CardContent className=\"py-4 text-sm text-destructive\">\n Failed to load sync outbox entries: {listError.message}\n </CardContent>\n </Card>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={entries}\n keyExtractor={(entry) => entry.id}\n loading={listLoading}\n onRowClick={(entry: OutboxEntry) =>\n setExpandedErrorId((current) => (current === entry.id ? null : entry.id))\n }\n emptyState={\n <EmptyState\n icon={<CheckCircle2 className=\"h-8 w-8\" />}\n title=\"No sync failures\"\n description={\n statusFilter === 'PERMANENTLY_FAILED'\n ? 'Nothing has exhausted automatic retries.'\n : 'No outbox entries match the current filter.'\n }\n />\n }\n />\n\n {/* Expanded error detail */}\n {expandedErrorId &&\n entries\n .filter((e: OutboxEntry) => e.id === expandedErrorId)\n .map((entry: OutboxEntry) => (\n <Card key={entry.id}>\n <CardContent className=\"space-y-2 py-4 text-sm\">\n <div className=\"flex items-center justify-between\">\n <span className=\"font-medium\">Last error</span>\n <span className=\"text-xs text-muted-foreground\">\n {entry.lastAttemptAt\n ? `Last attempt: ${formatDateTime(entry.lastAttemptAt)}`\n : 'Never attempted'}\n </span>\n </div>\n <pre className=\"whitespace-pre-wrap break-words rounded bg-muted px-3 py-2 text-xs text-muted-foreground\">\n {entry.lastError ?? 'No error recorded'}\n </pre>\n <div className=\"text-xs text-muted-foreground\">\n Entry id: <code className=\"font-mono\">{entry.id}</code>\n </div>\n </CardContent>\n </Card>\n ))}\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={(p) => {\n setPage(p);\n // Same rationale as the filter buttons — the previously\n // expanded row may not be in the new page's result set.\n setExpandedErrorId(null);\n }}\n />\n )}\n </div>\n\n {/* Retry confirm */}\n <ConfirmDialog\n open={!!confirmRetryId}\n onClose={() => setConfirmRetryId(null)}\n onConfirm={handleConfirmRetry}\n title=\"Retry this sync?\"\n description={\n <>\n The entry will be flipped to <strong>RETRYING</strong> and the background reconciler\n will pick it up on its next tick. Attempts will reset to 0 so the entry gets a fresh\n retry budget.\n <br />\n <br />\n Make sure the underlying issue (e.g. a downstream service that was down) is actually\n resolved — otherwise it will fail again.\n </>\n }\n confirmLabel={retrying ? 'Requeueing…' : 'Retry'}\n loading={retrying}\n />\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"SyncFailuresPage.js","names":[],"sources":["../../../src/admin/pages/SyncFailuresPage.tsx"],"sourcesContent":["import { useCallback, useMemo, useState } from 'react';\nimport { RefreshCw, AlertOctagon, Clock, CheckCircle2, RotateCw } from 'lucide-react';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n MetricCard,\n MetricsGrid,\n Card,\n CardContent,\n Badge,\n Button,\n DataTable,\n Pagination,\n EmptyState,\n PermissionGuard,\n ConfirmDialog,\n useToast,\n} from '../components';\nimport { formatDateTime, formatRelativeTime } from '../utils/formatters';\nimport { SyncOutboxStatus } from '../../generated/global-types';\nimport {\n useListSyncOutboxEntriesQuery,\n useSyncOutboxStatsQuery,\n useRetrySyncOutboxEntryMutation,\n type ListSyncOutboxEntriesQuery,\n} from '../../generated/global-operations';\n\nconst PAGE_SIZE = 25;\n\n// Single source of truth for the underlying string values. Authored as a\n// const-tuple so TypeScript derives both the union type and a runtime guard.\n// The mapping below is type-checked: if codegen ever drops or renames a\n// SyncOutboxStatus enum member, the build fails at STATUS_TO_GQL, not at\n// runtime.\nconst STATUS_VALUES = ['PENDING', 'RETRYING', 'SUCCESS', 'PERMANENTLY_FAILED'] as const;\ntype StatusValue = (typeof STATUS_VALUES)[number];\ntype StatusFilter = 'all' | StatusValue;\n\ntype OutboxEntry = ListSyncOutboxEntriesQuery['listSyncOutboxEntries']['items'][number];\n\n// Typed bridge between our boundary string union and the codegen-generated\n// enum. Explicit mapping makes the codegen casing dependency a compile-time\n// constraint instead of a runtime cast.\nconst STATUS_TO_GQL: Record<StatusValue, SyncOutboxStatus> = {\n PENDING: SyncOutboxStatus.Pending,\n RETRYING: SyncOutboxStatus.Retrying,\n SUCCESS: SyncOutboxStatus.Success,\n PERMANENTLY_FAILED: SyncOutboxStatus.PermanentlyFailed,\n};\n\n// Reverse map: codegen enum value → boundary string. Falls back to\n// PERMANENTLY_FAILED if codegen ever emits an unknown value — a loud-but-safe\n// default that surfaces the drift without breaking the page.\nfunction asStatusValue(s: SyncOutboxStatus): StatusValue {\n const raw = String(s) as StatusValue;\n return (STATUS_VALUES as readonly string[]).includes(raw) ? raw : 'PERMANENTLY_FAILED';\n}\n\nconst STATUS_VARIANTS: Record<StatusValue, string> = {\n PENDING: 'bg-warning/10 text-warning border-warning/30',\n RETRYING: 'bg-warning/10 text-warning border-warning/30',\n SUCCESS: 'bg-success/10 text-success border-success/30',\n PERMANENTLY_FAILED: 'bg-status-error-bg/10 text-status-error-text border-status-error-border/30',\n};\n\nconst STATUS_LABELS: Record<StatusValue, string> = {\n PENDING: 'Pending',\n RETRYING: 'Retrying',\n SUCCESS: 'Success',\n PERMANENTLY_FAILED: 'Permanently failed',\n};\n\nfunction StatusBadge({ status }: { status: SyncOutboxStatus }) {\n const key = asStatusValue(status);\n return (\n <Badge variant=\"outline\" className={STATUS_VARIANTS[key]}>\n {STATUS_LABELS[key]}\n </Badge>\n );\n}\n\n/**\n * Sync Failures admin page.\n *\n * Shows the state of the cross-layer sync outbox so a super-admin can spot\n * users/workspaces that didn't get fully provisioned (e.g. signup happened\n * while a downstream service was down) and manually requeue them once the\n * underlying problem is fixed.\n *\n * Manual retry only flips the row to RETRYING; the background reconciler in\n * global-tenant-svc actually performs the sync on its next tick. That keeps\n * the existing single-writer retry semantics intact.\n */\nexport function SyncFailuresPage() {\n const {\n canViewSyncFailures,\n canManageSyncFailures,\n isLoading: permissionsLoading,\n } = useAdminPermissions();\n const toast = useToast();\n\n const [page, setPage] = useState(1);\n const [statusFilter, setStatusFilter] = useState<StatusFilter>('PERMANENTLY_FAILED');\n const [confirmRetryId, setConfirmRetryId] = useState<string | null>(null);\n const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);\n\n const filter = useMemo(() => {\n if (statusFilter === 'all') return undefined;\n // Explicit, type-checked mapping — replaces the previous `as unknown as`\n // double-cast. If codegen ever renames a SyncOutboxStatus enum member,\n // the build fails at STATUS_TO_GQL above, not silently at runtime.\n return { statuses: [STATUS_TO_GQL[statusFilter]] };\n }, [statusFilter]);\n\n const skipQueries = permissionsLoading || !canViewSyncFailures;\n\n const {\n data: listData,\n loading: listLoading,\n error: listError,\n refetch: refetchList,\n } = useListSyncOutboxEntriesQuery({\n variables: {\n filter,\n limit: PAGE_SIZE,\n offset: (page - 1) * PAGE_SIZE,\n },\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const {\n data: statsData,\n loading: statsLoading,\n refetch: refetchStats,\n } = useSyncOutboxStatsQuery({\n fetchPolicy: 'cache-and-network',\n skip: skipQueries,\n });\n\n const [retryEntry, { loading: retrying }] = useRetrySyncOutboxEntryMutation({\n onCompleted: (data) => {\n const result = data.retrySyncOutboxEntry;\n if (result.success) {\n toast.success(\n 'Requeued for retry',\n result.message ?? 'The reconciler will pick it up on the next tick.'\n );\n void refetchList();\n void refetchStats();\n } else {\n toast.error('Retry failed', result.message ?? 'Could not requeue this entry.');\n }\n },\n onError: (err) => {\n toast.error('Retry failed', err.message);\n },\n });\n\n const handleRefresh = useCallback(() => {\n void refetchList();\n void refetchStats();\n }, [refetchList, refetchStats]);\n\n const handleConfirmRetry = useCallback(async () => {\n if (!confirmRetryId) return;\n // Apollo's mutate promise rejects on network/GraphQL error; without the\n // finally the dialog would stay open and ConfirmDialog's awaited\n // onConfirm would see an unhandled rejection. The onError toast still\n // surfaces the failure to the user.\n try {\n await retryEntry({ variables: { id: confirmRetryId } });\n } finally {\n setConfirmRetryId(null);\n }\n }, [confirmRetryId, retryEntry]);\n\n const entries = listData?.listSyncOutboxEntries.items ?? [];\n const total = listData?.listSyncOutboxEntries.total ?? 0;\n const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));\n const stats = statsData?.syncOutboxStats;\n\n const columns = useMemo(\n () => [\n {\n key: 'operation',\n header: 'Operation',\n width: '180px',\n render: (entry: OutboxEntry) => (\n <code className=\"text-xs bg-bg-sunken px-1.5 py-0.5 rounded\">{entry.operation}</code>\n ),\n },\n {\n key: 'workspaceId',\n header: 'Workspace',\n render: (entry: OutboxEntry) => (\n <span className=\"font-mono text-xs text-text-secondary\">{entry.workspaceId}</span>\n ),\n },\n {\n key: 'status',\n header: 'Status',\n width: '170px',\n render: (entry: OutboxEntry) => <StatusBadge status={entry.status} />,\n },\n {\n key: 'attempts',\n header: 'Attempts',\n width: '100px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm\">\n {entry.attempts}\n <span className=\"text-text-secondary\">/{entry.maxAttempts}</span>\n </span>\n ),\n },\n {\n key: 'nextRetryAt',\n header: 'Next retry',\n width: '160px',\n render: (entry: OutboxEntry) =>\n entry.nextRetryAt ? (\n <span className=\"text-sm text-text-secondary\">\n {formatRelativeTime(entry.nextRetryAt)}\n </span>\n ) : (\n <span className=\"text-xs text-text-secondary\">—</span>\n ),\n },\n {\n key: 'createdAt',\n header: 'Created',\n width: '160px',\n render: (entry: OutboxEntry) => (\n <span className=\"text-sm text-text-secondary\">\n {formatRelativeTime(entry.createdAt)}\n </span>\n ),\n },\n {\n key: 'actions',\n header: '',\n width: '120px',\n align: 'right' as const,\n render: (entry: OutboxEntry) => {\n // Retry is only meaningful for entries that have exhausted automatic\n // retries. PENDING/RETRYING entries already get picked up by the\n // background reconciler. SUCCESS entries don't need retrying.\n const isRetryable = asStatusValue(entry.status) === 'PERMANENTLY_FAILED';\n if (!canManageSyncFailures || !isRetryable) {\n return null;\n }\n return (\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={(e) => {\n e.stopPropagation();\n setConfirmRetryId(entry.id);\n }}\n disabled={retrying}\n >\n <RotateCw className=\"mr-1.5 h-3.5 w-3.5\" />\n Retry\n </Button>\n );\n },\n },\n ],\n [canManageSyncFailures, retrying]\n );\n\n return (\n <PermissionGuard\n allowed={canViewSyncFailures}\n isLoading={permissionsLoading}\n pageTitle=\"Sync Failures\"\n denyMessage=\"Only super-admins can view cross-layer sync failures.\"\n >\n <PageLayout\n title=\"Sync Failures\"\n description=\"Cross-layer sync operations (global → workspace) that did not complete. Automatic retries run in the background; use the manual retry once the underlying issue is fixed.\"\n actions={\n <Button variant=\"outline\" onClick={handleRefresh} disabled={listLoading || statsLoading}>\n <RefreshCw className={`mr-2 h-4 w-4 ${listLoading ? 'animate-spin' : ''}`} />\n Refresh\n </Button>\n }\n >\n <div className=\"space-y-6\">\n {/* Stats */}\n <MetricsGrid>\n <MetricCard\n title=\"Pending\"\n value={stats?.pending ?? 0}\n icon={<Clock className=\"h-5 w-5\" />}\n subtitle=\"Waiting for first retry\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Retrying\"\n value={stats?.retrying ?? 0}\n icon={<RotateCw className=\"h-5 w-5\" />}\n subtitle=\"Automatic backoff in progress\"\n loading={statsLoading}\n />\n <MetricCard\n title=\"Permanently failed\"\n value={stats?.permanentlyFailed ?? 0}\n icon={<AlertOctagon className=\"h-5 w-5\" />}\n subtitle=\"Exhausted automatic retries\"\n priority={stats?.permanentlyFailed ? 'critical' : 'normal'}\n loading={statsLoading}\n />\n <MetricCard\n title=\"Successful\"\n value={stats?.successful ?? 0}\n icon={<CheckCircle2 className=\"h-5 w-5\" />}\n subtitle=\"Synced (auto-purged after 7 days)\"\n loading={statsLoading}\n />\n </MetricsGrid>\n\n {/* Filters */}\n <div className=\"flex flex-wrap items-center gap-2\">\n {(\n [\n ['PERMANENTLY_FAILED', 'Permanently failed'],\n ['RETRYING', 'Retrying'],\n ['PENDING', 'Pending'],\n ['SUCCESS', 'Successful'],\n ['all', 'All'],\n ] as Array<[StatusFilter, string]>\n ).map(([value, label]) => (\n <Button\n key={value}\n variant={statusFilter === value ? 'default' : 'outline'}\n size=\"sm\"\n onClick={() => {\n setStatusFilter(value);\n setPage(1);\n // Clear any expanded error detail card — the row it referenced\n // may not be in the new result set.\n setExpandedErrorId(null);\n }}\n >\n {label}\n </Button>\n ))}\n </div>\n\n {/* Error banner */}\n {listError && (\n <Card>\n <CardContent className=\"py-4 text-sm text-status-error-text\">\n Failed to load sync outbox entries: {listError.message}\n </CardContent>\n </Card>\n )}\n\n {/* Table */}\n <DataTable\n columns={columns}\n data={entries}\n keyExtractor={(entry) => entry.id}\n loading={listLoading}\n onRowClick={(entry: OutboxEntry) =>\n setExpandedErrorId((current) => (current === entry.id ? null : entry.id))\n }\n emptyState={\n <EmptyState\n icon={<CheckCircle2 className=\"h-8 w-8\" />}\n title=\"No sync failures\"\n description={\n statusFilter === 'PERMANENTLY_FAILED'\n ? 'Nothing has exhausted automatic retries.'\n : 'No outbox entries match the current filter.'\n }\n />\n }\n />\n\n {/* Expanded error detail */}\n {expandedErrorId &&\n entries\n .filter((e: OutboxEntry) => e.id === expandedErrorId)\n .map((entry: OutboxEntry) => (\n <Card key={entry.id}>\n <CardContent className=\"space-y-2 py-4 text-sm\">\n <div className=\"flex items-center justify-between\">\n <span className=\"font-medium\">Last error</span>\n <span className=\"text-xs text-text-secondary\">\n {entry.lastAttemptAt\n ? `Last attempt: ${formatDateTime(entry.lastAttemptAt)}`\n : 'Never attempted'}\n </span>\n </div>\n <pre className=\"whitespace-pre-wrap break-words rounded bg-bg-sunken px-3 py-2 text-xs text-text-secondary\">\n {entry.lastError ?? 'No error recorded'}\n </pre>\n <div className=\"text-xs text-text-secondary\">\n Entry id: <code className=\"font-mono\">{entry.id}</code>\n </div>\n </CardContent>\n </Card>\n ))}\n\n {/* Pagination */}\n {total > PAGE_SIZE && (\n <Pagination\n page={page}\n totalPages={totalPages}\n totalItems={total}\n pageSize={PAGE_SIZE}\n onPageChange={(p) => {\n setPage(p);\n // Same rationale as the filter buttons — the previously\n // expanded row may not be in the new page's result set.\n setExpandedErrorId(null);\n }}\n />\n )}\n </div>\n\n {/* Retry confirm */}\n <ConfirmDialog\n open={!!confirmRetryId}\n onClose={() => setConfirmRetryId(null)}\n onConfirm={handleConfirmRetry}\n title=\"Retry this sync?\"\n description={\n <>\n The entry will be flipped to <strong>RETRYING</strong> and the background reconciler\n will pick it up on its next tick. Attempts will reset to 0 so the entry gets a fresh\n retry budget.\n <br />\n <br />\n Make sure the underlying issue (e.g. a downstream service that was down) is actually\n resolved — otherwise it will fail again.\n </>\n }\n confirmLabel={retrying ? 'Requeueing…' : 'Retry'}\n loading={retrying}\n />\n </PageLayout>\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -59,13 +59,13 @@ function F() {
|
|
|
59
59
|
title: U("admin.tenantDetail.loadingTitle", "Loading..."),
|
|
60
60
|
children: /* @__PURE__ */ j("div", {
|
|
61
61
|
className: "flex h-64 items-center justify-center",
|
|
62
|
-
children: /* @__PURE__ */ j(O, { className: "h-8 w-8 animate-spin text-
|
|
62
|
+
children: /* @__PURE__ */ j(O, { className: "h-8 w-8 animate-spin text-text-secondary" })
|
|
63
63
|
})
|
|
64
64
|
}) : K || !Q ? /* @__PURE__ */ M(u, {
|
|
65
65
|
title: U("admin.tenantDetail.notFoundTitle", "Tenant Not Found"),
|
|
66
66
|
description: U("admin.tenantDetail.notFoundDescription", "The requested tenant could not be found"),
|
|
67
67
|
children: [K && /* @__PURE__ */ j("div", {
|
|
68
|
-
className: "mb-4 rounded-md border border-
|
|
68
|
+
className: "mb-4 rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text",
|
|
69
69
|
children: K.message
|
|
70
70
|
}), /* @__PURE__ */ M(i, {
|
|
71
71
|
variant: "outline",
|
|
@@ -120,15 +120,15 @@ function F() {
|
|
|
120
120
|
}), /* @__PURE__ */ M("div", {
|
|
121
121
|
className: "flex-1 space-y-4",
|
|
122
122
|
children: [/* @__PURE__ */ M("div", { children: [/* @__PURE__ */ j("h2", {
|
|
123
|
-
className: "text-xl font-semibold text-
|
|
123
|
+
className: "text-xl font-semibold text-text-primary",
|
|
124
124
|
children: Q.name
|
|
125
125
|
}), /* @__PURE__ */ M("p", {
|
|
126
|
-
className: "text-sm text-
|
|
126
|
+
className: "text-sm text-text-secondary",
|
|
127
127
|
children: ["@", Q.slug]
|
|
128
128
|
})] }), /* @__PURE__ */ M("div", {
|
|
129
129
|
className: "flex flex-wrap items-center gap-4",
|
|
130
130
|
children: [/* @__PURE__ */ j(d, { status: Q.status }), Q.plan && /* @__PURE__ */ j("span", {
|
|
131
|
-
className: "rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary",
|
|
131
|
+
className: "rounded-md bg-action-primary-bg/10 px-2 py-1 text-xs font-medium text-primary",
|
|
132
132
|
children: Q.plan
|
|
133
133
|
})]
|
|
134
134
|
})]
|
|
@@ -140,8 +140,8 @@ function F() {
|
|
|
140
140
|
children: [
|
|
141
141
|
Q.ownerEmail && /* @__PURE__ */ M("div", {
|
|
142
142
|
className: "flex items-center gap-3",
|
|
143
|
-
children: [/* @__PURE__ */ j(te, { className: "h-4 w-4 text-
|
|
144
|
-
className: "text-xs text-
|
|
143
|
+
children: [/* @__PURE__ */ j(te, { className: "h-4 w-4 text-text-secondary" }), /* @__PURE__ */ M("div", { children: [/* @__PURE__ */ j("p", {
|
|
144
|
+
className: "text-xs text-text-secondary",
|
|
145
145
|
children: U("admin.tenantDetail.overview.ownerEmail", "Owner Email")
|
|
146
146
|
}), /* @__PURE__ */ j("p", {
|
|
147
147
|
className: "text-sm font-medium",
|
|
@@ -150,8 +150,8 @@ function F() {
|
|
|
150
150
|
}),
|
|
151
151
|
Q.domain && /* @__PURE__ */ M("div", {
|
|
152
152
|
className: "flex items-center gap-3",
|
|
153
|
-
children: [/* @__PURE__ */ j(D, { className: "h-4 w-4 text-
|
|
154
|
-
className: "text-xs text-
|
|
153
|
+
children: [/* @__PURE__ */ j(D, { className: "h-4 w-4 text-text-secondary" }), /* @__PURE__ */ M("div", { children: [/* @__PURE__ */ j("p", {
|
|
154
|
+
className: "text-xs text-text-secondary",
|
|
155
155
|
children: U("admin.tenantDetail.overview.domain", "Domain")
|
|
156
156
|
}), /* @__PURE__ */ j("p", {
|
|
157
157
|
className: "text-sm font-medium",
|
|
@@ -160,8 +160,8 @@ function F() {
|
|
|
160
160
|
}),
|
|
161
161
|
/* @__PURE__ */ M("div", {
|
|
162
162
|
className: "flex items-center gap-3",
|
|
163
|
-
children: [/* @__PURE__ */ j(T, { className: "h-4 w-4 text-
|
|
164
|
-
className: "text-xs text-
|
|
163
|
+
children: [/* @__PURE__ */ j(T, { className: "h-4 w-4 text-text-secondary" }), /* @__PURE__ */ M("div", { children: [/* @__PURE__ */ j("p", {
|
|
164
|
+
className: "text-xs text-text-secondary",
|
|
165
165
|
children: U("admin.tenantDetail.overview.created", "Created")
|
|
166
166
|
}), /* @__PURE__ */ j("p", {
|
|
167
167
|
className: "text-sm font-medium",
|
|
@@ -170,8 +170,8 @@ function F() {
|
|
|
170
170
|
}),
|
|
171
171
|
/* @__PURE__ */ M("div", {
|
|
172
172
|
className: "flex items-center gap-3",
|
|
173
|
-
children: [/* @__PURE__ */ j(T, { className: "h-4 w-4 text-
|
|
174
|
-
className: "text-xs text-
|
|
173
|
+
children: [/* @__PURE__ */ j(T, { className: "h-4 w-4 text-text-secondary" }), /* @__PURE__ */ M("div", { children: [/* @__PURE__ */ j("p", {
|
|
174
|
+
className: "text-xs text-text-secondary",
|
|
175
175
|
children: U("admin.tenantDetail.overview.lastUpdated", "Last Updated")
|
|
176
176
|
}), /* @__PURE__ */ j("p", {
|
|
177
177
|
className: "text-sm font-medium",
|
|
@@ -187,12 +187,12 @@ function F() {
|
|
|
187
187
|
}) }), /* @__PURE__ */ j(o, { children: /* @__PURE__ */ j("div", {
|
|
188
188
|
className: "space-y-2",
|
|
189
189
|
children: $.map((e) => /* @__PURE__ */ M("div", {
|
|
190
|
-
className: "flex items-center justify-between rounded-lg border border-border p-3",
|
|
190
|
+
className: "flex items-center justify-between rounded-lg border border-border-subtle p-3",
|
|
191
191
|
children: [/* @__PURE__ */ j("span", {
|
|
192
192
|
className: "text-sm font-medium",
|
|
193
193
|
children: e.name
|
|
194
194
|
}), /* @__PURE__ */ j("span", {
|
|
195
|
-
className: "text-xs text-
|
|
195
|
+
className: "text-xs text-text-secondary capitalize",
|
|
196
196
|
children: e.status?.toLowerCase()
|
|
197
197
|
})]
|
|
198
198
|
}, e.id))
|
|
@@ -204,37 +204,37 @@ function F() {
|
|
|
204
204
|
className: "grid gap-4 sm:grid-cols-2",
|
|
205
205
|
children: [
|
|
206
206
|
/* @__PURE__ */ M("div", {
|
|
207
|
-
className: "flex items-center justify-between rounded-lg border border-border p-3",
|
|
207
|
+
className: "flex items-center justify-between rounded-lg border border-border-subtle p-3",
|
|
208
208
|
children: [/* @__PURE__ */ j("span", {
|
|
209
209
|
className: "text-sm",
|
|
210
210
|
children: U("admin.tenantDetail.settings.allowSignup", "Allow Signup")
|
|
211
211
|
}), /* @__PURE__ */ j("span", {
|
|
212
|
-
className: `text-sm font-medium ${Q.settings?.allowSignup ? "text-success" : "text-
|
|
212
|
+
className: `text-sm font-medium ${Q.settings?.allowSignup ? "text-success" : "text-text-secondary"}`,
|
|
213
213
|
children: Q.settings?.allowSignup ? U("admin.tenantDetail.settings.enabled", "Enabled") : U("admin.tenantDetail.settings.disabled", "Disabled")
|
|
214
214
|
})]
|
|
215
215
|
}),
|
|
216
216
|
/* @__PURE__ */ M("div", {
|
|
217
|
-
className: "flex items-center justify-between rounded-lg border border-border p-3",
|
|
217
|
+
className: "flex items-center justify-between rounded-lg border border-border-subtle p-3",
|
|
218
218
|
children: [/* @__PURE__ */ j("span", {
|
|
219
219
|
className: "text-sm",
|
|
220
220
|
children: U("admin.tenantDetail.settings.sso", "SSO")
|
|
221
221
|
}), /* @__PURE__ */ j("span", {
|
|
222
|
-
className: `text-sm font-medium ${Q.settings?.ssoEnabled ? "text-success" : "text-
|
|
222
|
+
className: `text-sm font-medium ${Q.settings?.ssoEnabled ? "text-success" : "text-text-secondary"}`,
|
|
223
223
|
children: Q.settings?.ssoEnabled ? U("admin.tenantDetail.settings.enabled", "Enabled") : U("admin.tenantDetail.settings.disabled", "Disabled")
|
|
224
224
|
})]
|
|
225
225
|
}),
|
|
226
226
|
/* @__PURE__ */ M("div", {
|
|
227
|
-
className: "flex items-center justify-between rounded-lg border border-border p-3",
|
|
227
|
+
className: "flex items-center justify-between rounded-lg border border-border-subtle p-3",
|
|
228
228
|
children: [/* @__PURE__ */ j("span", {
|
|
229
229
|
className: "text-sm",
|
|
230
230
|
children: U("admin.tenantDetail.settings.mfaRequired", "MFA Required")
|
|
231
231
|
}), /* @__PURE__ */ j("span", {
|
|
232
|
-
className: `text-sm font-medium ${Q.settings?.mfaRequired ? "text-success" : "text-
|
|
232
|
+
className: `text-sm font-medium ${Q.settings?.mfaRequired ? "text-success" : "text-text-secondary"}`,
|
|
233
233
|
children: Q.settings?.mfaRequired ? U("admin.tenantDetail.settings.yes", "Yes") : U("admin.tenantDetail.settings.no", "No")
|
|
234
234
|
})]
|
|
235
235
|
}),
|
|
236
236
|
/* @__PURE__ */ M("div", {
|
|
237
|
-
className: "flex items-center justify-between rounded-lg border border-border p-3",
|
|
237
|
+
className: "flex items-center justify-between rounded-lg border border-border-subtle p-3",
|
|
238
238
|
children: [/* @__PURE__ */ j("span", {
|
|
239
239
|
className: "text-sm",
|
|
240
240
|
children: U("admin.tenantDetail.settings.dataRetention", "Data Retention")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TenantDetailPage.js","names":[],"sources":["../../../src/admin/pages/TenantDetailPage.tsx"],"sourcesContent":["import { useParams, useNavigate } from 'react-router-dom';\nimport {\n ArrowLeft,\n Building2,\n Users,\n Mail,\n Calendar,\n Globe,\n Settings,\n Ban,\n CheckCircle,\n Loader2,\n} from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Button,\n Card,\n CardHeader,\n CardTitle,\n CardContent,\n Avatar,\n TenantStatusBadge,\n Separator,\n useToast,\n PermissionGuard,\n} from '../components';\nimport { TenantMembersPanel } from '../components/TenantMembersPanel';\nimport { TenantSsoPanel } from '../components/TenantSsoPanel';\nimport { joinPath } from '../utils/navigation';\nimport { formatDate } from '../utils/formatters';\nimport { mapTenantToUi } from '../utils/mappers';\nimport type { Tenant } from '../types';\nimport {\n useGetTenantQuery,\n useSuspendTenantMutation,\n useActivateTenantMutation,\n} from '../../generated/global-operations';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { useEventBus } from '@burdenoff/fe-libs/shared/events';\nimport { tWithFallback } from '../utils/i18n';\nimport { emitTenantLifecycle } from '../utils/tenantEvents';\n\n/**\n * Tenant detail page\n */\nexport function TenantDetailPage() {\n const { canViewTenants, canManageTenants, isLoading: permissionsLoading } = useAdminPermissions();\n\n const { tenantId } = useParams<{ tenantId: string }>();\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const toast = useToast();\n const { t } = useI18n();\n const tr = (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params);\n\n const { data, loading, error } = useGetTenantQuery({\n variables: { id: tenantId! },\n skip: !tenantId || permissionsLoading || !canViewTenants,\n fetchPolicy: 'cache-and-network',\n });\n\n const [suspendTenant, { loading: suspending }] = useSuspendTenantMutation();\n const [activateTenant, { loading: activating }] = useActivateTenantMutation();\n const events = useEventBus();\n\n const tenant: Tenant | null = data?.tenant ? mapTenantToUi(data.tenant) : null;\n\n const handleSuspend = async () => {\n if (!tenantId) return;\n // Capture before the await: a refetch could null `tenant` mid-mutation,\n // and we want consumers of `tenant.tenant.suspended` to see the slug as\n // it was when the user clicked suspend.\n const slugAtClick = tenant?.slug;\n try {\n await suspendTenant({ variables: { id: tenantId } });\n emitTenantLifecycle(events, 'tenant.tenant.suspended', {\n tenantId,\n tenantSlug: slugAtClick,\n status: 'SUSPENDED',\n });\n toast.success(\n tr('admin.tenantDetail.toast.suspendedTitle', 'Tenant suspended'),\n tr('admin.tenantDetail.toast.suspendedDescription', '{{name}} has been suspended.', {\n name: tenant?.name ?? '',\n })\n );\n } catch (err) {\n toast.error(\n tr('admin.tenantDetail.toast.errorTitle', 'Error'),\n err instanceof Error\n ? err.message\n : tr('admin.tenantDetail.toast.suspendFailed', 'Failed to suspend tenant.')\n );\n }\n };\n\n const handleActivate = async () => {\n if (!tenantId) return;\n const slugAtClick = tenant?.slug;\n try {\n await activateTenant({ variables: { id: tenantId } });\n emitTenantLifecycle(events, 'tenant.tenant.activated', {\n tenantId,\n tenantSlug: slugAtClick,\n status: 'ACTIVE',\n });\n toast.success(\n tr('admin.tenantDetail.toast.activatedTitle', 'Tenant activated'),\n tr('admin.tenantDetail.toast.activatedDescription', '{{name}} has been activated.', {\n name: tenant?.name ?? '',\n })\n );\n } catch (err) {\n toast.error(\n tr('admin.tenantDetail.toast.errorTitle', 'Error'),\n err instanceof Error\n ? err.message\n : tr('admin.tenantDetail.toast.activateFailed', 'Failed to activate tenant.')\n );\n }\n };\n\n const workspaces = data?.tenant?.workspaces ?? [];\n\n return (\n <PermissionGuard\n allowed={canViewTenants}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.tenantDetail.pageTitle', 'Tenant Details')}\n denyMessage={tr(\n 'admin.tenantDetail.denyMessage',\n \"You don't have permission to view tenant details.\"\n )}\n >\n {loading ? (\n <PageLayout title={tr('admin.tenantDetail.loadingTitle', 'Loading...')}>\n <div className=\"flex h-64 items-center justify-center\">\n <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n </div>\n </PageLayout>\n ) : error || !tenant ? (\n <PageLayout\n title={tr('admin.tenantDetail.notFoundTitle', 'Tenant Not Found')}\n description={tr(\n 'admin.tenantDetail.notFoundDescription',\n 'The requested tenant could not be found'\n )}\n >\n {error && (\n <div className=\"mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive\">\n {error.message}\n </div>\n )}\n <Button variant=\"outline\" onClick={() => navigate(joinPath(basePath, 'tenants'))}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.backToTenants', 'Back to Tenants')}\n </Button>\n </PageLayout>\n ) : (\n <PageLayout\n title={tenant.name}\n description={tr('admin.tenantDetail.tenantId', 'Tenant ID: {{id}}', { id: tenant.id })}\n actions={\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\" onClick={() => navigate(joinPath(basePath, 'tenants'))}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.back', 'Back')}\n </Button>\n {canManageTenants && (\n <Button\n variant=\"outline\"\n onClick={() => navigate(joinPath(basePath, `tenants/${tenant.id}/edit`))}\n >\n <Settings className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.edit', 'Edit')}\n </Button>\n )}\n {canManageTenants &&\n (tenant.status === 'active' ? (\n <Button\n variant=\"outline\"\n className=\"text-warning\"\n onClick={handleSuspend}\n disabled={suspending}\n >\n {suspending ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <Ban className=\"mr-2 h-4 w-4\" />\n )}\n {tr('admin.tenantDetail.suspend', 'Suspend')}\n </Button>\n ) : (\n <Button\n variant=\"outline\"\n className=\"text-success\"\n onClick={handleActivate}\n disabled={activating}\n >\n {activating ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <CheckCircle className=\"mr-2 h-4 w-4\" />\n )}\n {tr('admin.tenantDetail.activate', 'Activate')}\n </Button>\n ))}\n </div>\n }\n >\n <div className=\"grid gap-6 lg:grid-cols-3\">\n {/* Main Info */}\n <div className=\"space-y-6 lg:col-span-2\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.overview.title', 'Overview')}\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"flex items-start gap-4\">\n <Avatar fallback={tenant.name.slice(0, 2).toUpperCase()} size=\"lg\" />\n <div className=\"flex-1 space-y-4\">\n <div>\n <h2 className=\"text-xl font-semibold text-foreground\">{tenant.name}</h2>\n <p className=\"text-sm text-muted-foreground\">@{tenant.slug}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-4\">\n <TenantStatusBadge status={tenant.status} />\n {tenant.plan && (\n <span className=\"rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary\">\n {tenant.plan}\n </span>\n )}\n </div>\n </div>\n </div>\n\n <Separator className=\"my-6\" />\n\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {tenant.ownerEmail && (\n <div className=\"flex items-center gap-3\">\n <Mail className=\"h-4 w-4 text-muted-foreground\" />\n <div>\n <p className=\"text-xs text-muted-foreground\">\n {tr('admin.tenantDetail.overview.ownerEmail', 'Owner Email')}\n </p>\n <p className=\"text-sm font-medium\">{tenant.ownerEmail}</p>\n </div>\n </div>\n )}\n {tenant.domain && (\n <div className=\"flex items-center gap-3\">\n <Globe className=\"h-4 w-4 text-muted-foreground\" />\n <div>\n <p className=\"text-xs text-muted-foreground\">\n {tr('admin.tenantDetail.overview.domain', 'Domain')}\n </p>\n <p className=\"text-sm font-medium\">{tenant.domain}</p>\n </div>\n </div>\n )}\n <div className=\"flex items-center gap-3\">\n <Calendar className=\"h-4 w-4 text-muted-foreground\" />\n <div>\n <p className=\"text-xs text-muted-foreground\">\n {tr('admin.tenantDetail.overview.created', 'Created')}\n </p>\n <p className=\"text-sm font-medium\">{formatDate(tenant.createdAt)}</p>\n </div>\n </div>\n <div className=\"flex items-center gap-3\">\n <Calendar className=\"h-4 w-4 text-muted-foreground\" />\n <div>\n <p className=\"text-xs text-muted-foreground\">\n {tr('admin.tenantDetail.overview.lastUpdated', 'Last Updated')}\n </p>\n <p className=\"text-sm font-medium\">{formatDate(tenant.updatedAt)}</p>\n </div>\n </div>\n </div>\n </CardContent>\n </Card>\n\n {/* Workspaces */}\n {workspaces.length > 0 && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.workspaces.title', 'Workspaces ({{count}})', {\n count: workspaces.length,\n })}\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-2\">\n {workspaces.map(\n (ws: { id: string; name: string; status?: string | null }) => (\n <div\n key={ws.id}\n className=\"flex items-center justify-between rounded-lg border border-border p-3\"\n >\n <span className=\"text-sm font-medium\">{ws.name}</span>\n <span className=\"text-xs text-muted-foreground capitalize\">\n {ws.status?.toLowerCase()}\n </span>\n </div>\n )\n )}\n </div>\n </CardContent>\n </Card>\n )}\n\n {/* Config */}\n {tenant.settings && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.settings.title', 'Settings')}\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <div className=\"flex items-center justify-between rounded-lg border border-border p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.allowSignup', 'Allow Signup')}\n </span>\n <span\n className={`text-sm font-medium ${\n tenant.settings?.allowSignup ? 'text-success' : 'text-muted-foreground'\n }`}\n >\n {tenant.settings?.allowSignup\n ? tr('admin.tenantDetail.settings.enabled', 'Enabled')\n : tr('admin.tenantDetail.settings.disabled', 'Disabled')}\n </span>\n </div>\n <div className=\"flex items-center justify-between rounded-lg border border-border p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.sso', 'SSO')}\n </span>\n <span\n className={`text-sm font-medium ${\n tenant.settings?.ssoEnabled ? 'text-success' : 'text-muted-foreground'\n }`}\n >\n {tenant.settings?.ssoEnabled\n ? tr('admin.tenantDetail.settings.enabled', 'Enabled')\n : tr('admin.tenantDetail.settings.disabled', 'Disabled')}\n </span>\n </div>\n <div className=\"flex items-center justify-between rounded-lg border border-border p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.mfaRequired', 'MFA Required')}\n </span>\n <span\n className={`text-sm font-medium ${\n tenant.settings?.mfaRequired ? 'text-success' : 'text-muted-foreground'\n }`}\n >\n {tenant.settings?.mfaRequired\n ? tr('admin.tenantDetail.settings.yes', 'Yes')\n : tr('admin.tenantDetail.settings.no', 'No')}\n </span>\n </div>\n <div className=\"flex items-center justify-between rounded-lg border border-border p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.dataRetention', 'Data Retention')}\n </span>\n <span className=\"text-sm font-medium\">\n {tr('admin.tenantDetail.settings.dataRetentionDays', '{{days}} days', {\n days: tenant.settings?.dataRetentionDays ?? 0,\n })}\n </span>\n </div>\n </div>\n </CardContent>\n </Card>\n )}\n\n {/* SSO Configuration */}\n <TenantSsoPanel tenantId={tenant.id} canManage={canManageTenants} />\n\n {/* Tenant Members (BOFF-2652) — gates createOrganization on DEDICATED tenants */}\n <TenantMembersPanel\n tenantId={tenant.id}\n tenantType={tenant.type ?? ''}\n canManage={canManageTenants}\n />\n </div>\n\n {/* Sidebar */}\n <div className=\"space-y-6\">\n {/* Actions */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.actions.title', 'Actions')}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-2\">\n <Button\n variant=\"outline\"\n className=\"w-full justify-start\"\n onClick={() => navigate(joinPath(basePath, `users?tenantId=${tenant.id}`))}\n >\n <Users className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.actions.viewUsers', 'View Users')}\n </Button>\n <Button\n variant=\"outline\"\n className=\"w-full justify-start\"\n onClick={() =>\n navigate(joinPath(basePath, `billing/subscriptions?tenantId=${tenant.id}`))\n }\n >\n <Building2 className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.actions.viewBilling', 'View Billing')}\n </Button>\n </CardContent>\n </Card>\n </div>\n </div>\n </PageLayout>\n )}\n </PermissionGuard>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA+CA,SAAgB,IAAmB;CACjC,IAAM,EAAE,mBAAgB,qBAAkB,WAAW,MAAuB,GAAqB,EAE3F,EAAE,gBAAa,GAAiC,EAChD,IAAW,GAAa,EACxB,EAAE,cAAW,OAAO,GAAU,EAC9B,IAAQ,GAAU,EAClB,EAAE,SAAM,GAAS,EACjB,KAAM,GAAa,GAAkB,MACzC,EAAc,GAAG,GAAK,GAAU,EAAO,EAEnC,EAAE,SAAM,YAAS,aAAU,EAAkB;EACjD,WAAW,EAAE,IAAI,GAAW;EAC5B,MAAM,CAAC,KAAY,KAAsB,CAAC;EAC1C,aAAa;EACd,CAAC,EAEI,CAAC,GAAe,EAAE,SAAS,OAAgB,GAA0B,EACrE,CAAC,GAAgB,EAAE,SAAS,OAAgB,GAA2B,EACvE,IAAS,GAAa,EAEtB,IAAwB,GAAM,SAAS,EAAc,EAAK,OAAO,GAAG,MAEpE,KAAgB,YAAY;AAChC,MAAI,CAAC,EAAU;EAIf,IAAM,IAAc,GAAQ;AAC5B,MAAI;AAOF,GANA,MAAM,EAAc,EAAE,WAAW,EAAE,IAAI,GAAU,EAAE,CAAC,EACpD,EAAoB,GAAQ,2BAA2B;IACrD;IACA,YAAY;IACZ,QAAQ;IACT,CAAC,EACF,EAAM,QACJ,EAAG,2CAA2C,mBAAmB,EACjE,EAAG,iDAAiD,gCAAgC,EAClF,MAAM,GAAQ,QAAQ,IACvB,CAAC,CACH;WACM,GAAK;AACZ,KAAM,MACJ,EAAG,uCAAuC,QAAQ,EAClD,aAAe,QACX,EAAI,UACJ,EAAG,0CAA0C,4BAA4B,CAC9E;;IAIC,KAAiB,YAAY;AACjC,MAAI,CAAC,EAAU;EACf,IAAM,IAAc,GAAQ;AAC5B,MAAI;AAOF,GANA,MAAM,EAAe,EAAE,WAAW,EAAE,IAAI,GAAU,EAAE,CAAC,EACrD,EAAoB,GAAQ,2BAA2B;IACrD;IACA,YAAY;IACZ,QAAQ;IACT,CAAC,EACF,EAAM,QACJ,EAAG,2CAA2C,mBAAmB,EACjE,EAAG,iDAAiD,gCAAgC,EAClF,MAAM,GAAQ,QAAQ,IACvB,CAAC,CACH;WACM,GAAK;AACZ,KAAM,MACJ,EAAG,uCAAuC,QAAQ,EAClD,aAAe,QACX,EAAI,UACJ,EAAG,2CAA2C,6BAA6B,CAChF;;IAIC,IAAa,GAAM,QAAQ,cAAc,EAAE;AAEjD,QACE,kBAAC,IAAD;EACE,SAAS;EACT,WAAW;EACX,WAAW,EAAG,gCAAgC,iBAAiB;EAC/D,aAAa,EACX,kCACA,oDACD;YAEA,IACC,kBAAC,GAAD;GAAY,OAAO,EAAG,mCAAmC,aAAa;aACpE,kBAAC,OAAD;IAAK,WAAU;cACb,kBAAC,GAAD,EAAS,WAAU,8CAA+C,CAAA;IAC9D,CAAA;GACK,CAAA,GACX,KAAS,CAAC,IACZ,kBAAC,GAAD;GACE,OAAO,EAAG,oCAAoC,mBAAmB;GACjE,aAAa,EACX,0CACA,0CACD;aALH,CAOG,KACC,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAM;IACH,CAAA,EAER,kBAAC,GAAD;IAAQ,SAAQ;IAAU,eAAe,EAAS,EAAS,GAAU,UAAU,CAAC;cAAhF,CACE,kBAAC,GAAD,EAAW,WAAU,gBAAiB,CAAA,EACrC,EAAG,oCAAoC,kBAAkB,CACnD;MACE;OAEb,kBAAC,GAAD;GACE,OAAO,EAAO;GACd,aAAa,EAAG,+BAA+B,qBAAqB,EAAE,IAAI,EAAO,IAAI,CAAC;GACtF,SACE,kBAAC,OAAD;IAAK,WAAU;cAAf;KACE,kBAAC,GAAD;MAAQ,SAAQ;MAAU,eAAe,EAAS,EAAS,GAAU,UAAU,CAAC;gBAAhF,CACE,kBAAC,GAAD,EAAW,WAAU,gBAAiB,CAAA,EACrC,EAAG,2BAA2B,OAAO,CAC/B;;KACR,KACC,kBAAC,GAAD;MACE,SAAQ;MACR,eAAe,EAAS,EAAS,GAAU,WAAW,EAAO,GAAG,OAAO,CAAC;gBAF1E,CAIE,kBAAC,IAAD,EAAU,WAAU,gBAAiB,CAAA,EACpC,EAAG,2BAA2B,OAAO,CAC/B;;KAEV,MACE,EAAO,WAAW,WACjB,kBAAC,GAAD;MACE,SAAQ;MACR,WAAU;MACV,SAAS;MACT,UAAU;gBAJZ,CAMG,IACC,kBAAC,GAAD,EAAS,WAAU,6BAA8B,CAAA,GAEjD,kBAAC,GAAD,EAAK,WAAU,gBAAiB,CAAA,EAEjC,EAAG,8BAA8B,UAAU,CACrC;UAET,kBAAC,GAAD;MACE,SAAQ;MACR,WAAU;MACV,SAAS;MACT,UAAU;gBAJZ,CAMG,IACC,kBAAC,GAAD,EAAS,WAAU,6BAA8B,CAAA,GAEjD,kBAAC,GAAD,EAAa,WAAU,gBAAiB,CAAA,EAEzC,EAAG,+BAA+B,WAAW,CACvC;;KAET;;aAGR,kBAAC,OAAD;IAAK,WAAU;cAAf,CAEE,kBAAC,OAAD;KAAK,WAAU;eAAf;MACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;OAAW,WAAU;iBAClB,EAAG,qCAAqC,WAAW;OAC1C,CAAA,EACD,CAAA,EACb,kBAAC,GAAD,EAAA,UAAA;OACE,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,GAAD;SAAQ,UAAU,EAAO,KAAK,MAAM,GAAG,EAAE,CAAC,aAAa;SAAE,MAAK;SAAO,CAAA,EACrE,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,MAAD;UAAI,WAAU;oBAAyC,EAAO;UAAU,CAAA,EACxE,kBAAC,KAAD;UAAG,WAAU;oBAAb,CAA6C,KAAE,EAAO,KAAS;YAC3D,EAAA,CAAA,EACN,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD,EAAmB,QAAQ,EAAO,QAAU,CAAA,EAC3C,EAAO,QACN,kBAAC,QAAD;WAAM,WAAU;qBACb,EAAO;WACH,CAAA,CAEL;YACF;WACF;;OAEN,kBAAC,GAAD,EAAW,WAAU,QAAS,CAAA;OAE9B,kBAAC,OAAD;QAAK,WAAU;kBAAf;SACG,EAAO,cACN,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,IAAD,EAAM,WAAU,iCAAkC,CAAA,EAClD,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,KAAD;WAAG,WAAU;qBACV,EAAG,0CAA0C,cAAc;WAC1D,CAAA,EACJ,kBAAC,KAAD;WAAG,WAAU;qBAAuB,EAAO;WAAe,CAAA,CACtD,EAAA,CAAA,CACF;;SAEP,EAAO,UACN,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD,EAAO,WAAU,iCAAkC,CAAA,EACnD,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,KAAD;WAAG,WAAU;qBACV,EAAG,sCAAsC,SAAS;WACjD,CAAA,EACJ,kBAAC,KAAD;WAAG,WAAU;qBAAuB,EAAO;WAAW,CAAA,CAClD,EAAA,CAAA,CACF;;SAER,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD,EAAU,WAAU,iCAAkC,CAAA,EACtD,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,KAAD;WAAG,WAAU;qBACV,EAAG,uCAAuC,UAAU;WACnD,CAAA,EACJ,kBAAC,KAAD;WAAG,WAAU;qBAAuB,EAAW,EAAO,UAAU;WAAK,CAAA,CACjE,EAAA,CAAA,CACF;;SACN,kBAAC,OAAD;UAAK,WAAU;oBAAf,CACE,kBAAC,GAAD,EAAU,WAAU,iCAAkC,CAAA,EACtD,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,KAAD;WAAG,WAAU;qBACV,EAAG,2CAA2C,eAAe;WAC5D,CAAA,EACJ,kBAAC,KAAD;WAAG,WAAU;qBAAuB,EAAW,EAAO,UAAU;WAAK,CAAA,CACjE,EAAA,CAAA,CACF;;SACF;;OACM,EAAA,CAAA,CACT,EAAA,CAAA;MAGN,EAAW,SAAS,KACnB,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;OAAW,WAAU;iBAClB,EAAG,uCAAuC,0BAA0B,EACnE,OAAO,EAAW,QACnB,CAAC;OACQ,CAAA,EACD,CAAA,EACb,kBAAC,GAAD,EAAA,UACE,kBAAC,OAAD;OAAK,WAAU;iBACZ,EAAW,KACT,MACC,kBAAC,OAAD;QAEE,WAAU;kBAFZ,CAIE,kBAAC,QAAD;SAAM,WAAU;mBAAuB,EAAG;SAAY,CAAA,EACtD,kBAAC,QAAD;SAAM,WAAU;mBACb,EAAG,QAAQ,aAAa;SACpB,CAAA,CACH;UAPC,EAAG,GAOJ,CAET;OACG,CAAA,EACM,CAAA,CACT,EAAA,CAAA;MAIR,EAAO,YACN,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;OAAW,WAAU;iBAClB,EAAG,qCAAqC,WAAW;OAC1C,CAAA,EACD,CAAA,EACb,kBAAC,GAAD,EAAA,UACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,QAAD;UAAM,WAAU;oBACb,EAAG,2CAA2C,eAAe;UACzD,CAAA,EACP,kBAAC,QAAD;UACE,WAAW,uBACT,EAAO,UAAU,cAAc,iBAAiB;oBAGjD,EAAO,UAAU,cACd,EAAG,uCAAuC,UAAU,GACpD,EAAG,wCAAwC,WAAW;UACrD,CAAA,CACH;;QACN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,QAAD;UAAM,WAAU;oBACb,EAAG,mCAAmC,MAAM;UACxC,CAAA,EACP,kBAAC,QAAD;UACE,WAAW,uBACT,EAAO,UAAU,aAAa,iBAAiB;oBAGhD,EAAO,UAAU,aACd,EAAG,uCAAuC,UAAU,GACpD,EAAG,wCAAwC,WAAW;UACrD,CAAA,CACH;;QACN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,QAAD;UAAM,WAAU;oBACb,EAAG,2CAA2C,eAAe;UACzD,CAAA,EACP,kBAAC,QAAD;UACE,WAAW,uBACT,EAAO,UAAU,cAAc,iBAAiB;oBAGjD,EAAO,UAAU,cACd,EAAG,mCAAmC,MAAM,GAC5C,EAAG,kCAAkC,KAAK;UACzC,CAAA,CACH;;QACN,kBAAC,OAAD;SAAK,WAAU;mBAAf,CACE,kBAAC,QAAD;UAAM,WAAU;oBACb,EAAG,6CAA6C,iBAAiB;UAC7D,CAAA,EACP,kBAAC,QAAD;UAAM,WAAU;oBACb,EAAG,iDAAiD,iBAAiB,EACpE,MAAM,EAAO,UAAU,qBAAqB,GAC7C,CAAC;UACG,CAAA,CACH;;QACF;UACM,CAAA,CACT,EAAA,CAAA;MAIT,kBAAC,GAAD;OAAgB,UAAU,EAAO;OAAI,WAAW;OAAoB,CAAA;MAGpE,kBAAC,GAAD;OACE,UAAU,EAAO;OACjB,YAAY,EAAO,QAAQ;OAC3B,WAAW;OACX,CAAA;MACE;QAGN,kBAAC,OAAD;KAAK,WAAU;eAEb,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;MAAW,WAAU;gBAClB,EAAG,oCAAoC,UAAU;MACxC,CAAA,EACD,CAAA,EACb,kBAAC,GAAD;MAAa,WAAU;gBAAvB,CACE,kBAAC,GAAD;OACE,SAAQ;OACR,WAAU;OACV,eAAe,EAAS,EAAS,GAAU,kBAAkB,EAAO,KAAK,CAAC;iBAH5E,CAKE,kBAAC,IAAD,EAAO,WAAU,gBAAiB,CAAA,EACjC,EAAG,wCAAwC,aAAa,CAClD;UACT,kBAAC,GAAD;OACE,SAAQ;OACR,WAAU;OACV,eACE,EAAS,EAAS,GAAU,kCAAkC,EAAO,KAAK,CAAC;iBAJ/E,CAOE,kBAAC,GAAD,EAAW,WAAU,gBAAiB,CAAA,EACrC,EAAG,0CAA0C,eAAe,CACtD;SACG;QACT,EAAA,CAAA;KACH,CAAA,CACF;;GACK,CAAA;EAEC,CAAA"}
|
|
1
|
+
{"version":3,"file":"TenantDetailPage.js","names":[],"sources":["../../../src/admin/pages/TenantDetailPage.tsx"],"sourcesContent":["import { useParams, useNavigate } from 'react-router-dom';\nimport {\n ArrowLeft,\n Building2,\n Users,\n Mail,\n Calendar,\n Globe,\n Settings,\n Ban,\n CheckCircle,\n Loader2,\n} from 'lucide-react';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport {\n PageLayout,\n Button,\n Card,\n CardHeader,\n CardTitle,\n CardContent,\n Avatar,\n TenantStatusBadge,\n Separator,\n useToast,\n PermissionGuard,\n} from '../components';\nimport { TenantMembersPanel } from '../components/TenantMembersPanel';\nimport { TenantSsoPanel } from '../components/TenantSsoPanel';\nimport { joinPath } from '../utils/navigation';\nimport { formatDate } from '../utils/formatters';\nimport { mapTenantToUi } from '../utils/mappers';\nimport type { Tenant } from '../types';\nimport {\n useGetTenantQuery,\n useSuspendTenantMutation,\n useActivateTenantMutation,\n} from '../../generated/global-operations';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { useEventBus } from '@burdenoff/fe-libs/shared/events';\nimport { tWithFallback } from '../utils/i18n';\nimport { emitTenantLifecycle } from '../utils/tenantEvents';\n\n/**\n * Tenant detail page\n */\nexport function TenantDetailPage() {\n const { canViewTenants, canManageTenants, isLoading: permissionsLoading } = useAdminPermissions();\n\n const { tenantId } = useParams<{ tenantId: string }>();\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const toast = useToast();\n const { t } = useI18n();\n const tr = (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params);\n\n const { data, loading, error } = useGetTenantQuery({\n variables: { id: tenantId! },\n skip: !tenantId || permissionsLoading || !canViewTenants,\n fetchPolicy: 'cache-and-network',\n });\n\n const [suspendTenant, { loading: suspending }] = useSuspendTenantMutation();\n const [activateTenant, { loading: activating }] = useActivateTenantMutation();\n const events = useEventBus();\n\n const tenant: Tenant | null = data?.tenant ? mapTenantToUi(data.tenant) : null;\n\n const handleSuspend = async () => {\n if (!tenantId) return;\n // Capture before the await: a refetch could null `tenant` mid-mutation,\n // and we want consumers of `tenant.tenant.suspended` to see the slug as\n // it was when the user clicked suspend.\n const slugAtClick = tenant?.slug;\n try {\n await suspendTenant({ variables: { id: tenantId } });\n emitTenantLifecycle(events, 'tenant.tenant.suspended', {\n tenantId,\n tenantSlug: slugAtClick,\n status: 'SUSPENDED',\n });\n toast.success(\n tr('admin.tenantDetail.toast.suspendedTitle', 'Tenant suspended'),\n tr('admin.tenantDetail.toast.suspendedDescription', '{{name}} has been suspended.', {\n name: tenant?.name ?? '',\n })\n );\n } catch (err) {\n toast.error(\n tr('admin.tenantDetail.toast.errorTitle', 'Error'),\n err instanceof Error\n ? err.message\n : tr('admin.tenantDetail.toast.suspendFailed', 'Failed to suspend tenant.')\n );\n }\n };\n\n const handleActivate = async () => {\n if (!tenantId) return;\n const slugAtClick = tenant?.slug;\n try {\n await activateTenant({ variables: { id: tenantId } });\n emitTenantLifecycle(events, 'tenant.tenant.activated', {\n tenantId,\n tenantSlug: slugAtClick,\n status: 'ACTIVE',\n });\n toast.success(\n tr('admin.tenantDetail.toast.activatedTitle', 'Tenant activated'),\n tr('admin.tenantDetail.toast.activatedDescription', '{{name}} has been activated.', {\n name: tenant?.name ?? '',\n })\n );\n } catch (err) {\n toast.error(\n tr('admin.tenantDetail.toast.errorTitle', 'Error'),\n err instanceof Error\n ? err.message\n : tr('admin.tenantDetail.toast.activateFailed', 'Failed to activate tenant.')\n );\n }\n };\n\n const workspaces = data?.tenant?.workspaces ?? [];\n\n return (\n <PermissionGuard\n allowed={canViewTenants}\n isLoading={permissionsLoading}\n pageTitle={tr('admin.tenantDetail.pageTitle', 'Tenant Details')}\n denyMessage={tr(\n 'admin.tenantDetail.denyMessage',\n \"You don't have permission to view tenant details.\"\n )}\n >\n {loading ? (\n <PageLayout title={tr('admin.tenantDetail.loadingTitle', 'Loading...')}>\n <div className=\"flex h-64 items-center justify-center\">\n <Loader2 className=\"h-8 w-8 animate-spin text-text-secondary\" />\n </div>\n </PageLayout>\n ) : error || !tenant ? (\n <PageLayout\n title={tr('admin.tenantDetail.notFoundTitle', 'Tenant Not Found')}\n description={tr(\n 'admin.tenantDetail.notFoundDescription',\n 'The requested tenant could not be found'\n )}\n >\n {error && (\n <div className=\"mb-4 rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n {error.message}\n </div>\n )}\n <Button variant=\"outline\" onClick={() => navigate(joinPath(basePath, 'tenants'))}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.backToTenants', 'Back to Tenants')}\n </Button>\n </PageLayout>\n ) : (\n <PageLayout\n title={tenant.name}\n description={tr('admin.tenantDetail.tenantId', 'Tenant ID: {{id}}', { id: tenant.id })}\n actions={\n <div className=\"flex items-center gap-2\">\n <Button variant=\"outline\" onClick={() => navigate(joinPath(basePath, 'tenants'))}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.back', 'Back')}\n </Button>\n {canManageTenants && (\n <Button\n variant=\"outline\"\n onClick={() => navigate(joinPath(basePath, `tenants/${tenant.id}/edit`))}\n >\n <Settings className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.edit', 'Edit')}\n </Button>\n )}\n {canManageTenants &&\n (tenant.status === 'active' ? (\n <Button\n variant=\"outline\"\n className=\"text-warning\"\n onClick={handleSuspend}\n disabled={suspending}\n >\n {suspending ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <Ban className=\"mr-2 h-4 w-4\" />\n )}\n {tr('admin.tenantDetail.suspend', 'Suspend')}\n </Button>\n ) : (\n <Button\n variant=\"outline\"\n className=\"text-success\"\n onClick={handleActivate}\n disabled={activating}\n >\n {activating ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <CheckCircle className=\"mr-2 h-4 w-4\" />\n )}\n {tr('admin.tenantDetail.activate', 'Activate')}\n </Button>\n ))}\n </div>\n }\n >\n <div className=\"grid gap-6 lg:grid-cols-3\">\n {/* Main Info */}\n <div className=\"space-y-6 lg:col-span-2\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.overview.title', 'Overview')}\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"flex items-start gap-4\">\n <Avatar fallback={tenant.name.slice(0, 2).toUpperCase()} size=\"lg\" />\n <div className=\"flex-1 space-y-4\">\n <div>\n <h2 className=\"text-xl font-semibold text-text-primary\">{tenant.name}</h2>\n <p className=\"text-sm text-text-secondary\">@{tenant.slug}</p>\n </div>\n <div className=\"flex flex-wrap items-center gap-4\">\n <TenantStatusBadge status={tenant.status} />\n {tenant.plan && (\n <span className=\"rounded-md bg-action-primary-bg/10 px-2 py-1 text-xs font-medium text-primary\">\n {tenant.plan}\n </span>\n )}\n </div>\n </div>\n </div>\n\n <Separator className=\"my-6\" />\n\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {tenant.ownerEmail && (\n <div className=\"flex items-center gap-3\">\n <Mail className=\"h-4 w-4 text-text-secondary\" />\n <div>\n <p className=\"text-xs text-text-secondary\">\n {tr('admin.tenantDetail.overview.ownerEmail', 'Owner Email')}\n </p>\n <p className=\"text-sm font-medium\">{tenant.ownerEmail}</p>\n </div>\n </div>\n )}\n {tenant.domain && (\n <div className=\"flex items-center gap-3\">\n <Globe className=\"h-4 w-4 text-text-secondary\" />\n <div>\n <p className=\"text-xs text-text-secondary\">\n {tr('admin.tenantDetail.overview.domain', 'Domain')}\n </p>\n <p className=\"text-sm font-medium\">{tenant.domain}</p>\n </div>\n </div>\n )}\n <div className=\"flex items-center gap-3\">\n <Calendar className=\"h-4 w-4 text-text-secondary\" />\n <div>\n <p className=\"text-xs text-text-secondary\">\n {tr('admin.tenantDetail.overview.created', 'Created')}\n </p>\n <p className=\"text-sm font-medium\">{formatDate(tenant.createdAt)}</p>\n </div>\n </div>\n <div className=\"flex items-center gap-3\">\n <Calendar className=\"h-4 w-4 text-text-secondary\" />\n <div>\n <p className=\"text-xs text-text-secondary\">\n {tr('admin.tenantDetail.overview.lastUpdated', 'Last Updated')}\n </p>\n <p className=\"text-sm font-medium\">{formatDate(tenant.updatedAt)}</p>\n </div>\n </div>\n </div>\n </CardContent>\n </Card>\n\n {/* Workspaces */}\n {workspaces.length > 0 && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.workspaces.title', 'Workspaces ({{count}})', {\n count: workspaces.length,\n })}\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"space-y-2\">\n {workspaces.map(\n (ws: { id: string; name: string; status?: string | null }) => (\n <div\n key={ws.id}\n className=\"flex items-center justify-between rounded-lg border border-border-subtle p-3\"\n >\n <span className=\"text-sm font-medium\">{ws.name}</span>\n <span className=\"text-xs text-text-secondary capitalize\">\n {ws.status?.toLowerCase()}\n </span>\n </div>\n )\n )}\n </div>\n </CardContent>\n </Card>\n )}\n\n {/* Config */}\n {tenant.settings && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.settings.title', 'Settings')}\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"grid gap-4 sm:grid-cols-2\">\n <div className=\"flex items-center justify-between rounded-lg border border-border-subtle p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.allowSignup', 'Allow Signup')}\n </span>\n <span\n className={`text-sm font-medium ${\n tenant.settings?.allowSignup ? 'text-success' : 'text-text-secondary'\n }`}\n >\n {tenant.settings?.allowSignup\n ? tr('admin.tenantDetail.settings.enabled', 'Enabled')\n : tr('admin.tenantDetail.settings.disabled', 'Disabled')}\n </span>\n </div>\n <div className=\"flex items-center justify-between rounded-lg border border-border-subtle p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.sso', 'SSO')}\n </span>\n <span\n className={`text-sm font-medium ${\n tenant.settings?.ssoEnabled ? 'text-success' : 'text-text-secondary'\n }`}\n >\n {tenant.settings?.ssoEnabled\n ? tr('admin.tenantDetail.settings.enabled', 'Enabled')\n : tr('admin.tenantDetail.settings.disabled', 'Disabled')}\n </span>\n </div>\n <div className=\"flex items-center justify-between rounded-lg border border-border-subtle p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.mfaRequired', 'MFA Required')}\n </span>\n <span\n className={`text-sm font-medium ${\n tenant.settings?.mfaRequired ? 'text-success' : 'text-text-secondary'\n }`}\n >\n {tenant.settings?.mfaRequired\n ? tr('admin.tenantDetail.settings.yes', 'Yes')\n : tr('admin.tenantDetail.settings.no', 'No')}\n </span>\n </div>\n <div className=\"flex items-center justify-between rounded-lg border border-border-subtle p-3\">\n <span className=\"text-sm\">\n {tr('admin.tenantDetail.settings.dataRetention', 'Data Retention')}\n </span>\n <span className=\"text-sm font-medium\">\n {tr('admin.tenantDetail.settings.dataRetentionDays', '{{days}} days', {\n days: tenant.settings?.dataRetentionDays ?? 0,\n })}\n </span>\n </div>\n </div>\n </CardContent>\n </Card>\n )}\n\n {/* SSO Configuration */}\n <TenantSsoPanel tenantId={tenant.id} canManage={canManageTenants} />\n\n {/* Tenant Members (BOFF-2652) — gates createOrganization on DEDICATED tenants */}\n <TenantMembersPanel\n tenantId={tenant.id}\n tenantType={tenant.type ?? ''}\n canManage={canManageTenants}\n />\n </div>\n\n {/* Sidebar */}\n <div className=\"space-y-6\">\n {/* Actions */}\n <Card>\n <CardHeader>\n <CardTitle className=\"text-lg\">\n {tr('admin.tenantDetail.actions.title', 'Actions')}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-2\">\n <Button\n variant=\"outline\"\n className=\"w-full justify-start\"\n onClick={() => navigate(joinPath(basePath, `users?tenantId=${tenant.id}`))}\n >\n <Users className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.actions.viewUsers', 'View Users')}\n </Button>\n <Button\n variant=\"outline\"\n className=\"w-full justify-start\"\n onClick={() =>\n navigate(joinPath(basePath, `billing/subscriptions?tenantId=${tenant.id}`))\n }\n >\n <Building2 className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantDetail.actions.viewBilling', 'View Billing')}\n </Button>\n </CardContent>\n </Card>\n </div>\n </div>\n </PageLayout>\n )}\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -97,13 +97,13 @@ function E({ mode: E }) {
|
|
|
97
97
|
title: L("admin.tenantForm.loadingTitle", "Loading tenant..."),
|
|
98
98
|
children: /* @__PURE__ */ x("div", {
|
|
99
99
|
className: "flex h-64 items-center justify-center",
|
|
100
|
-
children: /* @__PURE__ */ x(_, { className: "h-8 w-8 animate-spin text-
|
|
100
|
+
children: /* @__PURE__ */ x(_, { className: "h-8 w-8 animate-spin text-text-secondary" })
|
|
101
101
|
})
|
|
102
102
|
}) : P && (H || !G) ? /* @__PURE__ */ S(i, {
|
|
103
103
|
title: L("admin.tenantForm.notFoundTitle", "Tenant Not Found"),
|
|
104
104
|
description: L("admin.tenantForm.notFoundDescription", "The tenant you want to edit could not be found."),
|
|
105
105
|
children: [H && /* @__PURE__ */ x("div", {
|
|
106
|
-
className: "mb-4 rounded-md border border-
|
|
106
|
+
className: "mb-4 rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text",
|
|
107
107
|
children: H.message
|
|
108
108
|
}), /* @__PURE__ */ S(r, {
|
|
109
109
|
variant: "outline",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TenantFormRoutePage.js","names":[],"sources":["../../../src/admin/pages/TenantFormRoutePage.tsx"],"sourcesContent":["import { useCallback, useMemo } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { ArrowLeft, Loader2 } from 'lucide-react';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { useFeatureFlag } from '@burdenoff/fe-libs/shared/feature-flags';\nimport { useEventBus } from '@burdenoff/fe-libs/shared/events';\nimport { emitTenantLifecycle } from '../utils/tenantEvents';\nimport {\n useCreateTenantMutation,\n useGetTenantQuery,\n useUpdateTenantMutation,\n} from '../../generated/global-operations';\nimport { Button, PageLayout, PermissionGuard, TenantForm, useToast } from '../components';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport type { CreateTenantInput, UpdateTenantInput } from '../schemas';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { joinPath } from '../utils/navigation';\nimport { tWithFallback } from '../utils/i18n';\nimport { mapTenantToUi } from '../utils/mappers';\n\ntype TenantFormRoutePageProps = {\n mode: 'create' | 'edit';\n};\n\nexport function TenantFormRoutePage({ mode }: TenantFormRoutePageProps) {\n const { tenantId } = useParams<{ tenantId: string }>();\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const toast = useToast();\n const { t } = useI18n();\n const { canManageTenants, isLoading: permissionsLoading } = useAdminPermissions();\n const isEdit = mode === 'edit';\n // Page-level feature flags. Default-allow keeps prior behavior intact when\n // the flag is missing; turning the flag OFF in OpenBao/feature-flag-svc\n // disables the corresponding route without a code deploy.\n const flagKey = isEdit ? 'admin.tenant.edit' : 'admin.tenant.create';\n const flagEnabled = useFeatureFlag(flagKey, true);\n const events = useEventBus();\n const tr = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params),\n [t]\n );\n\n const tenantsPath = joinPath(basePath, 'tenants');\n const closePath = isEdit && tenantId ? joinPath(basePath, `tenants/${tenantId}`) : tenantsPath;\n\n const { data, loading, error } = useGetTenantQuery({\n variables: { id: tenantId ?? '' },\n // Also skip when the page is feature-flag-disabled: the early return\n // below renders the \"feature disabled\" panel instead of the form, so a\n // network round-trip would be wasted.\n skip: !isEdit || !tenantId || permissionsLoading || !canManageTenants || !flagEnabled,\n fetchPolicy: 'cache-and-network',\n });\n\n const [createTenant] = useCreateTenantMutation();\n const [updateTenant] = useUpdateTenantMutation();\n\n // Memoize the mapped tenant so its object identity is stable across\n // renders. mapTenantToUi otherwise produces a new object on every render,\n // forcing downstream memoization (TenantForm.defaultValues, reset effects)\n // to fan out unnecessarily.\n const tenant = useMemo(\n () => (data?.tenant ? mapTenantToUi(data.tenant) : undefined),\n [data?.tenant]\n );\n\n const handleClose = useCallback(() => {\n navigate(closePath);\n }, [navigate, closePath]);\n\n const handleSave = useCallback(\n async (input: CreateTenantInput | UpdateTenantInput) => {\n if (isEdit) {\n if (!tenantId) return;\n try {\n const updated = await updateTenant({\n variables: {\n id: tenantId,\n input: {\n name: input.name,\n customDomain: input.domain,\n metadata: {\n ownerEmail: input.ownerEmail,\n billingEmail: input.billingEmail,\n plan: input.plan,\n } as Record<string, unknown>,\n },\n },\n });\n // The update mutation does not accept slug, so emit the slug from\n // the persisted record (mutation response, falling back to the\n // already-loaded tenant) rather than the user's form input — the\n // form's slug field is read-only for edits, but a defensive emit\n // protects listeners against any future change.\n emitTenantLifecycle(events, 'tenant.tenant.updated', {\n tenantId,\n tenantSlug: updated.data?.updateTenant?.slug ?? tenant?.slug,\n });\n toast.success(\n tr('admin.tenants.toast.updatedTitle', 'Tenant updated'),\n tr('admin.tenants.toast.updatedDescription', '{{name}} has been updated.', {\n name: input.name ?? '',\n })\n );\n navigate(joinPath(basePath, `tenants/${tenantId}`));\n } catch (err) {\n toast.error(\n tr('admin.tenants.toast.updateFailedTitle', 'Update failed'),\n err instanceof Error ? err.message : ''\n );\n // Rethrow so TenantForm.onSubmit's try/catch sees the rejection\n // and keeps the form open instead of calling onClose() — losing\n // the user's edits would be a worse outcome than a duplicate\n // surfacing of the same error.\n throw err;\n }\n return;\n }\n\n try {\n const result = await createTenant({\n variables: {\n input: {\n name: input.name!,\n slug: input.slug!,\n customDomain: input.domain,\n metadata: {\n ownerEmail: input.ownerEmail,\n billingEmail: input.billingEmail,\n plan: input.plan,\n } as Record<string, unknown>,\n },\n },\n });\n const created = result.data?.createTenant;\n if (!created?.id) {\n // A resolved mutation without a createdTenant record is a\n // degenerate response (federation hiccup, partial schema, etc.).\n // Treat as failure so the user is not shown a misleading success\n // toast and the form stays open with their input intact.\n throw new Error(\n tr(\n 'admin.tenants.toast.createNoRecord',\n 'Tenant create returned no record. Please retry.'\n )\n );\n }\n // Slug from the server-confirmed record so listeners cannot see a\n // form value that was rejected (e.g. uniqueness collision).\n emitTenantLifecycle(events, 'tenant.tenant.created', {\n tenantId: created.id,\n tenantSlug: created.slug,\n });\n toast.success(\n tr('admin.tenants.toast.createdTitle', 'Tenant created'),\n tr('admin.tenants.toast.createdDescription', '{{name}} has been created.', {\n name: input.name ?? '',\n })\n );\n navigate(joinPath(basePath, `tenants/${created.id}`));\n } catch (err) {\n toast.error(\n tr('admin.tenants.toast.createFailedTitle', 'Create failed'),\n err instanceof Error ? err.message : ''\n );\n // Rethrow so the form stays open on failure (see edit branch).\n throw err;\n }\n },\n [\n basePath,\n createTenant,\n events,\n isEdit,\n navigate,\n tenant?.slug,\n tenantId,\n tenantsPath,\n toast,\n tr,\n updateTenant,\n ]\n );\n\n // Permission denials win over feature-flag denials: a user without\n // canManageTenants should see \"permission denied\" rather than learning\n // whether the feature flag is on/off (minor information disclosure).\n if (!permissionsLoading && !canManageTenants) {\n return (\n <PermissionGuard\n allowed={false}\n isLoading={false}\n pageTitle={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n denyMessage={tr(\n 'admin.tenantForm.permissionDenied',\n 'You do not have permission to manage tenants.'\n )}\n >\n <></>\n </PermissionGuard>\n );\n }\n\n if (!flagEnabled) {\n return (\n <PageLayout\n title={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n description={tr('admin.tenantForm.featureDisabled', 'This feature is currently disabled.')}\n >\n <Button variant=\"outline\" onClick={() => navigate(tenantsPath)}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantForm.backToTenants', 'Back to Tenants')}\n </Button>\n </PageLayout>\n );\n }\n\n return (\n <PermissionGuard\n allowed={canManageTenants}\n isLoading={permissionsLoading}\n pageTitle={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n denyMessage={tr(\n 'admin.tenantForm.denyMessage',\n \"You don't have permission to manage tenants.\"\n )}\n >\n {isEdit && loading ? (\n <PageLayout title={tr('admin.tenantForm.loadingTitle', 'Loading tenant...')}>\n <div className=\"flex h-64 items-center justify-center\">\n <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n </div>\n </PageLayout>\n ) : isEdit && (error || !tenant) ? (\n <PageLayout\n title={tr('admin.tenantForm.notFoundTitle', 'Tenant Not Found')}\n description={tr(\n 'admin.tenantForm.notFoundDescription',\n 'The tenant you want to edit could not be found.'\n )}\n >\n {error && (\n <div className=\"mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive\">\n {error.message}\n </div>\n )}\n <Button variant=\"outline\" onClick={() => navigate(tenantsPath)}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantForm.backToTenants', 'Back to Tenants')}\n </Button>\n </PageLayout>\n ) : (\n <PageLayout\n title={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n description={\n isEdit\n ? tr('admin.tenantForm.editDescription', 'Update tenant configuration and metadata.')\n : tr('admin.tenantForm.createDescription', 'Add a new tenant to the platform.')\n }\n >\n <TenantForm open={true} onClose={handleClose} tenant={tenant} onSave={handleSave} />\n </PageLayout>\n )}\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"TenantFormRoutePage.js","names":[],"sources":["../../../src/admin/pages/TenantFormRoutePage.tsx"],"sourcesContent":["import { useCallback, useMemo } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { ArrowLeft, Loader2 } from 'lucide-react';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { useFeatureFlag } from '@burdenoff/fe-libs/shared/feature-flags';\nimport { useEventBus } from '@burdenoff/fe-libs/shared/events';\nimport { emitTenantLifecycle } from '../utils/tenantEvents';\nimport {\n useCreateTenantMutation,\n useGetTenantQuery,\n useUpdateTenantMutation,\n} from '../../generated/global-operations';\nimport { Button, PageLayout, PermissionGuard, TenantForm, useToast } from '../components';\nimport { useAdminPermissions } from '../hooks/useAdminPermissions';\nimport type { CreateTenantInput, UpdateTenantInput } from '../schemas';\nimport { useAdmin } from '../providers/AdminProvider';\nimport { joinPath } from '../utils/navigation';\nimport { tWithFallback } from '../utils/i18n';\nimport { mapTenantToUi } from '../utils/mappers';\n\ntype TenantFormRoutePageProps = {\n mode: 'create' | 'edit';\n};\n\nexport function TenantFormRoutePage({ mode }: TenantFormRoutePageProps) {\n const { tenantId } = useParams<{ tenantId: string }>();\n const navigate = useNavigate();\n const { basePath = '' } = useAdmin();\n const toast = useToast();\n const { t } = useI18n();\n const { canManageTenants, isLoading: permissionsLoading } = useAdminPermissions();\n const isEdit = mode === 'edit';\n // Page-level feature flags. Default-allow keeps prior behavior intact when\n // the flag is missing; turning the flag OFF in OpenBao/feature-flag-svc\n // disables the corresponding route without a code deploy.\n const flagKey = isEdit ? 'admin.tenant.edit' : 'admin.tenant.create';\n const flagEnabled = useFeatureFlag(flagKey, true);\n const events = useEventBus();\n const tr = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>): string =>\n tWithFallback(t, key, fallback, params),\n [t]\n );\n\n const tenantsPath = joinPath(basePath, 'tenants');\n const closePath = isEdit && tenantId ? joinPath(basePath, `tenants/${tenantId}`) : tenantsPath;\n\n const { data, loading, error } = useGetTenantQuery({\n variables: { id: tenantId ?? '' },\n // Also skip when the page is feature-flag-disabled: the early return\n // below renders the \"feature disabled\" panel instead of the form, so a\n // network round-trip would be wasted.\n skip: !isEdit || !tenantId || permissionsLoading || !canManageTenants || !flagEnabled,\n fetchPolicy: 'cache-and-network',\n });\n\n const [createTenant] = useCreateTenantMutation();\n const [updateTenant] = useUpdateTenantMutation();\n\n // Memoize the mapped tenant so its object identity is stable across\n // renders. mapTenantToUi otherwise produces a new object on every render,\n // forcing downstream memoization (TenantForm.defaultValues, reset effects)\n // to fan out unnecessarily.\n const tenant = useMemo(\n () => (data?.tenant ? mapTenantToUi(data.tenant) : undefined),\n [data?.tenant]\n );\n\n const handleClose = useCallback(() => {\n navigate(closePath);\n }, [navigate, closePath]);\n\n const handleSave = useCallback(\n async (input: CreateTenantInput | UpdateTenantInput) => {\n if (isEdit) {\n if (!tenantId) return;\n try {\n const updated = await updateTenant({\n variables: {\n id: tenantId,\n input: {\n name: input.name,\n customDomain: input.domain,\n metadata: {\n ownerEmail: input.ownerEmail,\n billingEmail: input.billingEmail,\n plan: input.plan,\n } as Record<string, unknown>,\n },\n },\n });\n // The update mutation does not accept slug, so emit the slug from\n // the persisted record (mutation response, falling back to the\n // already-loaded tenant) rather than the user's form input — the\n // form's slug field is read-only for edits, but a defensive emit\n // protects listeners against any future change.\n emitTenantLifecycle(events, 'tenant.tenant.updated', {\n tenantId,\n tenantSlug: updated.data?.updateTenant?.slug ?? tenant?.slug,\n });\n toast.success(\n tr('admin.tenants.toast.updatedTitle', 'Tenant updated'),\n tr('admin.tenants.toast.updatedDescription', '{{name}} has been updated.', {\n name: input.name ?? '',\n })\n );\n navigate(joinPath(basePath, `tenants/${tenantId}`));\n } catch (err) {\n toast.error(\n tr('admin.tenants.toast.updateFailedTitle', 'Update failed'),\n err instanceof Error ? err.message : ''\n );\n // Rethrow so TenantForm.onSubmit's try/catch sees the rejection\n // and keeps the form open instead of calling onClose() — losing\n // the user's edits would be a worse outcome than a duplicate\n // surfacing of the same error.\n throw err;\n }\n return;\n }\n\n try {\n const result = await createTenant({\n variables: {\n input: {\n name: input.name!,\n slug: input.slug!,\n customDomain: input.domain,\n metadata: {\n ownerEmail: input.ownerEmail,\n billingEmail: input.billingEmail,\n plan: input.plan,\n } as Record<string, unknown>,\n },\n },\n });\n const created = result.data?.createTenant;\n if (!created?.id) {\n // A resolved mutation without a createdTenant record is a\n // degenerate response (federation hiccup, partial schema, etc.).\n // Treat as failure so the user is not shown a misleading success\n // toast and the form stays open with their input intact.\n throw new Error(\n tr(\n 'admin.tenants.toast.createNoRecord',\n 'Tenant create returned no record. Please retry.'\n )\n );\n }\n // Slug from the server-confirmed record so listeners cannot see a\n // form value that was rejected (e.g. uniqueness collision).\n emitTenantLifecycle(events, 'tenant.tenant.created', {\n tenantId: created.id,\n tenantSlug: created.slug,\n });\n toast.success(\n tr('admin.tenants.toast.createdTitle', 'Tenant created'),\n tr('admin.tenants.toast.createdDescription', '{{name}} has been created.', {\n name: input.name ?? '',\n })\n );\n navigate(joinPath(basePath, `tenants/${created.id}`));\n } catch (err) {\n toast.error(\n tr('admin.tenants.toast.createFailedTitle', 'Create failed'),\n err instanceof Error ? err.message : ''\n );\n // Rethrow so the form stays open on failure (see edit branch).\n throw err;\n }\n },\n [\n basePath,\n createTenant,\n events,\n isEdit,\n navigate,\n tenant?.slug,\n tenantId,\n tenantsPath,\n toast,\n tr,\n updateTenant,\n ]\n );\n\n // Permission denials win over feature-flag denials: a user without\n // canManageTenants should see \"permission denied\" rather than learning\n // whether the feature flag is on/off (minor information disclosure).\n if (!permissionsLoading && !canManageTenants) {\n return (\n <PermissionGuard\n allowed={false}\n isLoading={false}\n pageTitle={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n denyMessage={tr(\n 'admin.tenantForm.permissionDenied',\n 'You do not have permission to manage tenants.'\n )}\n >\n <></>\n </PermissionGuard>\n );\n }\n\n if (!flagEnabled) {\n return (\n <PageLayout\n title={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n description={tr('admin.tenantForm.featureDisabled', 'This feature is currently disabled.')}\n >\n <Button variant=\"outline\" onClick={() => navigate(tenantsPath)}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantForm.backToTenants', 'Back to Tenants')}\n </Button>\n </PageLayout>\n );\n }\n\n return (\n <PermissionGuard\n allowed={canManageTenants}\n isLoading={permissionsLoading}\n pageTitle={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n denyMessage={tr(\n 'admin.tenantForm.denyMessage',\n \"You don't have permission to manage tenants.\"\n )}\n >\n {isEdit && loading ? (\n <PageLayout title={tr('admin.tenantForm.loadingTitle', 'Loading tenant...')}>\n <div className=\"flex h-64 items-center justify-center\">\n <Loader2 className=\"h-8 w-8 animate-spin text-text-secondary\" />\n </div>\n </PageLayout>\n ) : isEdit && (error || !tenant) ? (\n <PageLayout\n title={tr('admin.tenantForm.notFoundTitle', 'Tenant Not Found')}\n description={tr(\n 'admin.tenantForm.notFoundDescription',\n 'The tenant you want to edit could not be found.'\n )}\n >\n {error && (\n <div className=\"mb-4 rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text\">\n {error.message}\n </div>\n )}\n <Button variant=\"outline\" onClick={() => navigate(tenantsPath)}>\n <ArrowLeft className=\"mr-2 h-4 w-4\" />\n {tr('admin.tenantForm.backToTenants', 'Back to Tenants')}\n </Button>\n </PageLayout>\n ) : (\n <PageLayout\n title={\n isEdit\n ? tr('admin.tenantForm.editTitle', 'Edit Tenant')\n : tr('admin.tenantForm.createTitle', 'Create Tenant')\n }\n description={\n isEdit\n ? tr('admin.tenantForm.editDescription', 'Update tenant configuration and metadata.')\n : tr('admin.tenantForm.createDescription', 'Add a new tenant to the platform.')\n }\n >\n <TenantForm open={true} onClose={handleClose} tenant={tenant} onSave={handleSave} />\n </PageLayout>\n )}\n </PermissionGuard>\n );\n}\n"],"mappings":"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"}
|
|
@@ -167,10 +167,10 @@ function w() {
|
|
|
167
167
|
}), /* @__PURE__ */ x("div", {
|
|
168
168
|
className: "min-w-0",
|
|
169
169
|
children: [/* @__PURE__ */ b("div", {
|
|
170
|
-
className: "truncate font-medium text-
|
|
170
|
+
className: "truncate font-medium text-text-primary",
|
|
171
171
|
children: e.name
|
|
172
172
|
}), /* @__PURE__ */ b("div", {
|
|
173
|
-
className: "truncate text-xs text-
|
|
173
|
+
className: "truncate text-xs text-text-secondary",
|
|
174
174
|
children: e.slug
|
|
175
175
|
})]
|
|
176
176
|
})]
|
|
@@ -240,7 +240,7 @@ function w() {
|
|
|
240
240
|
className: "flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between",
|
|
241
241
|
children: [/* @__PURE__ */ x("div", {
|
|
242
242
|
className: "relative max-w-sm flex-1",
|
|
243
|
-
children: [/* @__PURE__ */ b(pe, { className: "absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-
|
|
243
|
+
children: [/* @__PURE__ */ b(pe, { className: "absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" }), /* @__PURE__ */ b(ee, {
|
|
244
244
|
placeholder: L("admin.tenants.searchPlaceholder", "Search tenants..."),
|
|
245
245
|
value: k.search,
|
|
246
246
|
onChange: (e) => {
|
|
@@ -255,7 +255,7 @@ function w() {
|
|
|
255
255
|
onChange: (e) => {
|
|
256
256
|
z(1), A({ status: e.target.value });
|
|
257
257
|
},
|
|
258
|
-
className: "h-10 rounded-md border border-
|
|
258
|
+
className: "h-10 rounded-md border border-border-default bg-bg-surface px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)] focus:ring-offset-2",
|
|
259
259
|
children: [
|
|
260
260
|
/* @__PURE__ */ b("option", {
|
|
261
261
|
value: "all",
|
|
@@ -284,7 +284,7 @@ function w() {
|
|
|
284
284
|
})]
|
|
285
285
|
}),
|
|
286
286
|
W && /* @__PURE__ */ x("div", {
|
|
287
|
-
className: "rounded-md border border-
|
|
287
|
+
className: "rounded-md border border-status-error-border/50 bg-status-error-bg/10 p-4 text-sm text-status-error-text",
|
|
288
288
|
children: [
|
|
289
289
|
L("admin.tenants.error.loadFailed", "Failed to load tenants"),
|
|
290
290
|
": ",
|