0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
401 lines
13 KiB
Vue
401 lines
13 KiB
Vue
<script setup lang="ts">
|
|
// Strict port of project/platform-screens.jsx `SecurityScreen` (lines 2187-2310)
|
|
// and RadioBig (line 2311). Two tabs: Security · Audit log. Same cards, same
|
|
// copy, same SSO apps, same audit-log column structure with sample rows.
|
|
|
|
|
|
import { sampleAudit } from '~/data/workspace'
|
|
|
|
const tab = ref<'security' | 'audit'>('security')
|
|
const mfa = ref<'all' | 'admins' | 'optional'>('admins')
|
|
|
|
const toast = useToast()
|
|
const addCountryOpen = ref(false)
|
|
const newAllowCountry = ref('')
|
|
|
|
function ssoAction(name: string, id: string) {
|
|
if (id === 'configure') toast.info(`Configure ${name}`)
|
|
else if (id === 'test') toast.info(`Sending test sign-in to ${name}`)
|
|
else if (id === 'rotate') toast.info(`Rotating certificate for ${name}`)
|
|
else if (id === 'disconnect') toast.warn(`${name} disconnected`)
|
|
}
|
|
const ssoItems = [
|
|
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
|
|
{ id: 'test', label: 'Send test sign-in', icon: 'key' as const },
|
|
{ id: 'rotate', label: 'Rotate certificate', icon: 'refresh' as const },
|
|
{ id: 'sep1', separator: true },
|
|
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
|
|
]
|
|
|
|
function removeCountry(c: string) {
|
|
toast.info(`${c} removed from allow-list`)
|
|
}
|
|
|
|
const ssoApps = [
|
|
{ n: 'Notion', p: 'SAML', s: 'ok' as const },
|
|
{ n: 'Figma', p: 'SAML', s: 'ok' as const },
|
|
{ n: 'Linear', p: 'OIDC', s: 'ok' as const },
|
|
{ n: 'GitHub', p: 'OIDC', s: 'warn' as const },
|
|
]
|
|
|
|
const mfaOptions = [
|
|
{ v: 'all' as const, label: 'Required for everyone', d: 'All members must enroll TOTP or WebAuthn at next sign-in.' },
|
|
{ v: 'admins' as const, label: 'Required for admins only', d: 'Members may opt in. Admins are forced to enroll.' },
|
|
{ v: 'optional' as const, label: 'Optional', d: 'No enforcement. Not recommended for compliance work.' },
|
|
]
|
|
|
|
const countries = ['Denmark', 'Sweden', 'Norway', 'Germany', 'Netherlands']
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Compliance"
|
|
title="Security & audit"
|
|
subtitle="Policies, identity controls, and a tamper-evident log of every administrative action."
|
|
/>
|
|
|
|
<div class="tab-wrap">
|
|
<Tabs
|
|
v-model="tab"
|
|
:items="[
|
|
{ value: 'security', label: 'Security' },
|
|
{ value: 'audit', label: 'Audit log', count: 4218 },
|
|
]"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="tab === 'security'" class="content security">
|
|
<Card>
|
|
<div class="card-head">
|
|
<Eyebrow>Identity</Eyebrow>
|
|
<div class="card-title">Multi-factor authentication</div>
|
|
</div>
|
|
<div class="radio-big">
|
|
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: mfa === o.v }">
|
|
<span class="radio-dot"><span v-if="mfa === o.v" /></span>
|
|
<input type="radio" :value="o.v" v-model="mfa" />
|
|
<div>
|
|
<div class="radio-label">{{ o.label }}</div>
|
|
<div class="radio-d">{{ o.d }}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div class="card-head">
|
|
<Eyebrow>Sessions</Eyebrow>
|
|
<div class="card-title">Session policy</div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<label class="field"><Eyebrow>Idle timeout</Eyebrow>
|
|
<div class="input-faux">
|
|
<input value="30 minutes" />
|
|
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
|
|
</div>
|
|
</label>
|
|
<label class="field"><Eyebrow>Absolute timeout</Eyebrow>
|
|
<div class="input-faux">
|
|
<input value="24 hours" />
|
|
<UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" />
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div class="card-head">
|
|
<Eyebrow>Network</Eyebrow>
|
|
<div class="card-title">Geo-fencing & allow-lists</div>
|
|
</div>
|
|
<div class="field">
|
|
<Eyebrow>Allowed countries</Eyebrow>
|
|
<div class="chip-row">
|
|
<Badge v-for="c in countries" :key="c" tone="neutral">
|
|
{{ c }}
|
|
<button class="badge-x" @click="removeCountry(c)" aria-label="Remove country">
|
|
<UiIcon name="x" :size="10" />
|
|
</button>
|
|
</Badge>
|
|
<UiButton size="sm" variant="ghost" @click="addCountryOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="12" /></template>
|
|
Add country
|
|
</UiButton>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div class="card-head">
|
|
<Eyebrow>SSO</Eyebrow>
|
|
<div class="card-title">dezky as identity provider</div>
|
|
</div>
|
|
<div class="sso-intro">
|
|
Connect external applications via OIDC or SAML. dezky's Authentik instance is the source of truth for identity.
|
|
</div>
|
|
<div class="sso-list">
|
|
<div v-for="a in ssoApps" :key="a.n" class="sso-row">
|
|
<div class="sso-icon">{{ a.n[0] }}</div>
|
|
<div class="sso-meta">
|
|
<div class="sso-name">{{ a.n }}</div>
|
|
<Mono dim>{{ a.p }} · provisioned</Mono>
|
|
</div>
|
|
<Badge :tone="a.s" dot>{{ a.s === 'ok' ? 'connected' : 'cert expiring' }}</Badge>
|
|
<AdminKebabMenu :items="ssoItems" @select="(id) => ssoAction(a.n, id)" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div v-else class="content audit">
|
|
<div class="toolbar">
|
|
<div class="input-search">
|
|
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
|
<input placeholder="action.type, actor, target…" />
|
|
</div>
|
|
<button class="chip"><Eyebrow>Actor:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
|
<button class="chip"><Eyebrow>Action:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
|
<button class="chip"><Eyebrow>Last:</Eyebrow> <span>7 days</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
|
<div class="spacer" />
|
|
<UiButton variant="secondary" @click="toast.info('Exporting audit log…', 'CSV · last 7 days · ~4,218 events')">
|
|
<template #leading><UiIcon name="download" :size="14" /></template>
|
|
Export CSV
|
|
</UiButton>
|
|
</div>
|
|
<Card :pad="0">
|
|
<table class="audit-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Actor</th>
|
|
<th>Action</th>
|
|
<th>Target</th>
|
|
<th>IP</th>
|
|
<th class="right" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="a in sampleAudit" :key="a.id">
|
|
<td><Mono>{{ a.when }}</Mono></td>
|
|
<td>
|
|
<div class="actor-cell">
|
|
<Avatar v-if="a.actor !== 'system'" :name="a.actor" :size="22" />
|
|
<div v-else class="sys">sys</div>
|
|
<span>{{ a.actor }}</span>
|
|
</div>
|
|
</td>
|
|
<td><Mono>{{ a.action }}</Mono></td>
|
|
<td class="target">{{ a.target }}</td>
|
|
<td><Mono dim>{{ a.ip }}</Mono></td>
|
|
<td class="right"><Badge :tone="a.tone" dot>{{ a.tone }}</Badge></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
<div class="retention">
|
|
<Mono dim>// retention · 365 days · tamper-evident · last verified 14:32:01 today</Mono>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add country modal -->
|
|
<Modal :open="addCountryOpen" eyebrow="Security · geo-fencing" title="Add country to allow-list" size="sm" @close="addCountryOpen = false">
|
|
<div class="form-stack">
|
|
<label class="field"><Eyebrow>Country</Eyebrow>
|
|
<CountrySelect v-model="newAllowCountry" placeholder="Search countries" />
|
|
</label>
|
|
</div>
|
|
<template #footer>
|
|
<UiButton variant="ghost" @click="addCountryOpen = false">Cancel</UiButton>
|
|
<UiButton
|
|
variant="primary"
|
|
:disabled="!newAllowCountry"
|
|
@click="addCountryOpen = false; toast.ok(`Country ${newAllowCountry} added`); newAllowCountry = ''"
|
|
>
|
|
Add
|
|
</UiButton>
|
|
</template>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tab-wrap { padding: 16px 40px 0 40px; }
|
|
.content { padding: 24px 40px 64px 40px; }
|
|
.content.security { display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
|
|
|
|
.card-head { margin-bottom: 16px; }
|
|
.card-title {
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
letter-spacing: -0.01em;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* RadioBig */
|
|
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
|
.radio-big label {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 14px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
|
.radio-big input { display: none; }
|
|
.radio-dot {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 999px;
|
|
border: 2px solid var(--border-hi, var(--border));
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
margin-top: 2px;
|
|
}
|
|
.radio-big label.active .radio-dot { border-color: var(--text); }
|
|
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
|
.radio-label { font-size: 14px; font-weight: 500; }
|
|
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
|
|
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
.input-faux {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 0 12px;
|
|
height: 36px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
.input-faux input {
|
|
flex: 1;
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
|
|
.sso-intro { font-size: 13px; color: var(--text-dim); margin-bottom: 12px; line-height: 1.5; }
|
|
.sso-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.sso-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
background: var(--bg);
|
|
border-radius: 6px;
|
|
}
|
|
.sso-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 5px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: var(--font-mono);
|
|
font-weight: 700;
|
|
font-size: 12px;
|
|
}
|
|
.sso-meta { flex: 1; }
|
|
.sso-name { font-size: 13px; font-weight: 500; }
|
|
|
|
/* Audit toolbar + table */
|
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
|
|
.input-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 0 12px;
|
|
height: 36px;
|
|
width: 360px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
}
|
|
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
height: 36px;
|
|
padding: 0 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-family: inherit;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
.chip span { font-weight: 500; }
|
|
.spacer { flex: 1; }
|
|
|
|
.audit-table { width: 100%; border-collapse: collapse; }
|
|
.audit-table thead th {
|
|
text-align: left;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-mute);
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-weight: 500;
|
|
}
|
|
.audit-table tbody td {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 13px;
|
|
vertical-align: middle;
|
|
}
|
|
.audit-table tbody tr:last-child td { border-bottom: none; }
|
|
.audit-table .right { text-align: right; }
|
|
.target { color: var(--text-dim); }
|
|
.actor-cell { display: flex; align-items: center; gap: 8px; }
|
|
.sys {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 5px;
|
|
background: var(--text);
|
|
color: var(--bg);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.retention { margin-top: 12px; font-size: 12px; color: var(--text-mute); }
|
|
|
|
.badge-x {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0;
|
|
margin-left: 4px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
color: inherit;
|
|
}
|
|
.badge-x:hover { color: var(--bad); }
|
|
|
|
/* Add country modal */
|
|
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
|
|
.input:focus { border-color: var(--text); }
|
|
</style>
|