Files
0pt/components/portal-shell.tsx
admin 023406c9b6
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 3s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 21s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / integration (pull_request) Successful in 28s
CI (SIAX Cloud) / quality (push) Successful in 48s
CI (SIAX Cloud) / quality (pull_request) Successful in 45s
feat(auth): Zitadel OIDC login for the portal
The portal had no way to obtain a verified principal: /api/0pt/* requires a
Bearer token, but the browser only had the dev-only HMAC cookie.

- GET /api/auth/login: Authorization Code + PKCE against Zitadel.
- GET /api/auth/callback: code exchange, id_token verification (issuer,
  audience, nonce) and a signed session cookie with sub + tenant.
- middleware accepts the signed session as a verified principal; Bearer wins.
- dev login POST is refused in production.
- Sign-in link in the portal shell.
2026-11-02 15:48:02 +01:00

149 lines
5.6 KiB
TypeScript

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useT } from '@/components/locale-provider';
export type NavItem = { label: { en: string; sv: string }; href: string };
const CUSTOMER_NAV: NavItem[] = [
{ label: { en: 'Overview', sv: 'Översikt' }, href: '/portal' },
{ label: { en: 'Subscriptions', sv: 'Prenumerationer' }, href: '/portal/subscriptions' },
{ label: { en: 'Documents', sv: 'Dokument' }, href: '/portal/dokument' },
{ label: { en: 'Credits', sv: 'Krediter' }, href: '/portal/krediter' },
{ label: { en: 'Integrations', sv: 'Integrationer' }, href: '/portal/integrationer' },
{ label: { en: 'Settings', sv: 'Inställningar' }, href: '/portal/settings' },
];
const ADMIN_NAV: NavItem[] = [
{ label: { en: 'Overview', sv: 'Översikt' }, href: '/admin' },
{ label: { en: 'Sources', sv: 'Källor' }, href: '/admin/sources' },
{ label: { en: 'Jobs', sv: 'Jobb' }, href: '/admin/jobs' },
{ label: { en: 'Tools', sv: 'Verktyg' }, href: '/admin/verktyg' },
{ label: { en: 'Customers', sv: 'Kunder' }, href: '/admin/customers' },
];
export function PortalShell({
variant,
title,
children,
}: {
variant: 'customer' | 'admin';
title: string;
children: React.ReactNode;
}) {
const t = useT();
const pathname = usePathname();
const nav = variant === 'admin' ? ADMIN_NAV : CUSTOMER_NAV;
const [email, setEmail] = useState<string | null>(null);
useEffect(() => {
fetch('/api/auth/me')
.then((r) => r.json())
.then((d) => setEmail(d?.user?.email ?? null))
.catch(() => {});
}, []);
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
setEmail(null);
}
return (
<div className="min-h-screen bg-background text-foreground">
{/* top bar */}
<div className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-background px-6">
<Link href="/" className="flex items-center gap-2">
<span className="text-lg font-semibold tracking-[-0.04em]">0pt</span>
<span className="inline-block h-1 w-1" style={{ backgroundColor: 'var(--accent-red)' }} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{variant === 'admin' ? 'admin' : 'portal'}
</span>
</Link>
<div className="flex items-center gap-4">
{email ? (
<>
<span className="font-mono text-[11px] text-muted-foreground">{email}</span>
<button
onClick={logout}
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
>
{t({ en: 'Log out', sv: 'Logga ut' })}
</button>
</>
) : (
<a
href="/api/auth/login?next=/portal/dokument"
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
>
{t({ en: 'Log in', sv: 'Logga in' })}
</a>
)}
</div>
</div>
<div className="mx-auto flex max-w-[80rem] flex-col gap-0 md:flex-row">
{/* sidebar */}
<aside className="shrink-0 border-b border-border md:w-56 md:border-b-0 md:border-r">
<nav className="flex flex-row gap-0 overflow-x-auto p-2 md:flex-col md:p-4">
{nav.map((n) => {
const active = pathname === n.href;
return (
<Link
key={n.href}
href={n.href}
aria-current={active ? 'page' : undefined}
className="border-l-2 px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors"
style={{
borderColor: active ? 'var(--accent-red)' : 'transparent',
color: active ? 'var(--foreground)' : 'var(--muted-foreground)',
}}
>
{t(n.label)}
</Link>
);
})}
{variant === 'customer' ? (
<Link
href="/bestall"
className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]"
>
{t({ en: 'New order', sv: 'Ny beställning' })}
</Link>
) : (
<Link
href="/portal"
className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
>
{t({ en: '← Customer view', sv: '← Kundvy' })}
</Link>
)}
</nav>
</aside>
{/* content */}
<main id="main-content" className="min-w-0 flex-1 px-6 py-10">
<h1 className="text-3xl font-medium tracking-[-0.03em] md:text-4xl">{title}</h1>
<div className="mt-8">{children}</div>
</main>
</div>
</div>
);
}
export function StatusPill({ status }: { status: string }) {
const color =
status === 'delivered' || status === 'accepted'
? 'var(--prov-3)'
: status === 'in_production' || status === 'review'
? 'var(--accent-red)'
: 'var(--muted-foreground)';
return (
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em]">
<span className="inline-block h-2 w-2" style={{ backgroundColor: color }} />
{status}
</span>
);
}