267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import { Outlet, useNavigate, useLocation } from 'react-router';
|
|
import {
|
|
AppShell,
|
|
Sidebar,
|
|
TopBar,
|
|
} from '@cameleer/design-system';
|
|
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail, BarChart3, Server, ExternalLink, Key, Lock, UserCog } from 'lucide-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useAuth } from '../auth/useAuth';
|
|
import { useScopes } from '../auth/useScopes';
|
|
import { useOrgStore } from '../auth/useOrganization';
|
|
import { api } from '../api/client';
|
|
import type { VendorTenantSummary } from '../types/api';
|
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
|
|
|
function CameleerLogo() {
|
|
return (
|
|
<img
|
|
src={cameleerLogo}
|
|
alt=""
|
|
width="24"
|
|
height="24"
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 isVendor = scopes.has('platform:admin');
|
|
const { data: vendorTenants } = useQuery<VendorTenantSummary[]>({
|
|
queryKey: ['vendor', 'tenants'],
|
|
queryFn: () => api.get('/vendor/tenants'),
|
|
enabled: isVendor,
|
|
refetchInterval: 30_000,
|
|
});
|
|
const isTenantAdmin = scopes.has('tenant:manage');
|
|
const onVendorRoute = location.pathname.startsWith('/vendor');
|
|
// Vendor on vendor routes: show only TENANTS. On tenant routes: show tenant portal too (for debugging).
|
|
const showTenantPortal = isTenantAdmin && (!isVendor || !onVendorRoute);
|
|
|
|
// Build breadcrumbs from path
|
|
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
|
|
const breadcrumb = segments.map((seg) => {
|
|
const label = seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, ' ');
|
|
return { label };
|
|
});
|
|
|
|
const userMenuItems = [
|
|
{
|
|
label: 'Account Settings',
|
|
icon: <Settings size={14} />,
|
|
onClick: () => navigate('/settings/account'),
|
|
},
|
|
];
|
|
|
|
const sidebar = (
|
|
<Sidebar collapsed={false} onCollapseToggle={() => {}}>
|
|
<Sidebar.Header
|
|
logo={<CameleerLogo />}
|
|
title="Cameleer SaaS"
|
|
onClick={() => navigate(isVendor ? '/vendor/tenants' : '/tenant')}
|
|
/>
|
|
|
|
{/* Vendor console — only visible to platform:admin */}
|
|
{isVendor && (
|
|
<Sidebar.Section
|
|
icon={<Building size={16} />}
|
|
label="Vendor"
|
|
open={onVendorRoute}
|
|
active={isActive(location, '/vendor')}
|
|
onToggle={() => navigate('/vendor/tenants')}
|
|
>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/tenants') && !location.pathname.startsWith('/vendor/tenants/') ? 600 : 400,
|
|
color: isActive(location, '/vendor/tenants') && !location.pathname.startsWith('/vendor/tenants/') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/tenants')}
|
|
>
|
|
<Users size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Tenants
|
|
</div>
|
|
{vendorTenants?.filter(t => t.status !== 'DELETED').map(t => (
|
|
<div
|
|
key={t.id}
|
|
style={{ padding: '4px 12px 4px 48px', fontSize: 12, cursor: 'pointer',
|
|
fontWeight: isActive(location, `/vendor/tenants/${t.id}`) ? 600 : 400,
|
|
color: isActive(location, `/vendor/tenants/${t.id}`) ? 'var(--amber)' : 'var(--text-muted)',
|
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
onClick={() => navigate(`/vendor/tenants/${t.id}`)}
|
|
title={t.name}
|
|
>
|
|
{t.name}
|
|
</div>
|
|
))}
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/audit') ? 600 : 400,
|
|
color: isActive(location, '/vendor/audit') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/audit')}
|
|
>
|
|
<ScrollText size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Audit Log
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/certificates') ? 600 : 400,
|
|
color: isActive(location, '/vendor/certificates') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/certificates')}
|
|
>
|
|
<ShieldCheck size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Certificates
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/metrics') ? 600 : 400,
|
|
color: isActive(location, '/vendor/metrics') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/metrics')}
|
|
>
|
|
<BarChart3 size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Metrics
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/infrastructure') ? 600 : 400,
|
|
color: isActive(location, '/vendor/infrastructure') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/infrastructure')}
|
|
>
|
|
<Server size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Infrastructure
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/email') ? 600 : 400,
|
|
color: isActive(location, '/vendor/email') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/email')}
|
|
>
|
|
<Mail size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Email Connector
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/license-tools') ? 600 : 400,
|
|
color: isActive(location, '/vendor/license-tools') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/license-tools')}
|
|
>
|
|
<Key size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
License Tools
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/auth-policy') ? 600 : 400,
|
|
color: isActive(location, '/vendor/auth-policy') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/auth-policy')}
|
|
>
|
|
<Lock size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Auth Policy
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
|
fontWeight: isActive(location, '/vendor/admins') ? 600 : 400,
|
|
color: isActive(location, '/vendor/admins') ? 'var(--amber)' : 'var(--text-muted)' }}
|
|
onClick={() => navigate('/vendor/admins')}
|
|
>
|
|
<UserCog size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Administrators
|
|
</div>
|
|
<div
|
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
|
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
|
>
|
|
<ExternalLink size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
|
Logto Console
|
|
</div>
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
{/* Tenant portal — visible to tenant admins; hidden for vendor on vendor routes */}
|
|
{showTenantPortal && (
|
|
<>
|
|
<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={<Shield size={16} />}
|
|
label="Security"
|
|
open={false}
|
|
active={isActive(location, '/tenant/sso')}
|
|
onToggle={() => navigate('/tenant/sso')}
|
|
>
|
|
{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={<ScrollText size={16} />}
|
|
label="Audit Log"
|
|
open={false}
|
|
active={isActive(location, '/tenant/audit')}
|
|
onToggle={() => navigate('/tenant/audit')}
|
|
>
|
|
{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>
|
|
);
|
|
|
|
return (
|
|
<AppShell sidebar={sidebar}>
|
|
<TopBar
|
|
breadcrumb={breadcrumb}
|
|
user={username ? { name: username } : undefined}
|
|
userMenuItems={userMenuItems}
|
|
onLogout={logout}
|
|
/>
|
|
<Outlet />
|
|
</AppShell>
|
|
);
|
|
}
|