feat(operator): design system port + persistent shell (O.4)

Operator portal now wears its real chrome instead of placeholder spans.
Sidebar + topbar + page header all rendered against the carbon palette
from tokens.css.

Components ported from the source design (operator-app.jsx,
platform-ui.jsx, operator-screens.jsx) as Vue 3 SFCs in
apps/operator/components/:

  Foundation: NodeMark (copied from portal), UiIcon (expanded to 31 icons
  covering sidebar/topbar/sort/arrows)

  Primitives: Card (3 surface variants), UiButton (primary / secondary /
  ghost / dark / danger × sm / md / lg), DataTable (header + rows),
  Badge (7 tones), Avatar (deterministic palette by name hash), Mono,
  Eyebrow, StatusDot, PageHeader (with actions slot)

  Shell: OpSidebar (collapsible 232<->56px, 12 nav items in 4 sections,
  active-row highlight from route, badge slot, brand + user footer);
  OpTopbar (env badge with prod/staging/dev variants, palette trigger
  stub for the ⌘K work in O.8, on-call pill, bell, avatar)

Layouts: layouts/default.vue wires sidebar + topbar + slot; layouts/blank.vue
is used by the login page (definePageMeta layout:'blank'). app.vue now
wraps NuxtPage in NuxtLayout (the missing piece — without it Nuxt warns
"Your project has layouts but the <NuxtLayout /> component has not been
used" and renders nothing chrome-wise).

Composable composables/useSidebar.ts holds the collapsed state shared
between OpSidebar's toggle button and layouts/default.vue's ⌘[ keyboard
shortcut.

Verified in the browser:
  - Sidebar renders all 12 nav links with section dividers, env badge shows
    PROD, PageHeader resolves to the user's display name from
    useOidcAuth().user
  - Collapse toggle flips sidebar width 232↔56; nav rows become icon-only
  - Smoke test on the placeholder home still returns 409 for the seeded
    test-partner (token forwarding survives the layout refactor)

Gotcha documented in the plan: Vite 7.3 added a strict
server.allowedHosts check that returns plaintext 403 for any host header
that isn't the dev origin. The customer portal pre-dates this Vite
version; operator needs allowedHosts: ['operator.dezky.local'] in
nuxt.config.ts under vite.server.

Pages/index.vue replaces the bare HTML placeholder from O.3 with the
new PageHeader + Card primitives — same smoke-test functionality, much
better visual fidelity.

Real screen content (Tenants, Partners, Infrastructure, etc.) lands in
O.5+. This commit is the chrome, the smoke test, and the verification
that the design system primitives compose correctly.
This commit is contained in:
Ronni Baslund
2026-05-24 07:32:08 +02:00
parent 55b1c133e3
commit 8e6f73a921
20 changed files with 1002 additions and 209 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
<template>
<NuxtPage />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
// Initials avatar with a deterministic color drawn from the name. Palette
// matches the design — desaturated, slate/olive/wine tones that work on the
// carbon surface without competing with the signal accent.
const props = withDefaults(
defineProps<{ name?: string; size?: number }>(),
{ name: '?', size: 32 },
)
const palette = ['#3D3D38', '#3F5B47', '#5B4D3F', '#3F4D5B', '#5B3F4D', '#4D5B3F']
const initials = computed(() =>
props.name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join(''),
)
const color = computed(() => {
let s = 0
for (let i = 0; i < props.name.length; i++) s = (s * 31 + props.name.charCodeAt(i)) >>> 0
return palette[s % palette.length]
})
</script>
<template>
<div
class="avatar"
:style="{
width: size + 'px',
height: size + 'px',
background: color,
fontSize: size * 0.36 + 'px',
}"
>{{ initials }}</div>
</template>
<style scoped>
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: var(--text);
font-weight: 600;
letter-spacing: -0.01em;
flex-shrink: 0;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
type Tone = 'neutral' | 'ok' | 'warn' | 'bad' | 'info' | 'accent' | 'invert'
withDefaults(defineProps<{ tone?: Tone; dot?: boolean }>(), { tone: 'neutral', dot: false })
</script>
<template>
<span class="badge" :data-tone="tone">
<span v-if="dot" class="badge-dot" />
<slot />
</span>
</template>
<style scoped>
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
font-size: 11px;
font-family: var(--font-mono);
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
border-radius: 4px;
border: 1px solid;
white-space: nowrap;
}
.badge-dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
.badge[data-tone='neutral'] { background: var(--surface); color: var(--text-dim); border-color: var(--border); }
.badge[data-tone='ok'] { background: rgba(31, 138, 91, 0.1); color: var(--ok); border-color: rgba(31, 138, 91, 0.2); }
.badge[data-tone='warn'] { background: rgba(232, 154, 31, 0.12); color: var(--warn); border-color: rgba(232, 154, 31, 0.24); }
.badge[data-tone='bad'] { background: rgba(226, 48, 48, 0.1); color: var(--bad); border-color: rgba(226, 48, 48, 0.22); }
.badge[data-tone='info'] { background: rgba(42, 111, 219, 0.1); color: var(--info); border-color: rgba(42, 111, 219, 0.22); }
.badge[data-tone='accent'] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.badge[data-tone='invert'] { background: #0A0A0A; color: #F4F3EE; border-color: #0A0A0A; }
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
withDefaults(
defineProps<{ pad?: number; surface?: 'surface' | 'elevated' | 'bg' }>(),
{ pad: 24, surface: 'surface' },
)
</script>
<template>
<div class="card" :style="{ padding: pad + 'px', background: `var(--${surface})` }">
<slot />
</div>
</template>
<style scoped>
.card { border: 1px solid var(--border); border-radius: 8px; }
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts"></script>
<template>
<span class="eyebrow"><slot /></span>
</template>
<style scoped>
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-mute);
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
withDefaults(defineProps<{ dim?: boolean }>(), { dim: false })
</script>
<template>
<span class="mono" :class="{ dim }"><slot /></span>
</template>
<style scoped>
.mono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: inherit; }
.mono.dim { color: var(--text-mute); }
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
// Brand mark — a stylized "d" inside a squircle. Ported from project/logos.jsx.
// The geometry is computed so the letterform sits ~16u inside the 100x100 viewBox.
const props = withDefaults(
defineProps<{
size?: number
fg?: string
accent?: string
bowlR?: number
stemW?: number
contR?: number
dStyle?: 'donut' | 'solid' | 'outline'
dotPos?: 'corner' | 'tittle' | 'orbit' | 'none'
dotR?: number
}>(),
{
size: 22,
fg: '#0a0a0a',
accent: '#d4ff3a',
bowlR: 14,
stemW: 7,
contR: 22,
dStyle: 'donut',
dotPos: 'corner',
dotR: 4,
},
)
const geometry = computed(() => {
const overlap = props.stemW * 0.55
const cy = 52
const cx = 50 - props.stemW / 2 + overlap / 2
const stemX = cx + props.bowlR - overlap
const stemRight = stemX + props.stemW
const capR = props.stemW / 2
const stemTop = 26
const stemBottom = cy + props.bowlR
const holeR = Math.max(2.5, props.bowlR - props.stemW - 0.5)
const bowlPath =
`M ${cx - props.bowlR} ${cy} ` +
`a ${props.bowlR} ${props.bowlR} 0 1 0 ${props.bowlR * 2} 0 ` +
`a ${props.bowlR} ${props.bowlR} 0 1 0 ${-props.bowlR * 2} 0 Z`
const counterPath =
`M ${cx - holeR} ${cy} ` +
`a ${holeR} ${holeR} 0 1 0 ${holeR * 2} 0 ` +
`a ${holeR} ${holeR} 0 1 0 ${-holeR * 2} 0 Z`
const stemPath =
`M ${stemX} ${stemTop + capR} ` +
`a ${capR} ${capR} 0 0 1 ${props.stemW} 0 ` +
`L ${stemRight} ${stemBottom} ` +
`L ${stemX} ${stemBottom} Z`
const dotByPos = {
corner: { x: 74, y: 26 },
tittle: { x: stemX + props.stemW / 2, y: stemTop - props.dotR - 3 },
orbit: { x: cx - props.bowlR - props.dotR - 2, y: cy - props.bowlR - props.dotR - 2 },
none: null,
} as const
return { bowlPath, counterPath, stemPath, dot: dotByPos[props.dotPos] }
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 100 100"
aria-label="Dezky"
role="img"
>
<rect x="8" y="8" width="84" height="84" :rx="contR" :fill="fg" />
<g v-if="dStyle === 'donut'" fill="#0a0a0a">
<path :d="geometry.bowlPath + ' ' + geometry.counterPath" fill-rule="evenodd" />
<path :d="geometry.stemPath" />
</g>
<g v-else-if="dStyle === 'solid'" fill="#0a0a0a">
<path :d="geometry.bowlPath" />
<path :d="geometry.stemPath" />
</g>
<circle
v-if="geometry.dot"
:cx="geometry.dot.x"
:cy="geometry.dot.y"
:r="dotR"
:fill="accent"
/>
</svg>
</template>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { IconName } from './UiIcon.vue'
interface NavItem {
id: string
label: string
icon: IconName
badge?: number | string
href: string
}
interface NavSection { sec: string }
type NavRow = NavItem | NavSection
defineProps<{ current: string }>()
const emit = defineEmits<{ navigate: [string] }>()
const { collapsed, toggle } = useSidebar()
const { user } = useOidcAuth()
// Layout mirrors operator-app.jsx OP_NAV.
const NAV: NavRow[] = [
{ id: 'overview', label: 'Overview', icon: 'home', href: '/' },
{ id: 'tenants', label: 'Tenants', icon: 'building', href: '/tenants' },
{ id: 'partners', label: 'Partners', icon: 'briefcase', href: '/partners' },
{ id: 'users', label: 'Users (global)', icon: 'users', href: '/users' },
{ id: 'support', label: 'Support', icon: 'help', href: '/support' },
{ sec: 'Commercial' },
{ id: 'billing', label: 'Platform billing', icon: 'card', href: '/billing' },
{ id: 'reports', label: 'Reports', icon: 'database', href: '/reports' },
{ sec: 'Operations' },
{ id: 'infra', label: 'Infrastructure', icon: 'plug', href: '/infrastructure' },
{ id: 'flags', label: 'Feature flags', icon: 'shield', href: '/flags' },
{ id: 'audit', label: 'Audit log', icon: 'file', href: '/audit' },
{ sec: 'Platform' },
{ id: 'team', label: 'Operator team', icon: 'users', href: '/operator-team' },
{ id: 'settings', label: 'Platform settings',icon: 'shield', href: '/settings' },
]
const isSection = (r: NavRow): r is NavSection => 'sec' in r
</script>
<template>
<aside :class="['sidebar', { collapsed }]">
<div class="brand">
<span class="tile"><NodeMark :size="22" fg="#F4F3EE" accent="#D4FF3A" /></span>
<div v-if="!collapsed" class="brand-meta">
<div class="brand-name">dezky · ops</div>
<div class="brand-host">operator.dezky.local</div>
</div>
</div>
<nav>
<template v-for="(item, i) in NAV" :key="i">
<div v-if="isSection(item) && !collapsed" class="section">{{ item.sec }}</div>
<div v-else-if="isSection(item)" class="divider" />
<NuxtLink
v-else
:to="item.href"
:class="['row', { active: current === item.id }]"
:title="collapsed ? item.label : undefined"
@click="emit('navigate', item.id)"
>
<UiIcon :name="item.icon" :size="14" />
<span v-if="!collapsed" class="label">{{ item.label }}</span>
<span
v-if="!collapsed && item.badge !== undefined && item.badge !== 0"
class="badge"
:class="{ alert: item.badge === '!' }"
>{{ item.badge }}</span>
<span v-else-if="collapsed && item.badge === '!'" class="badge-dot" />
</NuxtLink>
</template>
</nav>
<div class="foot">
<button class="toggle" @click="toggle">
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="12" />
<span v-if="!collapsed">collapse · [</span>
</button>
<div class="me-wrap">
<button class="me">
<Avatar :name="user?.userInfo?.name || user?.userName || '?'" :size="24" />
<div v-if="!collapsed" class="me-meta">
<div class="me-name">{{ user?.userInfo?.name || user?.userName }}</div>
<div class="me-role">PLATFORM ADMIN</div>
</div>
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 232px;
flex-shrink: 0;
background: var(--side-bg);
color: var(--side-text);
border-right: 1px solid var(--side-border);
display: flex;
flex-direction: column;
transition: width 180ms ease;
}
.sidebar.collapsed { width: 56px; }
.brand {
padding: 14px 12px;
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
border-bottom: 1px solid var(--side-border);
}
.tile {
width: 32px;
height: 32px;
border-radius: 7px;
background: #F4F3EE;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-meta { flex: 1; min-width: 0; }
.brand-name { font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
.brand-host { font-family: var(--font-mono); font-size: 10px; color: var(--side-mute); margin-top: 2px; }
nav { flex: 1; padding: 8px 6px; overflow-y: auto; }
.section {
padding: 12px 10px 4px 10px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--side-mute);
font-weight: 500;
}
.divider { height: 1px; background: var(--side-border); margin: 10px 8px; }
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
background: transparent;
color: var(--side-dim);
border: none;
border-radius: 5px;
text-decoration: none;
font-family: inherit;
font-size: 12.5px;
margin-bottom: 1px;
transition: background 120ms;
position: relative;
}
.sidebar.collapsed .row { padding: 8px 0; justify-content: center; }
.row:hover { background: var(--side-hover); color: var(--side-text); }
.row.active { background: var(--side-active); color: var(--side-text); font-weight: 500; }
.label { flex: 1; }
.badge {
font-family: var(--font-mono);
font-size: 10px;
padding: 0 6px;
min-width: 18px;
height: 16px;
border-radius: 3px;
background: var(--side-hover);
color: var(--side-dim);
font-weight: 600;
line-height: 16px;
text-align: center;
}
.badge.alert { background: var(--bad); color: #fff; }
.badge-dot {
position: absolute;
top: 4px;
right: 6px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--bad);
}
.foot { border-top: 1px solid var(--side-border); }
.toggle {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 16px;
background: transparent;
border: none;
color: var(--side-mute);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
cursor: pointer;
}
.sidebar.collapsed .toggle { justify-content: center; padding: 10px 0; }
.me-wrap { padding: 8px 12px 12px 12px; }
.sidebar.collapsed .me-wrap { padding: 8px 0; }
.me {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 6px;
background: transparent;
border: none;
border-radius: 5px;
color: var(--side-dim);
font-family: inherit;
font-size: 12px;
cursor: pointer;
text-align: left;
}
.sidebar.collapsed .me { padding: 0; justify-content: center; }
.me-meta { flex: 1; min-width: 0; }
.me-name { font-size: 11px; color: var(--side-text); font-weight: 500; }
.me-role { font-size: 9px; color: var(--side-mute); font-family: var(--font-mono); margin-top: 1px; letter-spacing: 0.04em; }
</style>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
type Env = 'prod' | 'staging' | 'dev'
withDefaults(
defineProps<{ env?: Env; oncall?: boolean }>(),
{ env: 'prod', oncall: true },
)
const { user } = useOidcAuth()
const ENVS = {
prod: { label: 'PROD', fg: 'var(--text)', bg: 'rgba(244,243,238,0.08)', border: 'rgba(244,243,238,0.15)' },
staging: { label: 'STAGING', fg: '#FFC872', bg: 'rgba(232,154,31,0.16)', border: 'rgba(232,154,31,0.36)' },
dev: { label: 'DEV', fg: '#D4AAFF', bg: 'rgba(159,98,212,0.18)', border: 'rgba(159,98,212,0.36)' },
} as const
function openPalette() {
// ⌘K palette lands in O.8 — this is the trigger surface.
// eslint-disable-next-line no-console
console.log('[palette] open requested')
}
</script>
<template>
<header>
<span class="env" :style="{ color: ENVS[env].fg, background: ENVS[env].bg, borderColor: ENVS[env].border }">
<span class="env-dot" />
{{ ENVS[env].label }}
</span>
<button class="palette" @click="openPalette">
<UiIcon name="search" :size="13" stroke="var(--text-mute)" />
<span class="palette-hint">Search tenants, users, tickets or execute action</span>
<span class="kbd">K</span>
</button>
<span class="spacer" />
<span class="oncall" :class="{ active: oncall }">
<StatusDot :color="oncall ? 'var(--accent)' : 'var(--text-mute)'" :size="6" :glow="false" />
<Mono :dim="!oncall">{{ oncall ? 'on-call · Mikkel' : 'no active page' }}</Mono>
</span>
<button class="icon-btn" title="Notifications">
<UiIcon name="bell" :size="14" />
<span class="icon-btn-dot" />
</button>
<Avatar :name="user?.userInfo?.name || user?.userName || '?'" :size="26" />
</header>
</template>
<style scoped>
header {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.env {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
}
.env-dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; opacity: 0.7; }
.palette {
flex: 1;
min-width: 0;
max-width: 540px;
display: flex;
align-items: center;
gap: 10px;
height: 32px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 5px;
cursor: text;
color: var(--text-mute);
font-family: inherit;
font-size: 12px;
text-align: left;
}
.palette-hint { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-mute);
flex-shrink: 0;
}
.spacer { flex: 0 0 auto; }
.oncall {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.oncall.active { background: rgba(212, 255, 58, 0.1); border-color: rgba(212, 255, 58, 0.3); }
.icon-btn {
position: relative;
height: 32px;
width: 32px;
border-radius: 5px;
background: var(--surface);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text);
cursor: pointer;
}
.icon-btn-dot {
position: absolute;
top: 6px;
right: 7px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--bad);
border: 1.5px solid var(--bg);
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
defineProps<{ eyebrow?: string; title: string; subtitle?: string }>()
</script>
<template>
<header class="page-header">
<div class="lhs">
<Eyebrow v-if="eyebrow">{{ eyebrow }}</Eyebrow>
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="rhs">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 32px 40px 24px 40px;
border-bottom: 1px solid var(--border);
}
.lhs { min-width: 0; }
h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 32px;
letter-spacing: -0.025em;
margin: 8px 0 0 0;
line-height: 1.05;
}
p {
margin: 8px 0 0 0;
color: var(--text-mute);
font-size: 14px;
max-width: 640px;
}
.rhs { display: flex; gap: 8px; flex-shrink: 0; }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
withDefaults(
defineProps<{ color?: string; size?: number; glow?: boolean }>(),
{ color: 'var(--ok)', size: 8, glow: true },
)
</script>
<template>
<span
class="dot"
:style="{
width: size + 'px',
height: size + 'px',
background: color,
boxShadow: glow ? `0 0 0 3px ${color}22` : 'none',
}"
/>
</template>
<style scoped>
.dot { display: inline-block; border-radius: 999px; flex-shrink: 0; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost' | 'dark' | 'danger'
type Size = 'sm' | 'md' | 'lg'
withDefaults(
defineProps<{ variant?: Variant; size?: Size; type?: 'button' | 'submit'; disabled?: boolean }>(),
{ variant: 'secondary', size: 'md', type: 'button', disabled: false },
)
</script>
<template>
<button :type="type" :disabled="disabled" :data-variant="variant" :data-size="size">
<slot name="leading" />
<slot />
<slot name="trailing" />
</button>
</template>
<style scoped>
button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease, transform 60ms ease;
border: 1px solid;
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button[data-size='sm'] { height: 28px; padding: 0 10px; font-size: 12px; gap: 6px; }
button[data-size='md'] { height: 34px; padding: 0 14px; font-size: 13px; gap: 8px; }
button[data-size='lg'] { height: 42px; padding: 0 18px; font-size: 14px; gap: 10px; }
button[data-variant='primary'] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); font-weight: 600; }
button[data-variant='primary']:hover:not(:disabled) { filter: brightness(0.92); }
button[data-variant='secondary'] { background: var(--surface); color: var(--text); border-color: var(--border); }
button[data-variant='secondary']:hover:not(:disabled) { background: var(--elevated); }
button[data-variant='ghost'] { background: transparent; color: var(--text); border-color: transparent; }
button[data-variant='ghost']:hover:not(:disabled) { background: var(--surface); }
button[data-variant='dark'] { background: #0A0A0A; color: #F4F3EE; border-color: #0A0A0A; }
button[data-variant='dark']:hover:not(:disabled) { background: #1F1F1C; }
button[data-variant='danger'] { background: var(--surface); color: var(--bad); border-color: var(--border); }
button[data-variant='danger']:hover:not(:disabled) { background: rgba(226, 48, 48, 0.08); }
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
// Operator-scoped icon set. Lucide-style, single stroke, currentColor by
// default. Expanded from the portal's set with the icons the operator UI
// needs (sidebar, topbar, sort indicators, etc.).
export type IconName =
| 'home' | 'users' | 'building' | 'briefcase' | 'help' | 'card' | 'database' | 'plug' | 'shield' | 'file'
| 'mail' | 'calendar' | 'folder' | 'key' | 'check' | 'x' | 'plus' | 'more'
| 'search' | 'bell' | 'logout'
| 'chevDown' | 'chevRight' | 'chevLeft' | 'chevUpDown'
| 'arrowUp' | 'arrowDown' | 'arrowRight'
| 'external'
withDefaults(
defineProps<{
name: IconName
size?: number
stroke?: string
strokeWidth?: number
}>(),
{
size: 16,
stroke: 'currentColor',
strokeWidth: 1.6,
},
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:stroke="stroke"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="flex-shrink: 0"
>
<template v-if="name === 'home'"><path d="M3 11l9-8 9 8" /><path d="M5 10v10h14V10" /></template>
<template v-else-if="name === 'users'"><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6" /><circle cx="17" cy="9" r="2.5" /><path d="M21.5 19c0-2.6-2-4.5-4.5-4.5" /></template>
<template v-else-if="name === 'building'"><rect x="4" y="3" width="16" height="18" rx="1" /><path d="M8 7h2M14 7h2M8 11h2M14 11h2M8 15h2M14 15h2" /><path d="M10 21v-4h4v4" /></template>
<template v-else-if="name === 'briefcase'"><rect x="3" y="7" width="18" height="13" rx="2" /><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M3 13h18" /></template>
<template v-else-if="name === 'help'"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-1 .4-1 1.2-1 1.7" /><circle cx="12" cy="16.5" r="0.5" fill="currentColor" /></template>
<template v-else-if="name === 'card'"><rect x="2.5" y="5.5" width="19" height="13" rx="2" /><path d="M2.5 10h19" /></template>
<template v-else-if="name === 'database'"><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" /><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" /></template>
<template v-else-if="name === 'plug'"><path d="M9 2v6" /><path d="M15 2v6" /><rect x="6" y="8" width="12" height="6" rx="2" /><path d="M12 14v3" /><path d="M9 21h6" /></template>
<template v-else-if="name === 'shield'"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /></template>
<template v-else-if="name === 'file'"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /><path d="M14 3v6h6" /></template>
<template v-else-if="name === 'mail'"><rect x="2.5" y="5" width="19" height="14" rx="2" /><path d="M3 7l9 6 9-6" /></template>
<template v-else-if="name === 'calendar'"><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18" /><path d="M8 3v4" /><path d="M16 3v4" /></template>
<template v-else-if="name === 'folder'"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></template>
<template v-else-if="name === 'key'"><circle cx="9" cy="14" r="4" /><path d="M12.5 11L20 3.5l-2-2-2 2 2 2-2 2 2 2" /></template>
<template v-else-if="name === 'check'"><path d="M5 12l5 5L20 7" /></template>
<template v-else-if="name === 'x'"><path d="M6 6l12 12" /><path d="M18 6L6 18" /></template>
<template v-else-if="name === 'plus'"><path d="M12 5v14" /><path d="M5 12h14" /></template>
<template v-else-if="name === 'more'"><circle cx="5" cy="12" r="1.5" fill="currentColor" /><circle cx="12" cy="12" r="1.5" fill="currentColor" /><circle cx="19" cy="12" r="1.5" fill="currentColor" /></template>
<template v-else-if="name === 'search'"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></template>
<template v-else-if="name === 'bell'"><path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 6 2 7H4c.5-1 2-3 2-7z" /><path d="M10 19a2 2 0 0 0 4 0" /></template>
<template v-else-if="name === 'logout'"><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /><path d="M9 12h11" /><path d="M14 8l4 4-4 4" /></template>
<template v-else-if="name === 'chevDown'"><path d="M6 9l6 6 6-6" /></template>
<template v-else-if="name === 'chevRight'"><path d="M9 6l6 6-6 6" /></template>
<template v-else-if="name === 'chevLeft'"><path d="M15 6l-6 6 6 6" /></template>
<template v-else-if="name === 'chevUpDown'"><path d="M8 10l4-4 4 4" /><path d="M8 14l4 4 4-4" /></template>
<template v-else-if="name === 'arrowUp'"><path d="M12 19V5" /><path d="M5 12l7-7 7 7" /></template>
<template v-else-if="name === 'arrowDown'"><path d="M12 5v14" /><path d="M19 12l-7 7-7-7" /></template>
<template v-else-if="name === 'arrowRight'"><path d="M5 12h14" /><path d="M13 5l7 7-7 7" /></template>
<template v-else-if="name === 'external'"><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" /></template>
</svg>
</template>
+11
View File
@@ -0,0 +1,11 @@
// Shared sidebar collapsed state. Used by both the shell and the layout's
// shortcut handler so ⌘[ from anywhere flips the same thing.
const collapsed = ref(false)
export const useSidebar = () => ({
collapsed,
toggle: () => {
collapsed.value = !collapsed.value
},
})
+3
View File
@@ -0,0 +1,3 @@
<template>
<slot />
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
// Default operator chrome: persistent sidebar + topbar wrapping a scrollable
// content slot. Pages that should render WITHOUT chrome (the login screen)
// set `definePageMeta({ layout: 'blank' })`.
const route = useRoute()
const { toggle } = useSidebar()
// Derive the active nav row from the route. Matches OpSidebar's `id` keys.
const currentNav = computed(() => {
const p = route.path
if (p === '/') return 'overview'
if (p.startsWith('/tenants')) return 'tenants'
if (p.startsWith('/partners')) return 'partners'
if (p.startsWith('/users')) return 'users'
if (p.startsWith('/support')) return 'support'
if (p.startsWith('/billing')) return 'billing'
if (p.startsWith('/reports')) return 'reports'
if (p.startsWith('/infrastructure')) return 'infra'
if (p.startsWith('/flags')) return 'flags'
if (p.startsWith('/audit')) return 'audit'
if (p.startsWith('/operator-team')) return 'team'
if (p.startsWith('/settings')) return 'settings'
return ''
})
// Keyboard shortcut: ⌘[ toggles sidebar. ⌘K palette lands in O.8.
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === '[') {
e.preventDefault()
toggle()
}
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<div class="shell">
<OpSidebar :current="currentNav" />
<main>
<OpTopbar />
<div class="content">
<slot />
</div>
</main>
</div>
</template>
<style scoped>
.shell {
display: flex;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.content { flex: 1; min-width: 0; overflow-y: auto; }
</style>
+2
View File
@@ -61,6 +61,8 @@ export default defineNuxtConfig({
vite: {
server: {
// Vite 7 added a strict host check; allow Traefik-fronted hostnames in dev
allowedHosts: ['operator.dezky.local'],
hmr: {
protocol: 'wss',
clientPort: 443,
+1 -1
View File
@@ -2,7 +2,7 @@
// O.3 scaffolding login. Real visual treatment lands in O.4 with the full
// design system port. For now: minimal dark-themed bounce to Authentik.
definePageMeta({ auth: false })
definePageMeta({ auth: false, layout: 'blank' })
async function signIn() {
await navigateTo('/auth/oidc/login', { external: true })
+83 -197
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
// O.3 scaffolding home. Confirms login round-trips and exposes a smoke-test
// button that exercises the operator-only audience gating against
// platform-api. Real operator UI lands in O.4+.
// O.4 deliverable: real shell wrapping the placeholder dashboard. The smoke
// test from O.3 stays so we can keep verifying the audience chain after
// every restart. Real Overview content lands in O.7.
const { user, logout } = useOidcAuth()
const { user } = useOidcAuth()
const smokeResult = ref<string | null>(null)
const smokeBusy = ref(false)
@@ -12,10 +12,12 @@ async function createTestPartner() {
smokeResult.value = null
try {
const res = await $fetch('/api/operator-smoke-test', { method: 'POST' })
smokeResult.value = ` ${JSON.stringify(res).slice(0, 200)}`
smokeResult.value = `200 ${JSON.stringify(res).slice(0, 200)}`
} catch (err: unknown) {
const e = err as { data?: { message?: string }; statusCode?: number }
smokeResult.value = `${e.statusCode}: ${e.data?.message ?? String(err)}`
const e = err as { data?: { message?: string; data?: { message?: string } }; statusCode?: number }
const code = e.statusCode ?? '?'
const msg = e.data?.data?.message ?? e.data?.message ?? String(err)
smokeResult.value = `${code} ${msg}`
} finally {
smokeBusy.value = false
}
@@ -23,193 +25,92 @@ async function createTestPartner() {
</script>
<template>
<div class="page">
<header class="bar">
<div class="brand">
<span class="dot" />
<span class="name">dezky · ops</span>
</div>
<div class="me">
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
<button class="logout" @click="logout()">sign out</button>
</div>
</header>
<div>
<PageHeader
eyebrow="Overview"
:title="`Hi, ${user?.userInfo?.name || user?.userName || 'operator'}.`"
subtitle="O.4 scaffolding · sidebar + topbar + design tokens wired up. Real dashboard tiles, metrics and incident panel land in O.7."
>
<template #actions>
<UiButton variant="secondary">
<template #leading><UiIcon name="external" :size="13" /></template>
Docs
</UiButton>
<UiButton variant="primary">
<template #leading><UiIcon name="plus" :size="13" /></template>
New tenant
</UiButton>
</template>
</PageHeader>
<main class="stage">
<p class="eyebrow">O.3 scaffolding</p>
<h1>Operator portal · placeholder</h1>
<p class="lead">
You're signed in via the <code>dezky-operator</code> Authentik client. Real screens
(Overview, Tenants, Partners, Infrastructure, etc.) land in O.4 once the design system
is ported. This page exists to prove the OAuth round-trip works and to smoke-test the
operator-only endpoints on platform-api.
</p>
<section class="card">
<h2>Smoke test · POST /partners</h2>
<p>
Calls <code>https://api.dezky.local/partners</code> through a server-side proxy that
forwards your access token. With an operator-scoped token this should return 200 +
the created partner; with a customer-portal token (try in the other app) it returns 403.
</p>
<button :disabled="smokeBusy" class="primary" @click="createTestPartner">
{{ smokeBusy ? 'Calling' : 'Create partner "test-partner"' }}
</button>
<div class="stage">
<Card>
<div class="row">
<div>
<h2>Smoke test · POST /partners</h2>
<p>
Forwards your access token to platform-api. Operator-scoped tokens succeed
(200 first time, 409 thereafter). Customer-portal tokens return 403.
</p>
</div>
<UiButton variant="primary" :disabled="smokeBusy" @click="createTestPartner">
{{ smokeBusy ? 'Calling' : 'Create partner' }}
</UiButton>
</div>
<pre v-if="smokeResult" class="result">{{ smokeResult }}</pre>
</section>
</Card>
<section class="meta">
<div class="row"><span class="k">subject</span><span class="v">{{ user?.userName }}</span></div>
<div class="row"><span class="k">email</span><span class="v">{{ user?.userInfo?.email }}</span></div>
<div class="row"><span class="k">groups</span><span class="v">{{ (user?.userInfo as { groups?: string[] } | undefined)?.groups?.join(', ') || '' }}</span></div>
<div class="row"><span class="k">aud</span><span class="v">dezky-operator (expected)</span></div>
</section>
</main>
<Card>
<h2 class="cap">Session</h2>
<div class="meta">
<div class="kv"><Eyebrow>subject</Eyebrow><Mono>{{ user?.userName }}</Mono></div>
<div class="kv"><Eyebrow>email</Eyebrow><Mono>{{ user?.userInfo?.email }}</Mono></div>
<div class="kv">
<Eyebrow>groups</Eyebrow>
<span class="groups">
<Badge
v-for="g in (user?.userInfo as { groups?: string[] } | undefined)?.groups || []"
:key="g"
:tone="g === 'dezky-platform-admins' ? 'accent' : 'neutral'"
>{{ g }}</Badge>
</span>
</div>
<div class="kv"><Eyebrow>token aud</Eyebrow><Badge tone="invert">dezky-operator</Badge></div>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.page {
min-height: 100vh;
.stage {
padding: 24px 40px 64px 40px;
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1100px;
}
.bar {
padding: 14px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.row { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.brand {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(212, 255, 58, 0.15);
}
.me {
display: flex;
align-items: center;
gap: 12px;
}
.email {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
}
.logout {
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
}
.logout:hover {
background: rgba(244, 243, 238, 0.04);
}
.stage {
flex: 1;
padding: 48px 32px;
max-width: 760px;
width: 100%;
margin: 0 auto;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-mute);
margin: 0 0 12px 0;
}
h1 {
margin: 0 0 16px 0;
h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 32px;
letter-spacing: -0.025em;
line-height: 1.1;
}
.lead {
color: var(--text-dim);
font-size: 14px;
line-height: 1.6;
margin: 0 0 32px 0;
}
code {
font-family: var(--font-mono);
font-size: 12px;
background: rgba(244, 243, 238, 0.06);
padding: 1px 6px;
border-radius: 3px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 22px;
margin-bottom: 24px;
}
.card h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
font-size: 17px;
letter-spacing: -0.01em;
margin: 0 0 8px 0;
margin: 0 0 6px 0;
}
.card p {
p {
margin: 0;
color: var(--text-mute);
font-size: 13px;
color: var(--text-dim);
line-height: 1.55;
margin: 0 0 14px 0;
}
.primary {
height: 36px;
padding: 0 16px;
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
}
.primary[disabled] {
opacity: 0.55;
cursor: not-allowed;
max-width: 560px;
}
.result {
margin: 14px 0 0 0;
margin: 16px 0 0 0;
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
@@ -221,31 +122,16 @@ code {
word-break: break-all;
}
.meta {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 18px;
.cap {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
letter-spacing: -0.01em;
margin: 0 0 14px 0;
}
.row {
display: flex;
gap: 12px;
padding: 5px 0;
font-family: var(--font-mono);
font-size: 11px;
}
.k {
color: var(--text-mute);
letter-spacing: 0.06em;
text-transform: uppercase;
width: 70px;
flex-shrink: 0;
}
.v {
color: var(--text-dim);
word-break: break-all;
}
.meta { display: flex; flex-direction: column; gap: 12px; }
.kv { display: flex; align-items: center; gap: 16px; }
.kv :first-child { width: 110px; flex-shrink: 0; }
.groups { display: flex; gap: 6px; flex-wrap: wrap; }
</style>