feat: restructure frontend routes — vendor/tenant persona split
Splits the flat 3-page UI into /vendor/* (platform:admin) and /tenant/* (all authenticated users) route trees, with stub pages, new API hooks, updated Layout with persona-aware sidebar, and SpaController forwarding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,104 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
AlertDialog,
|
||||
Badge,
|
||||
Card,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
Spinner,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAllTenants } from '../api/hooks';
|
||||
import { useOrgStore } from '../auth/useOrganization';
|
||||
import type { TenantResponse } from '../types/api';
|
||||
import styles from '../styles/platform.module.css';
|
||||
|
||||
const columns: Column<TenantResponse>[] = [
|
||||
{ key: 'name', header: 'Name' },
|
||||
{ key: 'slug', header: 'Slug' },
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
render: (_v: unknown, row: TenantResponse) => <Badge label={row.tier} color="primary" />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (_v: unknown, row: TenantResponse) => (
|
||||
<Badge
|
||||
label={row.status}
|
||||
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', header: 'Created', render: (_: unknown, row: TenantResponse) => new Date(row.createdAt).toLocaleDateString() },
|
||||
];
|
||||
|
||||
export function AdminTenantsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: tenants, isLoading, isError } = useAllTenants();
|
||||
const { setCurrentOrg } = useOrgStore();
|
||||
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Unable to load tenants"
|
||||
description="You may not have admin permissions, or the server is unavailable."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRowClick = (tenant: TenantResponse) => {
|
||||
setSwitchTarget(tenant);
|
||||
};
|
||||
|
||||
const confirmSwitch = () => {
|
||||
if (!switchTarget) return;
|
||||
const orgs = useOrgStore.getState().organizations;
|
||||
const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug);
|
||||
if (match) {
|
||||
setCurrentOrg(match.id);
|
||||
navigate('/');
|
||||
}
|
||||
setSwitchTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className={styles.heading}>All Tenants</h1>
|
||||
<Badge label="Platform Admin" color="warning" />
|
||||
</div>
|
||||
|
||||
<Card title={`${tenants?.length ?? 0} Tenants`}>
|
||||
{(!tenants || tenants.length === 0) ? (
|
||||
<EmptyState title="No tenants" description="No tenants have been created yet." />
|
||||
) : (
|
||||
<DataTable columns={columns} data={tenants} onRowClick={handleRowClick} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={!!switchTarget}
|
||||
onClose={() => setSwitchTarget(null)}
|
||||
onConfirm={confirmSwitch}
|
||||
title="Switch tenant?"
|
||||
description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`}
|
||||
confirmLabel="Switch"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
KpiStrip,
|
||||
Spinner,
|
||||
} from '@cameleer/design-system';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useTenant, useLicense } from '../api/hooks';
|
||||
import styles from '../styles/platform.module.css';
|
||||
import { tierColor } from '../utils/tier';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { tenantId } = useAuth();
|
||||
|
||||
const { data: tenant, isLoading: tenantLoading, isError: tenantError } = useTenant(tenantId ?? '');
|
||||
const { data: license, isLoading: licenseLoading, isError: licenseError } = useLicense(tenantId ?? '');
|
||||
|
||||
const isLoading = tenantLoading || licenseLoading;
|
||||
|
||||
const kpiItems = [
|
||||
{
|
||||
label: 'Tier',
|
||||
value: tenant?.tier ?? '-',
|
||||
subtitle: 'subscription level',
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
value: tenant?.status ?? '-',
|
||||
subtitle: 'tenant status',
|
||||
},
|
||||
{
|
||||
label: 'License',
|
||||
value: license ? 'Active' : 'None',
|
||||
trend: license
|
||||
? { label: `expires ${new Date(license.expiresAt).toLocaleDateString()}`, variant: 'success' as const }
|
||||
: { label: 'no license', variant: 'warning' as const },
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tenantError || licenseError) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Unable to load dashboard"
|
||||
description="Failed to retrieve tenant information. Please try again later."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No tenant associated"
|
||||
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Tenant Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className={styles.heading}>
|
||||
{tenant?.name ?? tenantId}
|
||||
</h1>
|
||||
{tenant?.tier && (
|
||||
<Badge
|
||||
label={tenant.tier.toUpperCase()}
|
||||
color={tierColor(tenant.tier)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPI Strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Tenant Info */}
|
||||
<Card title="Tenant Information">
|
||||
<div className="space-y-2">
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Slug</span>
|
||||
<span className={styles.kvValueMono}>{tenant?.slug ?? '-'}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Status</span>
|
||||
<Badge
|
||||
label={tenant?.status ?? 'UNKNOWN'}
|
||||
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Created</span>
|
||||
<span className={styles.kvValue}>{tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Server Dashboard Link */}
|
||||
<Card title="Server Management">
|
||||
<p className={`${styles.description} mb-3`}>
|
||||
Environments, applications, and deployments are managed through the server dashboard.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
||||
>
|
||||
Open Server Dashboard
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLicense } from '../api/hooks';
|
||||
import styles from '../styles/platform.module.css';
|
||||
import { tierColor } from '../utils/tier';
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
topology: 'Topology',
|
||||
lineage: 'Lineage',
|
||||
correlation: 'Correlation',
|
||||
debugger: 'Debugger',
|
||||
replay: 'Replay',
|
||||
};
|
||||
|
||||
const LIMIT_LABELS: Record<string, string> = {
|
||||
max_agents: 'Max Agents',
|
||||
retention_days: 'Retention Days',
|
||||
max_environments: 'Max Environments',
|
||||
};
|
||||
|
||||
function daysRemaining(expiresAt: string): number {
|
||||
const now = Date.now();
|
||||
const exp = new Date(expiresAt).getTime();
|
||||
return Math.max(0, Math.ceil((exp - now) / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
export function LicensePage() {
|
||||
const { tenantId } = useAuth();
|
||||
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
||||
const [tokenExpanded, setTokenExpanded] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No tenant associated"
|
||||
description="Your account is not linked to a tenant. Please contact your administrator."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !license) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="License unavailable"
|
||||
description="Unable to load license information. Please try again later."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const expDate = new Date(license.expiresAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
const days = daysRemaining(license.expiresAt);
|
||||
const isExpiringSoon = days <= 30;
|
||||
const isExpired = days === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className={styles.heading}>License</h1>
|
||||
<Badge
|
||||
label={license.tier.toUpperCase()}
|
||||
color={tierColor(license.tier)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiry info */}
|
||||
<Card title="Validity">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Issued</span>
|
||||
<span className={styles.kvValue}>
|
||||
{new Date(license.issuedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Expires</span>
|
||||
<span className={styles.kvValue}>{expDate}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Days remaining</span>
|
||||
<Badge
|
||||
label={isExpired ? 'Expired' : `${days} days`}
|
||||
color={isExpired ? 'error' : isExpiringSoon ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Feature matrix */}
|
||||
<Card title="Features">
|
||||
<div className={styles.dividerList}>
|
||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => {
|
||||
const enabled = license.features[key] ?? false;
|
||||
return (
|
||||
<div key={key} className={styles.dividerRow}>
|
||||
<span className={styles.kvLabel}>{label}</span>
|
||||
<Badge
|
||||
label={enabled ? 'Enabled' : 'Not included'}
|
||||
color={enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Limits */}
|
||||
<Card title="Limits">
|
||||
<div className={styles.dividerList}>
|
||||
{Object.entries(LIMIT_LABELS).map(([key, label]) => {
|
||||
const value = license.limits[key];
|
||||
return (
|
||||
<div key={key} className={styles.dividerRow}>
|
||||
<span className={styles.kvLabel}>{label}</span>
|
||||
<span className={styles.kvValueMono}>
|
||||
{value !== undefined ? value : '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* License token */}
|
||||
<Card title="License Token">
|
||||
<div className="space-y-3">
|
||||
<p className={styles.description}>
|
||||
Use this token when registering Cameleer agents with your tenant.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
|
||||
{tokenExpanded ? 'Hide token' : 'Show token'}
|
||||
</Button>
|
||||
{tokenExpanded && (
|
||||
<Button variant="ghost" size="sm" onClick={() => {
|
||||
navigator.clipboard.writeText(license.token);
|
||||
toast({ title: 'Token copied to clipboard', variant: 'success' });
|
||||
}}>
|
||||
<Copy size={14} /> Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{tokenExpanded && (
|
||||
<div className={styles.tokenBlock}>
|
||||
<code className={styles.tokenCode}>
|
||||
{license.token}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
ui/src/pages/tenant/OidcConfigPage.tsx
Normal file
1
ui/src/pages/tenant/OidcConfigPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function OidcConfigPage() { return <div>OidcConfigPage (TODO)</div>; }
|
||||
1
ui/src/pages/tenant/SettingsPage.tsx
Normal file
1
ui/src/pages/tenant/SettingsPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function SettingsPage() { return <div>SettingsPage (TODO)</div>; }
|
||||
1
ui/src/pages/tenant/TeamPage.tsx
Normal file
1
ui/src/pages/tenant/TeamPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function TeamPage() { return <div>TeamPage (TODO)</div>; }
|
||||
1
ui/src/pages/tenant/TenantDashboardPage.tsx
Normal file
1
ui/src/pages/tenant/TenantDashboardPage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function TenantDashboardPage() { return <div>TenantDashboardPage (TODO)</div>; }
|
||||
1
ui/src/pages/tenant/TenantLicensePage.tsx
Normal file
1
ui/src/pages/tenant/TenantLicensePage.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function TenantLicensePage() { return <div>TenantLicensePage (TODO)</div>; }
|
||||
1
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
Normal file
1
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export function CreateTenantPage() { return <div>CreateTenantPage (TODO)</div>; }
|
||||
1
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
Normal file
1
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export function TenantDetailPage() { return <div>TenantDetailPage (TODO)</div>; }
|
||||
1
ui/src/pages/vendor/VendorTenantsPage.tsx
vendored
Normal file
1
ui/src/pages/vendor/VendorTenantsPage.tsx
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export function VendorTenantsPage() { return <div>VendorTenantsPage (TODO)</div>; }
|
||||
Reference in New Issue
Block a user