Files
cameleer-saas/ui/src/components/Layout.tsx
hsiegeln e167d5475e
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 40s
feat: production-ready TLS with self-signed cert init container
Standard OIDC architecture: subdomain routing (auth.HOST, server.HOST),
TLS via Traefik, self-signed cert auto-generated on first boot.

- Add traefik-certs init container (generates wildcard self-signed cert)
- Enable TLS on all Traefik routers (websecure entrypoint)
- HTTP→HTTPS redirect in traefik.yml
- Host-based routing for all services (no more path conflicts)
- PUBLIC_PROTOCOL env var (https default, configurable)
- Protocol-aware redirect URIs in bootstrap
- Protocol-aware UI fallbacks

Customer bootstrap: set PUBLIC_HOST + DNS records + docker compose up.
For production TLS, configure Traefik ACME (Let's Encrypt).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:14:25 +02:00

189 lines
5.1 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 { useScopes } from '../auth/useScopes';
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 { logout } = useAuth();
const scopes = useScopes();
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 */}
{scopes.has('platform:admin') && (
<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(`${window.location.protocol}//server.${window.location.hostname}`, '_blank', 'noopener')}
/>
{/* User info + logout */}
<Sidebar.FooterLink
icon={<UserIcon />}
label="Account"
onClick={logout}
/>
</Sidebar.Footer>
</Sidebar>
);
return (
<AppShell sidebar={sidebar}>
<TopBar
breadcrumb={[]}
user={undefined}
onLogout={logout}
/>
<Outlet />
</AppShell>
);
}