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,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<typeof useLocation>, 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 = (
|
||||
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed(c => !c)}>
|
||||
<Sidebar collapsed={false} onCollapseToggle={() => {}}>
|
||||
<Sidebar.Header
|
||||
logo={<CameleerLogo />}
|
||||
title="Cameleer SaaS"
|
||||
onClick={() => navigate('/')}
|
||||
onClick={() => navigate(isVendor ? '/vendor/tenants' : '/tenant')}
|
||||
/>
|
||||
|
||||
{/* Dashboard */}
|
||||
<Sidebar.Section
|
||||
icon={<LayoutDashboard size={18} />}
|
||||
label="Dashboard"
|
||||
open={false}
|
||||
active={location.pathname === '/' || location.pathname === ''}
|
||||
onToggle={() => navigate('/')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
{/* License */}
|
||||
<Sidebar.Section
|
||||
icon={<ShieldCheck size={18} />}
|
||||
label="License"
|
||||
open={false}
|
||||
active={location.pathname.startsWith('/license')}
|
||||
onToggle={() => navigate('/license')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
{/* Platform Admin section */}
|
||||
{scopes.has('platform:admin') && (
|
||||
{/* Vendor console — only visible to platform:admin */}
|
||||
{isVendor && (
|
||||
<Sidebar.Section
|
||||
icon={<Building size={18} />}
|
||||
label="Platform"
|
||||
icon={<Building size={16} />}
|
||||
label="Tenants"
|
||||
open={false}
|
||||
active={location.pathname.startsWith('/admin')}
|
||||
onToggle={() => navigate('/admin/tenants')}
|
||||
active={isActive(location, '/vendor/tenants')}
|
||||
onToggle={() => navigate('/vendor/tenants')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
)}
|
||||
|
||||
{/* Tenant portal */}
|
||||
<Sidebar.Section
|
||||
icon={<LayoutDashboard size={16} />}
|
||||
label="Dashboard"
|
||||
open={false}
|
||||
active={location.pathname === '/tenant'}
|
||||
onToggle={() => navigate('/tenant')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<ShieldCheck size={16} />}
|
||||
label="License"
|
||||
open={false}
|
||||
active={isActive(location, '/tenant/license')}
|
||||
onToggle={() => navigate('/tenant/license')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<KeyRound size={16} />}
|
||||
label="OIDC"
|
||||
open={false}
|
||||
active={isActive(location, '/tenant/oidc')}
|
||||
onToggle={() => navigate('/tenant/oidc')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<Users size={16} />}
|
||||
label="Team"
|
||||
open={false}
|
||||
active={isActive(location, '/tenant/team')}
|
||||
onToggle={() => navigate('/tenant/team')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Section
|
||||
icon={<Settings size={16} />}
|
||||
label="Settings"
|
||||
open={false}
|
||||
active={isActive(location, '/tenant/settings')}
|
||||
onToggle={() => navigate('/tenant/settings')}
|
||||
>
|
||||
{null}
|
||||
</Sidebar.Section>
|
||||
|
||||
<Sidebar.Footer>
|
||||
{/* Link to the server observability dashboard */}
|
||||
<Sidebar.FooterLink
|
||||
icon={<Activity size={18} />}
|
||||
icon={<Server size={16} />}
|
||||
label="Open Server Dashboard"
|
||||
onClick={() => window.open('/server/', '_blank', 'noopener')}
|
||||
onClick={() => window.open(serverDashboardHref, '_blank', 'noopener')}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar>
|
||||
@@ -94,17 +130,9 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<AppShell sidebar={sidebar}>
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
user={{ name: username || 'User' }}
|
||||
user={username ? { name: username } : undefined}
|
||||
onLogout={logout}
|
||||
/>
|
||||
<Outlet />
|
||||
|
||||
Reference in New Issue
Block a user