From bf3aa57274935dc6594433a6508a488d37c26833 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:52:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20restructure=20frontend=20routes=20?= =?UTF-8?q?=E2=80=94=20vendor/tenant=20persona=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cameleer/saas/config/SpaController.java | 9 +- ui/src/api/tenant-hooks.ts | 63 ++++++ ui/src/api/vendor-hooks.ts | 59 ++++++ ui/src/auth/OrgResolver.tsx | 21 +- ui/src/auth/ProtectedRoute.tsx | 6 +- ui/src/components/Layout.tsx | 130 ++++++++----- ui/src/pages/AdminTenantsPage.tsx | 104 ---------- ui/src/pages/DashboardPage.tsx | 124 ------------ ui/src/pages/LicensePage.tsx | 180 ------------------ ui/src/pages/tenant/OidcConfigPage.tsx | 1 + ui/src/pages/tenant/SettingsPage.tsx | 1 + ui/src/pages/tenant/TeamPage.tsx | 1 + ui/src/pages/tenant/TenantDashboardPage.tsx | 1 + ui/src/pages/tenant/TenantLicensePage.tsx | 1 + ui/src/pages/vendor/CreateTenantPage.tsx | 1 + ui/src/pages/vendor/TenantDetailPage.tsx | 1 + ui/src/pages/vendor/VendorTenantsPage.tsx | 1 + ui/src/router.tsx | 58 ++++-- ui/src/types/api.ts | 63 ++++++ 19 files changed, 329 insertions(+), 496 deletions(-) create mode 100644 ui/src/api/tenant-hooks.ts create mode 100644 ui/src/api/vendor-hooks.ts delete mode 100644 ui/src/pages/AdminTenantsPage.tsx delete mode 100644 ui/src/pages/DashboardPage.tsx delete mode 100644 ui/src/pages/LicensePage.tsx create mode 100644 ui/src/pages/tenant/OidcConfigPage.tsx create mode 100644 ui/src/pages/tenant/SettingsPage.tsx create mode 100644 ui/src/pages/tenant/TeamPage.tsx create mode 100644 ui/src/pages/tenant/TenantDashboardPage.tsx create mode 100644 ui/src/pages/tenant/TenantLicensePage.tsx create mode 100644 ui/src/pages/vendor/CreateTenantPage.tsx create mode 100644 ui/src/pages/vendor/TenantDetailPage.tsx create mode 100644 ui/src/pages/vendor/VendorTenantsPage.tsx diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java b/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java index 64f5bdd..de27ab5 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SpaController.java @@ -1,13 +1,16 @@ package net.siegeln.cameleer.saas.config; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; @Controller public class SpaController { - @GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"}) - public String spa() { + @RequestMapping(value = { + "/", "/login", "/callback", + "/vendor/**", "/tenant/**" + }) + public String forward() { return "forward:/index.html"; } } diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts new file mode 100644 index 0000000..67c275e --- /dev/null +++ b/ui/src/api/tenant-hooks.ts @@ -0,0 +1,63 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from './client'; +import type { DashboardData, TenantLicenseData, TenantSettings } from '../types/api'; + +export function useTenantDashboard() { + return useQuery({ + queryKey: ['tenant', 'dashboard'], + queryFn: () => api.get('/tenant/dashboard'), + refetchInterval: 30_000, + }); +} + +export function useTenantLicense() { + return useQuery({ + queryKey: ['tenant', 'license'], + queryFn: () => api.get('/tenant/license'), + }); +} + +export function useTenantOidc() { + return useQuery>({ + queryKey: ['tenant', 'oidc'], + queryFn: () => api.get('/tenant/oidc'), + }); +} + +export function useUpdateOidc() { + const qc = useQueryClient(); + return useMutation>({ + mutationFn: (config) => api.post('/tenant/oidc', config), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'oidc'] }), + }); +} + +export function useTenantTeam() { + return useQuery>>({ + queryKey: ['tenant', 'team'], + queryFn: () => api.get('/tenant/team'), + }); +} + +export function useInviteTeamMember() { + const qc = useQueryClient(); + return useMutation<{ userId: string }, Error, { email: string; roleId: string }>({ + mutationFn: (body) => api.post('/tenant/team/invite', body), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), + }); +} + +export function useRemoveTeamMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/tenant/team/${userId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), + }); +} + +export function useTenantSettings() { + return useQuery({ + queryKey: ['tenant', 'settings'], + queryFn: () => api.get('/tenant/settings'), + }); +} diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts new file mode 100644 index 0000000..969e1e4 --- /dev/null +++ b/ui/src/api/vendor-hooks.ts @@ -0,0 +1,59 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from './client'; +import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse } from '../types/api'; + +export function useVendorTenants() { + return useQuery({ + queryKey: ['vendor', 'tenants'], + queryFn: () => api.get('/vendor/tenants'), + refetchInterval: 30_000, + }); +} + +export function useVendorTenant(id: string | null) { + return useQuery({ + queryKey: ['vendor', 'tenants', id], + queryFn: () => api.get(`/vendor/tenants/${id}`), + enabled: !!id, + }); +} + +export function useCreateTenant() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req) => api.post('/vendor/tenants', req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), + }); +} + +export function useSuspendTenant() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.post(`/vendor/tenants/${id}/suspend`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), + }); +} + +export function useActivateTenant() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.post(`/vendor/tenants/${id}/activate`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), + }); +} + +export function useDeleteTenant() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/vendor/tenants/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), + }); +} + +export function useRenewLicense() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (tenantId) => api.post(`/vendor/tenants/${tenantId}/license`), + onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }), + }); +} diff --git a/ui/src/auth/OrgResolver.tsx b/ui/src/auth/OrgResolver.tsx index 0537c95..e60e3db 100644 --- a/ui/src/auth/OrgResolver.tsx +++ b/ui/src/auth/OrgResolver.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; +import { Outlet } from 'react-router'; import { useLogto } from '@logto/react'; -import { Button, EmptyState, Spinner } from '@cameleer/design-system'; +import { Spinner } from '@cameleer/design-system'; import { useMe } from '../api/hooks'; import { useOrgStore } from './useOrganization'; import { fetchConfig } from '../config'; @@ -10,8 +11,8 @@ import { fetchConfig } from '../config'; * Also reads OAuth2 scopes from the access token and stores them. * Renders children once resolved. */ -export function OrgResolver({ children }: { children: React.ReactNode }) { - const { data: me, isLoading, isError, refetch } = useMe(); +export function OrgResolver({ children }: { children?: React.ReactNode }) { + const { data: me, isLoading, isError } = useMe(); const { getAccessToken } = useLogto(); const { getIdTokenClaims } = useLogto(); const { setOrganizations, setCurrentOrg, setScopes, setUsername, currentOrgId } = useOrgStore(); @@ -86,18 +87,8 @@ export function OrgResolver({ children }: { children: React.ReactNode }) { } if (isError) { - return ( -
- - -
- ); + return null; } - return <>{children}; + return children ? <>{children} : ; } diff --git a/ui/src/auth/ProtectedRoute.tsx b/ui/src/auth/ProtectedRoute.tsx index 0c08c06..31464f9 100644 --- a/ui/src/auth/ProtectedRoute.tsx +++ b/ui/src/auth/ProtectedRoute.tsx @@ -1,9 +1,9 @@ import { useRef } from 'react'; -import { Navigate } from 'react-router'; +import { Navigate, Outlet } from 'react-router'; import { useLogto } from '@logto/react'; import { Spinner } from '@cameleer/design-system'; -export function ProtectedRoute({ children }: { children: React.ReactNode }) { +export function ProtectedRoute({ children }: { children?: React.ReactNode }) { const { isAuthenticated, isLoading } = useLogto(); // The Logto SDK sets isLoading=true for EVERY async method (getAccessToken, etc.), // not just initial auth. Only gate on the initial load — once isLoading is false @@ -23,5 +23,5 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) { } if (!isAuthenticated) return ; - return <>{children}; + return children ? <>{children} : ; } diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 7f93f50..5d271b7 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,11 +1,10 @@ -import { useState, useMemo } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router'; -import { LayoutDashboard, ShieldCheck, Building, Activity } from 'lucide-react'; import { AppShell, Sidebar, TopBar, } from '@cameleer/design-system'; +import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; import { useScopes } from '../auth/useScopes'; import { useOrgStore } from '../auth/useOrganization'; @@ -23,70 +22,107 @@ function CameleerLogo() { ); } +function isActive(location: ReturnType, path: string) { + return location.pathname === path || location.pathname.startsWith(path + '/'); +} + export function Layout() { const navigate = useNavigate(); const location = useLocation(); const { logout } = useAuth(); const scopes = useScopes(); - const { username } = useOrgStore(); + const { username, organizations, currentOrgId } = useOrgStore(); - const [collapsed, setCollapsed] = useState(false); + const isVendor = scopes.has('platform:admin'); - const breadcrumb = useMemo(() => { - if (location.pathname.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }]; - if (location.pathname.startsWith('/license')) return [{ label: 'License' }]; - return [{ label: 'Dashboard' }]; - }, [location.pathname]); + // Determine current org slug for server dashboard link + const currentOrg = organizations.find((o) => o.id === currentOrgId); + const serverDashboardHref = currentOrg?.slug ? `/t/${currentOrg.slug}/` : '/server/'; + + // Build breadcrumbs from path + const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean); + const breadcrumb = segments.map((seg, i) => { + const label = seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, ' '); + return { label }; + }); const sidebar = ( - setCollapsed(c => !c)}> + {}}> } title="Cameleer SaaS" - onClick={() => navigate('/')} + onClick={() => navigate(isVendor ? '/vendor/tenants' : '/tenant')} /> - {/* Dashboard */} - } - label="Dashboard" - open={false} - active={location.pathname === '/' || location.pathname === ''} - onToggle={() => navigate('/')} - > - {null} - - - {/* License */} - } - label="License" - open={false} - active={location.pathname.startsWith('/license')} - onToggle={() => navigate('/license')} - > - {null} - - - {/* Platform Admin section */} - {scopes.has('platform:admin') && ( + {/* Vendor console — only visible to platform:admin */} + {isVendor && ( } - label="Platform" + icon={} + label="Tenants" open={false} - active={location.pathname.startsWith('/admin')} - onToggle={() => navigate('/admin/tenants')} + active={isActive(location, '/vendor/tenants')} + onToggle={() => navigate('/vendor/tenants')} > {null} )} + {/* Tenant portal */} + } + label="Dashboard" + open={false} + active={location.pathname === '/tenant'} + onToggle={() => navigate('/tenant')} + > + {null} + + + } + label="License" + open={false} + active={isActive(location, '/tenant/license')} + onToggle={() => navigate('/tenant/license')} + > + {null} + + + } + label="OIDC" + open={false} + active={isActive(location, '/tenant/oidc')} + onToggle={() => navigate('/tenant/oidc')} + > + {null} + + + } + label="Team" + open={false} + active={isActive(location, '/tenant/team')} + onToggle={() => navigate('/tenant/team')} + > + {null} + + + } + label="Settings" + open={false} + active={isActive(location, '/tenant/settings')} + onToggle={() => navigate('/tenant/settings')} + > + {null} + + - {/* Link to the server observability dashboard */} } + icon={} label="Open Server Dashboard" - onClick={() => window.open('/server/', '_blank', 'noopener')} + onClick={() => window.open(serverDashboardHref, '_blank', 'noopener')} /> @@ -94,17 +130,9 @@ export function Layout() { return ( - {/* - * TopBar always renders status filters, time range pills, auto-refresh, and - * command palette search via useGlobalFilters() / useCommandPalette(). Both - * hooks throw if their providers are absent, so GlobalFilterProvider and - * CommandPaletteProvider cannot be removed from main.tsx without crashing the - * app. The TopBar API has no props to suppress these server-oriented controls. - * Hiding them on platform pages would require a DS change. - */} diff --git a/ui/src/pages/AdminTenantsPage.tsx b/ui/src/pages/AdminTenantsPage.tsx deleted file mode 100644 index 7f24413..0000000 --- a/ui/src/pages/AdminTenantsPage.tsx +++ /dev/null @@ -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[] = [ - { key: 'name', header: 'Name' }, - { key: 'slug', header: 'Slug' }, - { - key: 'tier', - header: 'Tier', - render: (_v: unknown, row: TenantResponse) => , - }, - { - key: 'status', - header: 'Status', - render: (_v: unknown, row: TenantResponse) => ( - - ), - }, - { 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(null); - - if (isLoading) { - return ( -
- -
- ); - } - - if (isError) { - return ( -
- -
- ); - } - - 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 ( -
-
-

All Tenants

- -
- - - {(!tenants || tenants.length === 0) ? ( - - ) : ( - - )} - - - setSwitchTarget(null)} - onConfirm={confirmSwitch} - title="Switch tenant?" - description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`} - confirmLabel="Switch" - variant="warning" - /> -
- ); -} diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx deleted file mode 100644 index 3eb80e7..0000000 --- a/ui/src/pages/DashboardPage.tsx +++ /dev/null @@ -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 ( -
- -
- ); - } - - if (tenantError || licenseError) { - return ( -
- -
- ); - } - - if (!tenantId) { - return ( - - ); - } - - return ( -
- {/* Tenant Header */} -
-

- {tenant?.name ?? tenantId} -

- {tenant?.tier && ( - - )} -
- - {/* KPI Strip */} - - - {/* Tenant Info */} - -
-
- Slug - {tenant?.slug ?? '-'} -
-
- Status - -
-
- Created - {tenant?.createdAt ? new Date(tenant.createdAt).toLocaleDateString() : '-'} -
-
-
- - {/* Server Dashboard Link */} - -

- Environments, applications, and deployments are managed through the server dashboard. -

- -
-
- ); -} diff --git a/ui/src/pages/LicensePage.tsx b/ui/src/pages/LicensePage.tsx deleted file mode 100644 index 6a7536f..0000000 --- a/ui/src/pages/LicensePage.tsx +++ /dev/null @@ -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 = { - topology: 'Topology', - lineage: 'Lineage', - correlation: 'Correlation', - debugger: 'Debugger', - replay: 'Replay', -}; - -const LIMIT_LABELS: Record = { - 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 ( -
- -
- ); - } - - if (!tenantId) { - return ( - - ); - } - - if (isError || !license) { - return ( - - ); - } - - 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 ( -
- {/* Header */} -
-

License

- -
- - {/* Expiry info */} - -
-
- Issued - - {new Date(license.issuedAt).toLocaleDateString(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - -
-
- Expires - {expDate} -
-
- Days remaining - -
-
-
- - {/* Feature matrix */} - -
- {Object.entries(FEATURE_LABELS).map(([key, label]) => { - const enabled = license.features[key] ?? false; - return ( -
- {label} - -
- ); - })} -
-
- - {/* Limits */} - -
- {Object.entries(LIMIT_LABELS).map(([key, label]) => { - const value = license.limits[key]; - return ( -
- {label} - - {value !== undefined ? value : '—'} - -
- ); - })} -
-
- - {/* License token */} - -
-

- Use this token when registering Cameleer agents with your tenant. -

-
- - {tokenExpanded && ( - - )} -
- {tokenExpanded && ( -
- - {license.token} - -
- )} -
-
-
- ); -} diff --git a/ui/src/pages/tenant/OidcConfigPage.tsx b/ui/src/pages/tenant/OidcConfigPage.tsx new file mode 100644 index 0000000..d44fde3 --- /dev/null +++ b/ui/src/pages/tenant/OidcConfigPage.tsx @@ -0,0 +1 @@ +export function OidcConfigPage() { return
OidcConfigPage (TODO)
; } diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx new file mode 100644 index 0000000..af787a9 --- /dev/null +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -0,0 +1 @@ +export function SettingsPage() { return
SettingsPage (TODO)
; } diff --git a/ui/src/pages/tenant/TeamPage.tsx b/ui/src/pages/tenant/TeamPage.tsx new file mode 100644 index 0000000..24abc70 --- /dev/null +++ b/ui/src/pages/tenant/TeamPage.tsx @@ -0,0 +1 @@ +export function TeamPage() { return
TeamPage (TODO)
; } diff --git a/ui/src/pages/tenant/TenantDashboardPage.tsx b/ui/src/pages/tenant/TenantDashboardPage.tsx new file mode 100644 index 0000000..07a28ae --- /dev/null +++ b/ui/src/pages/tenant/TenantDashboardPage.tsx @@ -0,0 +1 @@ +export function TenantDashboardPage() { return
TenantDashboardPage (TODO)
; } diff --git a/ui/src/pages/tenant/TenantLicensePage.tsx b/ui/src/pages/tenant/TenantLicensePage.tsx new file mode 100644 index 0000000..3aa9236 --- /dev/null +++ b/ui/src/pages/tenant/TenantLicensePage.tsx @@ -0,0 +1 @@ +export function TenantLicensePage() { return
TenantLicensePage (TODO)
; } diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx new file mode 100644 index 0000000..4dfa9d1 --- /dev/null +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -0,0 +1 @@ +export function CreateTenantPage() { return
CreateTenantPage (TODO)
; } diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx new file mode 100644 index 0000000..1f623b3 --- /dev/null +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -0,0 +1 @@ +export function TenantDetailPage() { return
TenantDetailPage (TODO)
; } diff --git a/ui/src/pages/vendor/VendorTenantsPage.tsx b/ui/src/pages/vendor/VendorTenantsPage.tsx new file mode 100644 index 0000000..5856667 --- /dev/null +++ b/ui/src/pages/vendor/VendorTenantsPage.tsx @@ -0,0 +1 @@ +export function VendorTenantsPage() { return
VendorTenantsPage (TODO)
; } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 454d83d..30ad734 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -1,30 +1,56 @@ -import { Routes, Route } from 'react-router'; +import { Routes, Route, Navigate } from 'react-router'; import { LoginPage } from './auth/LoginPage'; import { CallbackPage } from './auth/CallbackPage'; import { ProtectedRoute } from './auth/ProtectedRoute'; import { OrgResolver } from './auth/OrgResolver'; import { Layout } from './components/Layout'; -import { DashboardPage } from './pages/DashboardPage'; -import { LicensePage } from './pages/LicensePage'; -import { AdminTenantsPage } from './pages/AdminTenantsPage'; +import { RequireScope } from './components/RequireScope'; + +import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage'; +import { CreateTenantPage } from './pages/vendor/CreateTenantPage'; +import { TenantDetailPage } from './pages/vendor/TenantDetailPage'; +import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage'; +import { TenantLicensePage } from './pages/tenant/TenantLicensePage'; +import { OidcConfigPage } from './pages/tenant/OidcConfigPage'; +import { TeamPage } from './pages/tenant/TeamPage'; +import { SettingsPage } from './pages/tenant/SettingsPage'; export function AppRouter() { return ( } /> } /> - - - - - - } - > - } /> - } /> - } /> + }> + }> + }> + {/* Vendor console */} + }> + + + } /> + }> + + + } /> + }> + + + } /> + + {/* Tenant portal */} + } /> + } /> + } /> + } /> + } /> + + {/* Default redirect */} + } /> + + ); diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index 5bf8d6c..4637205 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -4,6 +4,8 @@ export interface TenantResponse { slug: string; tier: string; status: string; + serverEndpoint: string | null; + provisionError: string | null; createdAt: string; updatedAt: string; } @@ -28,3 +30,64 @@ export interface MeResponse { logtoOrgId: string; }>; } + +// Vendor API types +export interface VendorTenantSummary { + id: string; + name: string; + slug: string; + tier: string; + status: string; + serverState: string; + licenseExpiry: string | null; + provisionError: string | null; +} + +export interface VendorTenantDetail { + tenant: TenantResponse; + serverState: string; + serverHealthy: boolean; + serverStatus: string; + license: LicenseResponse | null; +} + +export interface CreateTenantRequest { + name: string; + slug: string; + tier?: string; +} + +// Tenant portal API types +export interface DashboardData { + name: string; + slug: string; + tier: string; + status: string; + serverHealthy: boolean; + serverStatus: string; + serverEndpoint: string | null; + licenseTier: string | null; + licenseDaysRemaining: number; + limits: Record; + features: Record; +} + +export interface TenantLicenseData { + id: string; + tier: string; + features: Record; + limits: Record; + issuedAt: string; + expiresAt: string; + token: string; + daysRemaining: number; +} + +export interface TenantSettings { + name: string; + slug: string; + tier: string; + status: string; + serverEndpoint: string | null; + createdAt: string; +}