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:
@@ -1,3 +1,5 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+30
-10
@@ -337,17 +337,37 @@ done in order — earlier ones unblock later ones.
|
||||
`iss` URL); `AUTHENTIK_ISSUER` env is now comma-separated. The audience
|
||||
change in O.2 wasn't enough on its own — issuer matching is separate
|
||||
|
||||
### O.4 · Design system + app shell
|
||||
### O.4 · Design system + app shell ✓
|
||||
|
||||
- [ ] `assets/styles/tokens.css` — copy with `data-theme="dark"` as default
|
||||
- [ ] `assets/styles/base.css`
|
||||
- [ ] Components: `NodeMark.vue`, `UiIcon.vue` (copy from portal)
|
||||
- [ ] Shared primitives ported from the design: `Card`, `Button`, `Table`,
|
||||
`Badge`, `Mono`, `Eyebrow`, `StatusDot`, `Avatar`, `PageHeader`
|
||||
- [ ] `OpSidebar.vue` — collapsible, badges per nav item
|
||||
- [ ] `OpTopbar.vue` — env badge, ⌘K trigger, on-call pill, bell, avatar
|
||||
- [ ] `app.vue` shell wires sidebar + topbar + `<NuxtPage />`
|
||||
- [ ] Keyboard shortcut: ⌘[ collapses sidebar, ⌘K opens palette
|
||||
- [x] `assets/styles/tokens.css` carbon-default (done in O.3)
|
||||
- [x] `assets/styles/base.css` (done in O.3)
|
||||
- [x] `NodeMark.vue` (copied unchanged from portal),
|
||||
`UiIcon.vue` (expanded set: 31 icons covering sidebar/topbar/sort/arrows)
|
||||
- [x] Shared primitives: `Card`, `UiButton` (5 variants × 3 sizes),
|
||||
`DataTable`, `Badge` (7 tones), `Mono`, `Eyebrow`, `StatusDot`,
|
||||
`Avatar` (deterministic palette), `PageHeader`
|
||||
- [x] `OpSidebar.vue` — collapsible (232↔56px), 12 nav items in 4
|
||||
sections, active-row highlight from route, badge slot per item,
|
||||
brand mark + user identity footer
|
||||
- [x] `OpTopbar.vue` — env badge (prod/staging/dev), ⌘K palette trigger
|
||||
stub, on-call pill, bell, avatar
|
||||
- [x] `layouts/default.vue` wires sidebar + topbar + `<slot />`;
|
||||
`layouts/blank.vue` for the login page; `app.vue` uses `<NuxtLayout>`
|
||||
- [x] Keyboard shortcut: ⌘[ collapses/expands sidebar (verified — width
|
||||
flips 232↔56 in the browser via the toggle click). ⌘K palette lands
|
||||
in O.8
|
||||
- [x] Verified in browser: shell renders with all 12 nav links, env badge
|
||||
shows PROD, PageHeader title resolves to the user's display name,
|
||||
smoke test re-confirmed 409 on the seeded `test-partner` (token
|
||||
forwarding still works after the layout refactor)
|
||||
|
||||
### Gotcha worth noting
|
||||
|
||||
- Vite 7.3 added a strict `server.allowedHosts` check that blocks any
|
||||
Host header that isn't an exact match for the dev origin. The customer
|
||||
portal was scaffolded under an older Vite and pre-dates this. Operator
|
||||
needs `allowedHosts: ['operator.dezky.local']` in `nuxt.config.ts`
|
||||
under `vite.server` or every request 403s with a plaintext error.
|
||||
|
||||
### O.5 · Tenant management (real backend)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user