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>
|
<template>
|
||||||
<NuxtPage />
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
</template>
|
</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: {
|
vite: {
|
||||||
server: {
|
server: {
|
||||||
|
// Vite 7 added a strict host check; allow Traefik-fronted hostnames in dev
|
||||||
|
allowedHosts: ['operator.dezky.local'],
|
||||||
hmr: {
|
hmr: {
|
||||||
protocol: 'wss',
|
protocol: 'wss',
|
||||||
clientPort: 443,
|
clientPort: 443,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// O.3 scaffolding login. Real visual treatment lands in O.4 with the full
|
// 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.
|
// design system port. For now: minimal dark-themed bounce to Authentik.
|
||||||
|
|
||||||
definePageMeta({ auth: false })
|
definePageMeta({ auth: false, layout: 'blank' })
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
await navigateTo('/auth/oidc/login', { external: true })
|
await navigateTo('/auth/oidc/login', { external: true })
|
||||||
|
|||||||
+83
-197
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// O.3 scaffolding home. Confirms login round-trips and exposes a smoke-test
|
// O.4 deliverable: real shell wrapping the placeholder dashboard. The smoke
|
||||||
// button that exercises the operator-only audience gating against
|
// test from O.3 stays so we can keep verifying the audience chain after
|
||||||
// platform-api. Real operator UI lands in O.4+.
|
// every restart. Real Overview content lands in O.7.
|
||||||
|
|
||||||
const { user, logout } = useOidcAuth()
|
const { user } = useOidcAuth()
|
||||||
const smokeResult = ref<string | null>(null)
|
const smokeResult = ref<string | null>(null)
|
||||||
const smokeBusy = ref(false)
|
const smokeBusy = ref(false)
|
||||||
|
|
||||||
@@ -12,10 +12,12 @@ async function createTestPartner() {
|
|||||||
smokeResult.value = null
|
smokeResult.value = null
|
||||||
try {
|
try {
|
||||||
const res = await $fetch('/api/operator-smoke-test', { method: 'POST' })
|
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) {
|
} catch (err: unknown) {
|
||||||
const e = err as { data?: { message?: string }; statusCode?: number }
|
const e = err as { data?: { message?: string; data?: { message?: string } }; statusCode?: number }
|
||||||
smokeResult.value = `✗ ${e.statusCode}: ${e.data?.message ?? String(err)}`
|
const code = e.statusCode ?? '?'
|
||||||
|
const msg = e.data?.data?.message ?? e.data?.message ?? String(err)
|
||||||
|
smokeResult.value = `${code} ${msg}`
|
||||||
} finally {
|
} finally {
|
||||||
smokeBusy.value = false
|
smokeBusy.value = false
|
||||||
}
|
}
|
||||||
@@ -23,193 +25,92 @@ async function createTestPartner() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div>
|
||||||
<header class="bar">
|
<PageHeader
|
||||||
<div class="brand">
|
eyebrow="Overview"
|
||||||
<span class="dot" />
|
:title="`Hi, ${user?.userInfo?.name || user?.userName || 'operator'}.`"
|
||||||
<span class="name">dezky · ops</span>
|
subtitle="O.4 scaffolding · sidebar + topbar + design tokens wired up. Real dashboard tiles, metrics and incident panel land in O.7."
|
||||||
</div>
|
>
|
||||||
<div class="me">
|
<template #actions>
|
||||||
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
|
<UiButton variant="secondary">
|
||||||
<button class="logout" @click="logout()">sign out</button>
|
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||||
</div>
|
Docs
|
||||||
</header>
|
</UiButton>
|
||||||
|
<UiButton variant="primary">
|
||||||
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
|
New tenant
|
||||||
|
</UiButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<main class="stage">
|
<div class="stage">
|
||||||
<p class="eyebrow">O.3 scaffolding</p>
|
<Card>
|
||||||
<h1>Operator portal · placeholder</h1>
|
<div class="row">
|
||||||
<p class="lead">
|
<div>
|
||||||
You're signed in via the <code>dezky-operator</code> Authentik client. Real screens
|
<h2>Smoke test · POST /partners</h2>
|
||||||
(Overview, Tenants, Partners, Infrastructure, etc.) land in O.4 once the design system
|
<p>
|
||||||
is ported. This page exists to prove the OAuth round-trip works and to smoke-test the
|
Forwards your access token to platform-api. Operator-scoped tokens succeed
|
||||||
operator-only endpoints on platform-api.
|
(200 first time, 409 thereafter). Customer-portal tokens return 403.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<section class="card">
|
<UiButton variant="primary" :disabled="smokeBusy" @click="createTestPartner">
|
||||||
<h2>Smoke test · POST /partners</h2>
|
{{ smokeBusy ? 'Calling…' : 'Create partner' }}
|
||||||
<p>
|
</UiButton>
|
||||||
Calls <code>https://api.dezky.local/partners</code> through a server-side proxy that
|
</div>
|
||||||
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>
|
|
||||||
<pre v-if="smokeResult" class="result">{{ smokeResult }}</pre>
|
<pre v-if="smokeResult" class="result">{{ smokeResult }}</pre>
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
<section class="meta">
|
<Card>
|
||||||
<div class="row"><span class="k">subject</span><span class="v">{{ user?.userName }}</span></div>
|
<h2 class="cap">Session</h2>
|
||||||
<div class="row"><span class="k">email</span><span class="v">{{ user?.userInfo?.email }}</span></div>
|
<div class="meta">
|
||||||
<div class="row"><span class="k">groups</span><span class="v">{{ (user?.userInfo as { groups?: string[] } | undefined)?.groups?.join(', ') || '—' }}</span></div>
|
<div class="kv"><Eyebrow>subject</Eyebrow><Mono>{{ user?.userName }}</Mono></div>
|
||||||
<div class="row"><span class="k">aud</span><span class="v">dezky-operator (expected)</span></div>
|
<div class="kv"><Eyebrow>email</Eyebrow><Mono>{{ user?.userInfo?.email }}</Mono></div>
|
||||||
</section>
|
<div class="kv">
|
||||||
</main>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.stage {
|
||||||
min-height: 100vh;
|
padding: 24px 40px 64px 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.row { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||||
padding: 14px 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
h2 {
|
||||||
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;
|
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 32px;
|
font-size: 17px;
|
||||||
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;
|
|
||||||
letter-spacing: -0.01em;
|
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;
|
font-size: 13px;
|
||||||
color: var(--text-dim);
|
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
margin: 0 0 14px 0;
|
max-width: 560px;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
margin: 14px 0 0 0;
|
margin: 16px 0 0 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -221,31 +122,16 @@ code {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.cap {
|
||||||
background: var(--surface);
|
font-family: var(--font-display);
|
||||||
border: 1px solid var(--border);
|
font-weight: 600;
|
||||||
border-radius: 10px;
|
font-size: 17px;
|
||||||
padding: 14px 18px;
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.meta { display: flex; flex-direction: column; gap: 12px; }
|
||||||
display: flex;
|
.kv { display: flex; align-items: center; gap: 16px; }
|
||||||
gap: 12px;
|
.kv :first-child { width: 110px; flex-shrink: 0; }
|
||||||
padding: 5px 0;
|
.groups { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
</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
|
`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
|
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
|
- [x] `assets/styles/tokens.css` carbon-default (done in O.3)
|
||||||
- [ ] `assets/styles/base.css`
|
- [x] `assets/styles/base.css` (done in O.3)
|
||||||
- [ ] Components: `NodeMark.vue`, `UiIcon.vue` (copy from portal)
|
- [x] `NodeMark.vue` (copied unchanged from portal),
|
||||||
- [ ] Shared primitives ported from the design: `Card`, `Button`, `Table`,
|
`UiIcon.vue` (expanded set: 31 icons covering sidebar/topbar/sort/arrows)
|
||||||
`Badge`, `Mono`, `Eyebrow`, `StatusDot`, `Avatar`, `PageHeader`
|
- [x] Shared primitives: `Card`, `UiButton` (5 variants × 3 sizes),
|
||||||
- [ ] `OpSidebar.vue` — collapsible, badges per nav item
|
`DataTable`, `Badge` (7 tones), `Mono`, `Eyebrow`, `StatusDot`,
|
||||||
- [ ] `OpTopbar.vue` — env badge, ⌘K trigger, on-call pill, bell, avatar
|
`Avatar` (deterministic palette), `PageHeader`
|
||||||
- [ ] `app.vue` shell wires sidebar + topbar + `<NuxtPage />`
|
- [x] `OpSidebar.vue` — collapsible (232↔56px), 12 nav items in 4
|
||||||
- [ ] Keyboard shortcut: ⌘[ collapses sidebar, ⌘K opens palette
|
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)
|
### O.5 · Tenant management (real backend)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user