Bootstrap script now creates: - SaaS Owner (admin/admin) with platform-admin role - Tenant Admin (camel/camel) in Example Tenant org - Traditional Web App for cameleer3-server OIDC - DB records: tenant, default environment, license - Configures cameleer3-server OIDC via its admin API All credentials configurable via env vars. Backend: - Fix LogtoManagementClient resource URL (https://default.logto.app/api) - Add getUserRoles/getUserOrganizations to LogtoManagementClient - Add GET /api/me endpoint (user info, platform admin status, tenants) - Add GET /api/tenants list-all for platform admins - Remove insecure X-header forwarding from Traefik Frontend: - Org-scoped tokens: getAccessToken(resource, orgId) for tenant context - OrgResolver component populates org store from /api/me - useOrganization Zustand store (currentOrgId + currentTenantId) - Platform admin sidebar section + AdminTenantsPage - View Dashboard link points to cameleer3-server on port 8081 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
5.0 KiB
TypeScript
187 lines
5.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { Outlet, useNavigate } from 'react-router';
|
|
import {
|
|
AppShell,
|
|
Sidebar,
|
|
TopBar,
|
|
} from '@cameleer/design-system';
|
|
import { useAuth } from '../auth/useAuth';
|
|
import { EnvironmentTree } from './EnvironmentTree';
|
|
|
|
// Simple SVG logo mark for the sidebar header
|
|
function CameleerLogo() {
|
|
return (
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.15" />
|
|
<path
|
|
d="M7 14c0-2.5 2-4.5 4.5-4.5S16 11.5 16 14"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
<circle cx="12" cy="8" r="2" fill="currentColor" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// Nav icon helpers
|
|
function DashboardIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" />
|
|
<rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" />
|
|
<rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" />
|
|
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function EnvIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M2 4h12M2 8h12M2 12h12"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LicenseIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M5 8h6M5 5h6M5 11h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ObsIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M4 8h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
<path d="M8 4v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function UserIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5" />
|
|
<path
|
|
d="M2 13c0-3 2.7-5 6-5s6 2 6 5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PlatformIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<path d="M8 1l6 3.5v7L8 15l-6-3.5v-7L8 1z" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M8 1v14M2 4.5L14 4.5M2 11.5L14 11.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function Layout() {
|
|
const navigate = useNavigate();
|
|
const { username, logout, isPlatformAdmin } = useAuth();
|
|
|
|
const [envSectionOpen, setEnvSectionOpen] = useState(true);
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
|
|
const sidebar = (
|
|
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed((c) => !c)}>
|
|
<Sidebar.Header
|
|
logo={<CameleerLogo />}
|
|
title="Cameleer SaaS"
|
|
onClick={() => navigate('/')}
|
|
/>
|
|
|
|
{/* Dashboard */}
|
|
<Sidebar.Section
|
|
icon={<DashboardIcon />}
|
|
label="Dashboard"
|
|
open={false}
|
|
onToggle={() => navigate('/')}
|
|
>
|
|
{null}
|
|
</Sidebar.Section>
|
|
|
|
{/* Environments — expandable tree */}
|
|
<Sidebar.Section
|
|
icon={<EnvIcon />}
|
|
label="Environments"
|
|
open={envSectionOpen}
|
|
onToggle={() => setEnvSectionOpen((o) => !o)}
|
|
>
|
|
<EnvironmentTree />
|
|
</Sidebar.Section>
|
|
|
|
{/* License */}
|
|
<Sidebar.Section
|
|
icon={<LicenseIcon />}
|
|
label="License"
|
|
open={false}
|
|
onToggle={() => navigate('/license')}
|
|
>
|
|
{null}
|
|
</Sidebar.Section>
|
|
|
|
{/* Platform Admin section */}
|
|
{isPlatformAdmin && (
|
|
<Sidebar.Section
|
|
icon={<PlatformIcon />}
|
|
label="Platform"
|
|
open={false}
|
|
onToggle={() => navigate('/admin/tenants')}
|
|
>
|
|
{null}
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
<Sidebar.Footer>
|
|
{/* Link to the observability SPA (direct port, not via Traefik prefix) */}
|
|
<Sidebar.FooterLink
|
|
icon={<ObsIcon />}
|
|
label="View Dashboard"
|
|
onClick={() => window.open('http://localhost:8081', '_blank', 'noopener')}
|
|
/>
|
|
|
|
{/* User info + logout */}
|
|
<Sidebar.FooterLink
|
|
icon={<UserIcon />}
|
|
label={username ?? 'Account'}
|
|
onClick={logout}
|
|
/>
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
);
|
|
|
|
return (
|
|
<AppShell sidebar={sidebar}>
|
|
<TopBar
|
|
breadcrumb={[]}
|
|
user={username ? { name: username } : undefined}
|
|
onLogout={logout}
|
|
/>
|
|
<Outlet />
|
|
</AppShell>
|
|
);
|
|
}
|