Files
cameleer-saas/ui/src/components/Layout.tsx
2026-04-27 14:58:27 +02:00

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>
);
}