feat(operator): command palette, impersonation, incident, tweaks (O.8)
- CommandPalette + useCommandPalette: ⌘K opens a search-and-jump panel over real tenants/partners + fixture flags + nav + actions. Arrow keys + Enter navigate, Escape/backdrop close. Recents are intentionally omitted for now; add when there's something to recent over. - Impersonation stub: useImpersonation + ImpersonationModal + ImpersonationBanner. Modal opens from tenant detail and from the palette. Banner stays at the top of the shell until exited. No real OBO token is minted — wiring OAuth Token Exchange is tracked as a follow-up. - IncidentModal + useIncidentModal: opened from the Overview and Infrastructure incident banners, renders the mock INCIDENT data with metrics, timeline and draft composer. - TweaksPanel + useTweaks: floating bottom-right panel for theme (dark/light), density (comfy/compact), env badge (prod/staging/dev). Saved to localStorage. - Theme/density apply via [data-theme] + [data-density] overrides in tokens.css. Topbar env badge now reads from useTweaks instead of a prop. - Layout wires ⌘K + ⌘[ at the document level and mounts the palette + modals + banner + tweaks panel once for all pages.
This commit is contained in:
@@ -37,4 +37,47 @@
|
|||||||
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
|
||||||
|
|
||||||
--input-bg: rgba(244, 243, 238, 0.04);
|
--input-bg: rgba(244, 243, 238, 0.04);
|
||||||
|
|
||||||
|
/* Cosmetic density. comfy=1, compact≈0.78. Used by page chrome that opts in
|
||||||
|
(PageHeader / table cells). Reading via calc() keeps layouts in one place. */
|
||||||
|
--density-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tweaks: density overrides */
|
||||||
|
:root[data-density='compact'] { --density-scale: 0.78; }
|
||||||
|
|
||||||
|
/* Tweaks: light theme (warm cream, charcoal text). Overrides every surface
|
||||||
|
token so any component that uses var(--bg / --surface / --text / ...) flips
|
||||||
|
without code changes. Components that hard-code rgba(244,243,238,...) will
|
||||||
|
not flip — those should switch to tokens if they care about light mode. */
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--bg: #F6F4EF;
|
||||||
|
--surface: #FAF8F2;
|
||||||
|
--elevated: #FFFFFF;
|
||||||
|
--border: #E2DED2;
|
||||||
|
--border-hi: #D0CBBC;
|
||||||
|
|
||||||
|
--text: #1C1B17;
|
||||||
|
--text-dim: rgba(28, 27, 23, 0.72);
|
||||||
|
--text-mute: rgba(28, 27, 23, 0.50);
|
||||||
|
|
||||||
|
--side-bg: #F0EDE4;
|
||||||
|
--side-surf: #FAF8F2;
|
||||||
|
--side-border: #E2DED2;
|
||||||
|
--side-text: #1C1B17;
|
||||||
|
--side-dim: rgba(28, 27, 23, 0.62);
|
||||||
|
--side-mute: rgba(28, 27, 23, 0.42);
|
||||||
|
--side-hover: rgba(28, 27, 23, 0.05);
|
||||||
|
--side-active: rgba(28, 27, 23, 0.08);
|
||||||
|
|
||||||
|
--accent: #1F8A5B;
|
||||||
|
--accent-fg: #FAF8F2;
|
||||||
|
--signal: #1F8A5B;
|
||||||
|
|
||||||
|
--ok: #1F8A5B;
|
||||||
|
--warn: #C97F1F;
|
||||||
|
--bad: #C03A3A;
|
||||||
|
--info: #2A6FDB;
|
||||||
|
|
||||||
|
--input-bg: rgba(28, 27, 23, 0.04);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IconName } from './UiIcon.vue'
|
||||||
|
import type { Tenant } from '~/types/tenant'
|
||||||
|
import type { Partner } from '~/types/partner'
|
||||||
|
import { FLAGS, INCIDENT } from '~/data/fixtures'
|
||||||
|
|
||||||
|
// Each row in the palette. `action` decides what happens on Enter / click.
|
||||||
|
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
|
||||||
|
// the escape hatch for actions that aren't a route (open modal, toast, etc.).
|
||||||
|
interface Row {
|
||||||
|
id: string
|
||||||
|
groupLabel: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
icon?: IconName
|
||||||
|
badge?: string
|
||||||
|
to?: string
|
||||||
|
run?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isOpen, close } = useCommandPalette()
|
||||||
|
const { open: openImpersonation } = useImpersonation()
|
||||||
|
const { open: openIncident } = useIncidentModal()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const cursor = ref(0)
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// Only fetch when the palette has been opened at least once, then refresh on
|
||||||
|
// every open so newly-created tenants/partners show up. SSR is disabled (the
|
||||||
|
// palette is client-only).
|
||||||
|
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
|
||||||
|
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
|
||||||
|
|
||||||
|
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
|
||||||
|
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
|
||||||
|
{ id: 'n-tenants', label: 'Tenants', icon: 'building', to: '/tenants' },
|
||||||
|
{ id: 'n-partners', label: 'Partners', icon: 'briefcase', to: '/partners' },
|
||||||
|
{ id: 'n-users', label: 'Users (global)', icon: 'users', to: '/users' },
|
||||||
|
{ id: 'n-support', label: 'Support', icon: 'help', to: '/support' },
|
||||||
|
{ id: 'n-billing', label: 'Platform billing', icon: 'card', to: '/billing' },
|
||||||
|
{ id: 'n-reports', label: 'Reports', icon: 'database', to: '/reports' },
|
||||||
|
{ id: 'n-infra', label: 'Infrastructure', icon: 'plug', to: '/infrastructure' },
|
||||||
|
{ id: 'n-flags', label: 'Feature flags', icon: 'shield', to: '/flags' },
|
||||||
|
{ id: 'n-audit', label: 'Audit log', icon: 'file', to: '/audit' },
|
||||||
|
{ id: 'n-team', label: 'Operator team', icon: 'users', to: '/operator-team' },
|
||||||
|
{ id: 'n-settings', label: 'Platform settings',icon: 'shield', to: '/settings' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function tenantRows(): Row[] {
|
||||||
|
return (tenants.value ?? []).map((t) => ({
|
||||||
|
id: `t-${t._id}`,
|
||||||
|
groupLabel: 'Tenants',
|
||||||
|
title: t.name,
|
||||||
|
subtitle: `${t.domains?.[0] ?? t.slug} · ${t.plan} · ${t.status}`,
|
||||||
|
icon: 'building',
|
||||||
|
badge: t.status,
|
||||||
|
to: `/tenants/${t.slug}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function partnerRows(): Row[] {
|
||||||
|
return (partners.value ?? []).map((p) => ({
|
||||||
|
id: `p-${p._id}`,
|
||||||
|
groupLabel: 'Partners',
|
||||||
|
title: p.name,
|
||||||
|
subtitle: `${p.domain} · ${p.customers} customers · ${p.marginPct}% margin`,
|
||||||
|
icon: 'briefcase',
|
||||||
|
badge: p.status,
|
||||||
|
to: `/partners/${p.slug}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagRows(): Row[] {
|
||||||
|
return FLAGS.slice(0, 6).map((f) => ({
|
||||||
|
id: `f-${f.key}`,
|
||||||
|
groupLabel: 'Feature flags',
|
||||||
|
title: f.key,
|
||||||
|
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}% · ${f.scope}` : `${f.state} · ${f.scope}`,
|
||||||
|
icon: 'plug',
|
||||||
|
to: '/flags',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function navRows(): Row[] {
|
||||||
|
return NAV.map((n) => ({
|
||||||
|
id: n.id, groupLabel: 'Navigation', title: n.label, subtitle: 'navigate', icon: n.icon, to: n.to,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionRows(): Row[] {
|
||||||
|
const firstTenant = tenants.value?.[0]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'a-impersonate',
|
||||||
|
groupLabel: 'Actions',
|
||||||
|
title: 'Impersonate user…',
|
||||||
|
subtitle: firstTenant ? `enter ${firstTenant.name} as a user` : 'no tenants yet',
|
||||||
|
icon: 'key',
|
||||||
|
run: () => firstTenant && openImpersonation(firstTenant),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a-incident',
|
||||||
|
groupLabel: 'Actions',
|
||||||
|
title: 'Open active incident',
|
||||||
|
subtitle: INCIDENT.title,
|
||||||
|
icon: 'bell',
|
||||||
|
run: () => openIncident(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a-toggle-theme',
|
||||||
|
groupLabel: 'Actions',
|
||||||
|
title: 'Toggle theme',
|
||||||
|
subtitle: 'dark / light',
|
||||||
|
icon: 'shield',
|
||||||
|
run: () => {
|
||||||
|
const { state, setTheme } = useTweaks()
|
||||||
|
setTheme(state.value.theme === 'dark' ? 'light' : 'dark')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group { label: string; items: Row[] }
|
||||||
|
|
||||||
|
const groups = computed<Group[]>(() => {
|
||||||
|
const q = query.value.trim().toLowerCase()
|
||||||
|
const all: Row[] = [
|
||||||
|
...tenantRows(),
|
||||||
|
...partnerRows(),
|
||||||
|
...flagRows(),
|
||||||
|
...navRows(),
|
||||||
|
...actionRows(),
|
||||||
|
]
|
||||||
|
const matches = q
|
||||||
|
? all.filter((r) => r.title.toLowerCase().includes(q) || (r.subtitle ?? '').toLowerCase().includes(q))
|
||||||
|
: all
|
||||||
|
const grouped = new Map<string, Row[]>()
|
||||||
|
for (const r of matches) {
|
||||||
|
const arr = grouped.get(r.groupLabel) ?? []
|
||||||
|
arr.push(r)
|
||||||
|
grouped.set(r.groupLabel, arr)
|
||||||
|
}
|
||||||
|
// Stable group order
|
||||||
|
const order = ['Tenants', 'Partners', 'Feature flags', 'Navigation', 'Actions']
|
||||||
|
return order
|
||||||
|
.filter((g) => grouped.has(g))
|
||||||
|
.map((g) => ({ label: g, items: (grouped.get(g) ?? []).slice(0, 8) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const flat = computed(() => groups.value.flatMap((g) => g.items))
|
||||||
|
|
||||||
|
watch(isOpen, async (v) => {
|
||||||
|
if (!v) return
|
||||||
|
query.value = ''
|
||||||
|
cursor.value = 0
|
||||||
|
await Promise.all([rT(), rP()])
|
||||||
|
await nextTick()
|
||||||
|
inputRef.value?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
cursor.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function activate(row?: Row) {
|
||||||
|
if (!row) return
|
||||||
|
if (row.run) row.run()
|
||||||
|
if (row.to) navigateTo(row.to)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
cursor.value = Math.min(cursor.value + 1, Math.max(0, flat.value.length - 1))
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
cursor.value = Math.max(0, cursor.value - 1)
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
activate(flat.value[cursor.value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" class="palette-backdrop" @click="close">
|
||||||
|
<div class="palette" role="dialog" aria-label="Command palette" @click.stop>
|
||||||
|
<div class="input-row">
|
||||||
|
<UiIcon name="search" :size="14" />
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tenants, partners, actions…"
|
||||||
|
autocomplete="off"
|
||||||
|
@keydown="onKey"
|
||||||
|
/>
|
||||||
|
<span class="kbd">ESC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results">
|
||||||
|
<template v-if="groups.length">
|
||||||
|
<div v-for="g in groups" :key="g.label" class="group">
|
||||||
|
<div class="group-label">{{ g.label }}</div>
|
||||||
|
<button
|
||||||
|
v-for="item in g.items"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
:class="['row', { active: item.id === flat[cursor]?.id }]"
|
||||||
|
@mouseenter="cursor = flat.findIndex((x) => x.id === item.id)"
|
||||||
|
@click="activate(item)"
|
||||||
|
>
|
||||||
|
<span class="icon-tile">
|
||||||
|
<UiIcon v-if="item.icon" :name="item.icon" :size="13" />
|
||||||
|
</span>
|
||||||
|
<span class="row-body">
|
||||||
|
<span class="row-title">{{ item.title }}</span>
|
||||||
|
<span v-if="item.subtitle" class="row-sub">{{ item.subtitle }}</span>
|
||||||
|
</span>
|
||||||
|
<Mono v-if="item.badge" dim>{{ item.badge }}</Mono>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty">
|
||||||
|
<Mono dim>// no matches for "{{ query }}"</Mono>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<Mono dim>{{ flat.length }} {{ flat.length === 1 ? 'result' : 'results' }}</Mono>
|
||||||
|
<Mono dim>↑↓ navigate · ↵ open · esc close</Mono>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.palette-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 15vh 24px 24px 24px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
background: var(--elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
.input-row input {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.group { margin-bottom: 4px; }
|
||||||
|
.group-label {
|
||||||
|
padding: 10px 14px 4px 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.row.active {
|
||||||
|
background: rgba(212, 255, 58, 0.08);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
.icon-tile {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-mute);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row.active .icon-tile { color: var(--accent); }
|
||||||
|
|
||||||
|
.row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.row-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty { padding: 40px 18px; text-align: center; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Persistent red banner at the very top of the operator chrome. Renders
|
||||||
|
// whenever useImpersonation().active is set. Sits OUTSIDE the sidebar/topbar
|
||||||
|
// so it spans the full window width.
|
||||||
|
|
||||||
|
const { active, asUser, exit } = useImpersonation()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="active" class="banner" role="alert">
|
||||||
|
<span class="pill">
|
||||||
|
<span class="dot" />
|
||||||
|
Operator impersonation
|
||||||
|
</span>
|
||||||
|
<div class="body">
|
||||||
|
Viewing <strong>{{ active.name }}</strong> as <strong>{{ asUser }}</strong>.
|
||||||
|
Every action you take is logged with your operator identity.
|
||||||
|
</div>
|
||||||
|
<button class="log-note" type="button">Log note</button>
|
||||||
|
<button class="exit" type="button" @click="exit">
|
||||||
|
<UiIcon name="x" :size="12" />
|
||||||
|
Exit impersonation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.banner {
|
||||||
|
background: #9F1D1D;
|
||||||
|
color: #FEEEEE;
|
||||||
|
padding: 8px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: impersonationGlow 1.4s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 999px; background: #fff; }
|
||||||
|
.body { flex: 1; min-width: 0; font-size: 13px; }
|
||||||
|
.body strong { font-weight: 700; }
|
||||||
|
|
||||||
|
.log-note, .exit {
|
||||||
|
border: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.log-note {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #FEEEEE;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.exit {
|
||||||
|
background: #FEEEEE;
|
||||||
|
color: #9F1D1D;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes impersonationGlow {
|
||||||
|
from { box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.1); }
|
||||||
|
to { box-shadow: inset 0 -1px 0 rgba(255, 200, 200, 0.3); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { candidate, cancel, confirm } = useImpersonation()
|
||||||
|
const reason = ref('Customer reported issue — investigating')
|
||||||
|
const asUser = ref('first-user@example.com · Owner')
|
||||||
|
|
||||||
|
watch(candidate, (v) => {
|
||||||
|
if (v) {
|
||||||
|
reason.value = 'Customer reported issue — investigating'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!reason.value.trim()) return
|
||||||
|
confirm(reason.value.trim(), asUser.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="candidate" class="backdrop" @click="cancel">
|
||||||
|
<div class="modal" role="dialog" :aria-label="`Impersonate inside ${candidate.name}`" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Operator action · logged</Eyebrow>
|
||||||
|
<h2>Impersonate inside {{ candidate.name }}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="x" type="button" aria-label="Close" @click="cancel">
|
||||||
|
<UiIcon name="x" :size="12" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<div class="warn">
|
||||||
|
<UiIcon name="shield" :size="15" />
|
||||||
|
<p>
|
||||||
|
You'll see <strong>{{ candidate.name }}</strong>'s workspace exactly as one of their users sees it.
|
||||||
|
Any state-changing action will be flagged in their audit log as an operator action with your name
|
||||||
|
attached. <Mono dim>// stub — no real session is created</Mono>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Impersonate as</span>
|
||||||
|
<input v-model="asUser" type="text" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Reason (required)</span>
|
||||||
|
<input v-model="reason" type="text" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Mono dim class="note">// logged as op.impersonate · session expires after 30 min idle</Mono>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<UiButton variant="ghost" @click="cancel">Cancel</UiButton>
|
||||||
|
<UiButton variant="danger" :disabled="!reason.trim()" @click="onConfirm">
|
||||||
|
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||||
|
Enter impersonation mode
|
||||||
|
</UiButton>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 180;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
||||||
|
.x {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border: 0; border-radius: 6px; background: transparent;
|
||||||
|
color: var(--text-mute); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover { background: var(--surface); color: var(--text); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(226, 48, 48, 0.06);
|
||||||
|
border: 1px solid rgba(226, 48, 48, 0.24);
|
||||||
|
border-left: 3px solid var(--bad);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
.warn p { margin: 0; font-size: 12px; color: var(--text-dim); line-height: 1.55; }
|
||||||
|
.warn strong { color: var(--text); }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.field input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.field input:focus { border-color: var(--border-hi); }
|
||||||
|
|
||||||
|
.note { padding-top: 2px; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { INCIDENT } from '~/data/fixtures'
|
||||||
|
|
||||||
|
const { isOpen, close } = useIncidentModal()
|
||||||
|
const draft = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen.value) close()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" class="backdrop" @click="close">
|
||||||
|
<div class="modal" role="dialog" :aria-label="INCIDENT.title" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>{{ INCIDENT.id }} · {{ INCIDENT.severity }}</Eyebrow>
|
||||||
|
<h2>{{ INCIDENT.title }}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="x" type="button" aria-label="Close" @click="close">
|
||||||
|
<UiIcon name="x" :size="12" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<div class="metrics">
|
||||||
|
<MetricCell label="status" :value="INCIDENT.state" tone="warn" />
|
||||||
|
<MetricCell label="started" :value="INCIDENT.started" />
|
||||||
|
<MetricCell label="duration" :value="INCIDENT.duration" />
|
||||||
|
<MetricCell label="affected" value="12 tenants" />
|
||||||
|
<MetricCell label="incident lead" :value="INCIDENT.ic" />
|
||||||
|
<MetricCell label="severity" :value="INCIDENT.severity" tone="bad" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Eyebrow>Updates</Eyebrow>
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="(u, i) in INCIDENT.updates" :key="i" class="upd">
|
||||||
|
<span class="bullet" :class="{ first: i === 0 }" />
|
||||||
|
<div>
|
||||||
|
<div class="upd-head">
|
||||||
|
<Mono>{{ u.t }}</Mono>
|
||||||
|
<span class="who">{{ u.who }}</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ u.msg }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compose">
|
||||||
|
<input v-model="draft" type="text" placeholder="Add update… (markdown supported)" />
|
||||||
|
<div class="compose-foot">
|
||||||
|
<Mono dim>// will notify on-call + post to #incidents</Mono>
|
||||||
|
<UiButton variant="primary" :disabled="!draft.trim()">Post update</UiButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<UiButton variant="ghost" @click="close">Close</UiButton>
|
||||||
|
<UiButton variant="secondary">
|
||||||
|
<template #leading><UiIcon name="bell" :size="13" /></template>
|
||||||
|
Notify customers
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="primary">Mark resolved</UiButton>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 180;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
max-height: 86vh;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||||
|
.x {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border: 0; border-radius: 6px; background: transparent;
|
||||||
|
color: var(--text-mute); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover { background: var(--surface); color: var(--text); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||||
|
|
||||||
|
.timeline { display: flex; flex-direction: column; gap: 14px; padding-left: 14px; border-left: 1px solid var(--border); margin-left: 6px; }
|
||||||
|
.upd { position: relative; }
|
||||||
|
.bullet {
|
||||||
|
position: absolute;
|
||||||
|
left: -19px; top: 5px;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--text-mute);
|
||||||
|
}
|
||||||
|
.bullet.first { background: var(--accent); }
|
||||||
|
.upd-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.who { font-size: 12px; font-weight: 500; }
|
||||||
|
.upd p { margin: 4px 0 0 0; font-size: 13px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.compose {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.compose input {
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.compose-foot { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
type Env = 'prod' | 'staging' | 'dev'
|
withDefaults(defineProps<{ oncall?: boolean }>(), { oncall: true })
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{ env?: Env; oncall?: boolean }>(),
|
|
||||||
{ env: 'prod', oncall: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const { user } = useOidcAuth()
|
const { user } = useOidcAuth()
|
||||||
|
const { state: tweaks } = useTweaks()
|
||||||
|
const { open: openPalette } = useCommandPalette()
|
||||||
|
|
||||||
const ENVS = {
|
const ENVS = {
|
||||||
prod: { label: 'PROD', fg: 'var(--text)', bg: 'rgba(244,243,238,0.08)', border: 'rgba(244,243,238,0.15)' },
|
prod: { label: 'PROD', fg: 'var(--text)', bg: 'rgba(244,243,238,0.08)', border: 'rgba(244,243,238,0.15)' },
|
||||||
@@ -14,11 +11,7 @@ const ENVS = {
|
|||||||
dev: { label: 'DEV', fg: '#D4AAFF', bg: 'rgba(159,98,212,0.18)', border: 'rgba(159,98,212,0.36)' },
|
dev: { label: 'DEV', fg: '#D4AAFF', bg: 'rgba(159,98,212,0.18)', border: 'rgba(159,98,212,0.36)' },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function openPalette() {
|
const env = computed(() => tweaks.value.env)
|
||||||
// ⌘K palette lands in O.8 — this is the trigger surface.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[palette] open requested')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Floating cosmetic-tweaks panel. Lives in the bottom-right corner. Lets the
|
||||||
|
// operator flip theme/density/env without touching settings pages. All three
|
||||||
|
// are pure-cosmetic — env in particular is just a colored chip in the topbar,
|
||||||
|
// not a real environment switch.
|
||||||
|
|
||||||
|
const { state, setTheme, setDensity, setEnv } = useTweaks()
|
||||||
|
const open = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tweaks-root">
|
||||||
|
<button class="trigger" :class="{ on: open }" type="button" :title="open ? 'Close tweaks' : 'Open tweaks'" @click="open = !open">
|
||||||
|
<UiIcon :name="open ? 'x' : 'shield'" :size="14" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="tweaks">
|
||||||
|
<div v-if="open" class="panel" role="dialog" aria-label="Cosmetic tweaks">
|
||||||
|
<header>
|
||||||
|
<Eyebrow>Tweaks</Eyebrow>
|
||||||
|
<button class="x" type="button" aria-label="Close" @click="open = false">
|
||||||
|
<UiIcon name="x" :size="11" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="row-label">Theme</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button :class="{ on: state.theme === 'dark' }" type="button" @click="setTheme('dark')">Dark</button>
|
||||||
|
<button :class="{ on: state.theme === 'light' }" type="button" @click="setTheme('light')">Light</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="row-label">Density</label>
|
||||||
|
<div class="seg">
|
||||||
|
<button :class="{ on: state.density === 'comfy' }" type="button" @click="setDensity('comfy')">Comfy</button>
|
||||||
|
<button :class="{ on: state.density === 'compact' }" type="button" @click="setDensity('compact')">Compact</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="row-label">Env badge</label>
|
||||||
|
<div class="seg three">
|
||||||
|
<button :class="{ on: state.env === 'prod' }" type="button" @click="setEnv('prod')">PROD</button>
|
||||||
|
<button :class="{ on: state.env === 'staging' }" type="button" @click="setEnv('staging')">STAGING</button>
|
||||||
|
<button :class="{ on: state.env === 'dev' }" type="button" @click="setEnv('dev')">DEV</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<Mono dim>// cosmetic only — saved to localStorage</Mono>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tweaks-root {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.trigger:hover { color: var(--text); border-color: var(--border-hi); }
|
||||||
|
.trigger.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 44px;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.x {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-mute);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover { background: var(--surface); color: var(--text); }
|
||||||
|
|
||||||
|
section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.row-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.seg.three { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
|
.seg button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg button:hover { color: var(--text); }
|
||||||
|
.seg button.on { background: var(--text); color: var(--bg); }
|
||||||
|
|
||||||
|
footer { padding-top: 4px; border-top: 1px dashed var(--border); }
|
||||||
|
|
||||||
|
.tweaks-enter-active, .tweaks-leave-active { transition: opacity 0.12s, transform 0.12s; }
|
||||||
|
.tweaks-enter-from, .tweaks-leave-to { opacity: 0; transform: translateY(4px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Shared open/close state for the ⌘K command palette. The trigger lives on
|
||||||
|
// the topbar and on the global keyboard shortcut handler, while the rendered
|
||||||
|
// panel lives in the default layout — they all read/write the same ref via
|
||||||
|
// this composable.
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
export const useCommandPalette = () => ({
|
||||||
|
isOpen,
|
||||||
|
open: () => {
|
||||||
|
isOpen.value = true
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
isOpen.value = false
|
||||||
|
},
|
||||||
|
toggle: () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Visual-only impersonation state. Real impersonation requires an OAuth
|
||||||
|
// on-behalf-of grant or admin-impersonation token endpoint; this composable
|
||||||
|
// just tracks whether the modal is open + which tenant we're "viewing" so the
|
||||||
|
// red banner and topbar warning surface render correctly. Tracked as a
|
||||||
|
// follow-up in OPERATOR-PLAN.md.
|
||||||
|
|
||||||
|
import type { Tenant } from '~/types/tenant'
|
||||||
|
|
||||||
|
const candidate = ref<Tenant | null>(null) // shown in the confirm modal
|
||||||
|
const active = ref<Tenant | null>(null) // shown in the persistent banner
|
||||||
|
const asUser = ref<string>('first-user@example.com')
|
||||||
|
|
||||||
|
export const useImpersonation = () => ({
|
||||||
|
candidate,
|
||||||
|
active,
|
||||||
|
asUser,
|
||||||
|
open: (tenant: Tenant) => {
|
||||||
|
candidate.value = tenant
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
candidate.value = null
|
||||||
|
},
|
||||||
|
confirm: (reason: string, userLabel?: string) => {
|
||||||
|
if (!candidate.value) return
|
||||||
|
active.value = candidate.value
|
||||||
|
asUser.value = userLabel ?? asUser.value
|
||||||
|
candidate.value = null
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[impersonation] entered', { tenant: active.value.slug, reason, asUser: asUser.value })
|
||||||
|
},
|
||||||
|
exit: () => {
|
||||||
|
active.value = null
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
export const useIncidentModal = () => ({
|
||||||
|
isOpen,
|
||||||
|
open: () => {
|
||||||
|
isOpen.value = true
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
isOpen.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Cosmetic tweaks for the operator shell — theme (dark/light), density
|
||||||
|
// (comfy/compact), env badge (prod/staging/dev). Persisted in localStorage so
|
||||||
|
// the choices survive page reloads. The values are applied to <html> as
|
||||||
|
// data-* attributes; tokens.css picks them up via selector overrides.
|
||||||
|
|
||||||
|
export type ThemeMode = 'dark' | 'light'
|
||||||
|
export type Density = 'comfy' | 'compact'
|
||||||
|
export type Env = 'prod' | 'staging' | 'dev'
|
||||||
|
|
||||||
|
interface TweakState {
|
||||||
|
theme: ThemeMode
|
||||||
|
density: Density
|
||||||
|
env: Env
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'dezky-operator-tweaks'
|
||||||
|
|
||||||
|
const DEFAULTS: TweakState = { theme: 'dark', density: 'comfy', env: 'dev' }
|
||||||
|
|
||||||
|
const state = ref<TweakState>({ ...DEFAULTS })
|
||||||
|
const hydrated = ref(false)
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const root = document.documentElement
|
||||||
|
root.setAttribute('data-theme', state.value.theme)
|
||||||
|
root.setAttribute('data-density', state.value.density)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.value))
|
||||||
|
} catch {
|
||||||
|
// localStorage can throw in private mode; tweaks are cosmetic so swallow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrate() {
|
||||||
|
if (!import.meta.client || hydrated.value) return
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<TweakState>
|
||||||
|
state.value = { ...DEFAULTS, ...parsed }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupt JSON
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
hydrated.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTweaks = () => {
|
||||||
|
if (import.meta.client) hydrate()
|
||||||
|
|
||||||
|
function set<K extends keyof TweakState>(key: K, value: TweakState[K]) {
|
||||||
|
state.value = { ...state.value, [key]: value }
|
||||||
|
apply()
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
setTheme: (v: ThemeMode) => set('theme', v),
|
||||||
|
setDensity: (v: Density) => set('density', v),
|
||||||
|
setEnv: (v: Env) => set('env', v),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { toggle } = useSidebar()
|
const { toggle } = useSidebar()
|
||||||
|
const { toggle: togglePalette } = useCommandPalette()
|
||||||
|
// Touch useTweaks so the persisted theme/density hydrate on first paint.
|
||||||
|
useTweaks()
|
||||||
|
|
||||||
// Derive the active nav row from the route. Matches OpSidebar's `id` keys.
|
// Derive the active nav row from the route. Matches OpSidebar's `id` keys.
|
||||||
const currentNav = computed(() => {
|
const currentNav = computed(() => {
|
||||||
@@ -24,13 +27,17 @@ const currentNav = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Keyboard shortcut: ⌘[ toggles sidebar. ⌘K palette lands in O.8.
|
// Keyboard shortcuts: ⌘[ toggles sidebar, ⌘K opens the command palette.
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === '[') {
|
if ((e.metaKey || e.ctrlKey) && e.key === '[') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggle()
|
toggle()
|
||||||
}
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
togglePalette()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
@@ -39,23 +46,32 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<OpSidebar :current="currentNav" />
|
<ImpersonationBanner />
|
||||||
<main>
|
<div class="cols">
|
||||||
<OpTopbar />
|
<OpSidebar :current="currentNav" />
|
||||||
<div class="content">
|
<main>
|
||||||
<slot />
|
<OpTopbar />
|
||||||
</div>
|
<div class="content">
|
||||||
</main>
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<CommandPalette />
|
||||||
|
<ImpersonationModal />
|
||||||
|
<IncidentModal />
|
||||||
|
<TweaksPanel />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
.cols { display: flex; flex: 1; min-height: 0; }
|
||||||
|
|
||||||
main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
.content { flex: 1; min-width: 0; overflow-y: auto; }
|
.content { flex: 1; min-width: 0; overflow-y: auto; }
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/a
|
|||||||
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
|
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
|
||||||
const { data: users, pending: up, refresh: rU } = await useFetch<PlatformUser[]>('/api/users', { default: () => [] })
|
const { data: users, pending: up, refresh: rU } = await useFetch<PlatformUser[]>('/api/users', { default: () => [] })
|
||||||
|
|
||||||
|
const { open: openIncident } = useIncidentModal()
|
||||||
|
|
||||||
const pending = computed(() => tp.value || pp.value || up.value)
|
const pending = computed(() => tp.value || pp.value || up.value)
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@@ -63,7 +65,7 @@ function fmtDate(d: string) {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<button v-if="incidentActive" class="incident" type="button">
|
<button v-if="incidentActive" class="incident" type="button" @click="openIncident">
|
||||||
<span class="pill">
|
<span class="pill">
|
||||||
<span class="dot" />
|
<span class="dot" />
|
||||||
{{ INCIDENT.severity }} · ACTIVE
|
{{ INCIDENT.severity }} · ACTIVE
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SERVICES, INCIDENT, type PlatformService } from '~/data/fixtures'
|
|||||||
|
|
||||||
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
|
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
|
||||||
const incidentActive = computed(() => degradedCount.value > 0)
|
const incidentActive = computed(() => degradedCount.value > 0)
|
||||||
|
const { open: openIncident } = useIncidentModal()
|
||||||
|
|
||||||
function tone(s: PlatformService): 'ok' | 'warn' | 'bad' {
|
function tone(s: PlatformService): 'ok' | 'warn' | 'bad' {
|
||||||
return s.status
|
return s.status
|
||||||
@@ -41,7 +42,7 @@ function label(s: PlatformService) {
|
|||||||
<div class="title">{{ INCIDENT.title }}</div>
|
<div class="title">{{ INCIDENT.title }}</div>
|
||||||
<div class="sub">Started {{ INCIDENT.started }} · IC: {{ INCIDENT.ic }}</div>
|
<div class="sub">Started {{ INCIDENT.started }} · IC: {{ INCIDENT.ic }}</div>
|
||||||
</div>
|
</div>
|
||||||
<UiButton variant="primary" disabled>Open incident</UiButton>
|
<UiButton variant="primary" @click="openIncident">Open incident</UiButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const { data: tenant, refresh: refreshTenant } = await useFetch<Tenant>(() => `/
|
|||||||
|
|
||||||
const activeTab = ref<'overview' | 'users' | 'resources' | 'billing' | 'audit' | 'support' | 'danger'>('overview')
|
const activeTab = ref<'overview' | 'users' | 'resources' | 'billing' | 'audit' | 'support' | 'danger'>('overview')
|
||||||
|
|
||||||
|
const impersonate = useImpersonation()
|
||||||
|
|
||||||
// Lazy-fetch users only when the tab is opened.
|
// Lazy-fetch users only when the tab is opened.
|
||||||
const { data: users, refresh: refreshUsers } = useLazyFetch<TenantUser[]>(
|
const { data: users, refresh: refreshUsers } = useLazyFetch<TenantUser[]>(
|
||||||
() => `/api/tenants/${slug.value}/users`,
|
() => `/api/tenants/${slug.value}/users`,
|
||||||
@@ -97,6 +99,10 @@ async function reconcile() {
|
|||||||
<template #actions>
|
<template #actions>
|
||||||
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
|
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
|
||||||
<Badge tone="neutral">{{ tenant.plan }}</Badge>
|
<Badge tone="neutral">{{ tenant.plan }}</Badge>
|
||||||
|
<UiButton variant="secondary" @click="impersonate.open(tenant)">
|
||||||
|
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||||
|
Impersonate
|
||||||
|
</UiButton>
|
||||||
<UiButton variant="secondary">
|
<UiButton variant="secondary">
|
||||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||||
Open workspace
|
Open workspace
|
||||||
|
|||||||
+18
-9
@@ -454,16 +454,25 @@ forward as bearer to platform-api.
|
|||||||
`components/OpPlaceholder.vue`, `server/api/users/index.get.ts`,
|
`components/OpPlaceholder.vue`, `server/api/users/index.get.ts`,
|
||||||
`types/user.ts`.
|
`types/user.ts`.
|
||||||
|
|
||||||
### O.8 · Interactions
|
### O.8 · Interactions ✓
|
||||||
|
|
||||||
- [ ] `CommandPalette.vue` — ⌘K opens, fuzzy search over tenants + partners
|
- [x] `CommandPalette.vue` + `useCommandPalette` — ⌘K opens, searches real
|
||||||
+ flags + nav items + actions
|
tenants/partners + mock flags + nav items + actions. Arrow keys + Enter
|
||||||
- [ ] `ImpersonationModal.vue` — visual stub with reason field, Demo-only
|
navigate, Escape/backdrop close.
|
||||||
badge, no-op confirm + toast
|
- [x] `ImpersonationModal.vue` + `useImpersonation` — confirm modal with
|
||||||
- [ ] `ImpersonationBanner.vue` — top banner shown when impersonating
|
reason field, opens from tenant detail (`Impersonate` action) and from
|
||||||
- [ ] `IncidentModal.vue` — mock incident render
|
the palette (`Impersonate user…` action). Stub — no real OBO token is
|
||||||
- [ ] `TweaksPanel.vue` — theme (light/dark), density (comfy/compact),
|
minted. Follow-up to wire OAuth Token Exchange remains.
|
||||||
env (prod/staging/dev cosmetic switch)
|
- [x] `ImpersonationBanner.vue` — full-width red banner at the top of the
|
||||||
|
shell, persists until `Exit impersonation` is clicked.
|
||||||
|
- [x] `IncidentModal.vue` + `useIncidentModal` — opens from the Overview and
|
||||||
|
Infrastructure incident banners, renders mock `INCIDENT` data
|
||||||
|
(metrics + timeline + draft composer).
|
||||||
|
- [x] `TweaksPanel.vue` + `useTweaks` — floating bottom-right panel. Theme
|
||||||
|
(dark/light), density (comfy/compact), env badge (prod/staging/dev).
|
||||||
|
Choices persist to localStorage, apply via `[data-theme]` / `[data-density]`
|
||||||
|
overrides in tokens.css.
|
||||||
|
- [x] Layout wires ⌘K + ⌘[ globally. Topbar reads env from `useTweaks`.
|
||||||
|
|
||||||
### O.9 · Verification
|
### O.9 · Verification
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user