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
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.
149 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|