559348f6bc
Security & audit (admin) - Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with q/action/outcome/actorEmail/since/before; UI gains search, outcome + time filters, action chips, cursor pagination, and client-side CSV export. - Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute, allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy (membership-gated, audited). Editable, labelled by enforcement status. - MFA: live enrollment overview via GET /tenants/:slug/mfa-status (Authentik countAuthenticators per member). - SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD, scoped to the tenant group. New AuthentikClient methods (provider/app/binding + flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback on partial failure; client secret never stored), GET/POST/DELETE /tenants/:slug/sso-apps. Validated end-to-end against live Authentik. - Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast radius) — to be done as its own reviewed change. Bundled in-progress work that shares the same files (kept together so the tree stays green): - Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed), storage.get proxy, storage.vue. - Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
817 lines
30 KiB
Vue
817 lines
30 KiB
Vue
<script setup lang="ts">
|
|
// Security & audit. Audit log: real (filterable, paginated, CSV export).
|
|
// Security tab: MFA-enrollment overview is a live read; the policy (MFA mode,
|
|
// session timeouts, allowed countries, IP allow-list) is saved as real intent —
|
|
// enforcement via Authentik is wired in a later stage, so each control is
|
|
// labelled with its enforcement status. SSO apps are still coming soon.
|
|
|
|
import type { AuditEventDoc, MfaStatus, SsoApp, SsoAppCreated } from '~/types/workspace'
|
|
|
|
const tab = ref<'security' | 'audit'>('security')
|
|
|
|
const toast = useToast()
|
|
const addCountryOpen = ref(false)
|
|
const newAllowCountry = ref('')
|
|
|
|
// ── Audit log (real) ─────────────────────────────────────────────────────
|
|
const { fetchMe } = useMe()
|
|
await fetchMe()
|
|
const { tenant } = useTenant()
|
|
const slug = computed(() => tenant.value?.slug ?? '')
|
|
|
|
const search = ref('')
|
|
const actionFilter = ref('') // action prefix, e.g. 'billing'
|
|
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
|
|
const sinceFilter = ref<'' | '1d' | '7d' | '30d'>('')
|
|
|
|
const QUICK_ACTIONS = [
|
|
{ label: 'All', value: '' },
|
|
{ label: 'Users', value: 'user' },
|
|
{ label: 'Billing', value: 'billing' },
|
|
{ label: 'Branding', value: 'tenant.branding' },
|
|
{ label: 'Auth', value: 'authentik' },
|
|
{ label: 'Tenant', value: 'tenant' },
|
|
]
|
|
|
|
function sinceIso(v: string): string | undefined {
|
|
if (!v) return undefined
|
|
const days = v === '1d' ? 1 : v === '7d' ? 7 : 30
|
|
return new Date(Date.now() - days * 86_400_000).toISOString()
|
|
}
|
|
|
|
const params = computed<Record<string, string>>(() => {
|
|
const p: Record<string, string> = { limit: '200' }
|
|
if (search.value.trim()) p.q = search.value.trim()
|
|
if (actionFilter.value) p.action = actionFilter.value
|
|
if (outcomeFilter.value) p.outcome = outcomeFilter.value
|
|
const since = sinceIso(sinceFilter.value)
|
|
if (since) p.since = since
|
|
return p
|
|
})
|
|
|
|
const { data: auditPage } = await useFetch<AuditEventDoc[]>(
|
|
() => `/api/tenants/${slug.value}/audit`,
|
|
{ key: 'admin-audit', default: () => [], query: params, immediate: !!slug.value, watch: [slug] },
|
|
)
|
|
|
|
const olderPages = ref<AuditEventDoc[]>([])
|
|
const reachedEnd = ref(false)
|
|
const loadingMore = ref(false)
|
|
const auditRows = computed(() => [...(auditPage.value ?? []), ...olderPages.value])
|
|
|
|
// New filter set → drop accumulated older pages.
|
|
watch(params, () => {
|
|
olderPages.value = []
|
|
reachedEnd.value = false
|
|
})
|
|
|
|
async function loadMore() {
|
|
const last = auditRows.value[auditRows.value.length - 1]
|
|
if (!last || loadingMore.value) return
|
|
loadingMore.value = true
|
|
try {
|
|
const next = await $fetch<AuditEventDoc[]>(`/api/tenants/${slug.value}/audit`, {
|
|
query: { ...params.value, before: last.at },
|
|
})
|
|
if (!next.length) reachedEnd.value = true
|
|
else olderPages.value.push(...next)
|
|
} finally {
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
|
|
function fmtTime(iso: string): string {
|
|
return new Date(iso).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
|
|
}
|
|
function actorName(e: AuditEventDoc): string {
|
|
return e.actorType === 'system' ? 'system' : e.actorEmail ?? '—'
|
|
}
|
|
function targetOf(e: AuditEventDoc): string {
|
|
return e.resourceName ?? e.resourceId ?? ''
|
|
}
|
|
function auditTone(e: AuditEventDoc): 'info' | 'warn' | 'bad' {
|
|
if (e.outcome === 'failure') return 'bad'
|
|
if (/suspend|delete|terminat|revok|disable/.test(e.action)) return 'warn'
|
|
return 'info'
|
|
}
|
|
|
|
function exportCsv() {
|
|
const rows = auditRows.value
|
|
if (!rows.length) {
|
|
toast.info('Nothing to export', 'No events match the current filters')
|
|
return
|
|
}
|
|
const esc = (s: string) => `"${String(s ?? '').replace(/"/g, '""')}"`
|
|
const header = ['Time', 'Actor', 'Action', 'Target', 'Outcome', 'IP']
|
|
const lines = rows.map((e) =>
|
|
[fmtTime(e.at), actorName(e), e.action, targetOf(e), e.outcome, e.actorIp ?? ''].map(esc).join(','),
|
|
)
|
|
const csv = [header.map(esc).join(','), ...lines].join('\n')
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `audit-${slug.value}-${new Date().toISOString().slice(0, 10)}.csv`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
toast.ok(`Exported ${rows.length} events`, 'CSV · current view')
|
|
}
|
|
|
|
// ── Security policy (real, stored intent) ────────────────────────────────
|
|
const { request } = useApiFetch()
|
|
|
|
// Live MFA-enrollment overview.
|
|
const { data: mfaStatus } = await useFetch<MfaStatus>(
|
|
() => `/api/tenants/${slug.value}/mfa-status`,
|
|
{ key: 'admin-mfa', default: () => ({ total: 0, enrolled: 0, members: [] }), immediate: !!slug.value, watch: [slug] },
|
|
)
|
|
const notEnrolled = computed(() => (mfaStatus.value?.members ?? []).filter((m) => !m.enrolled))
|
|
const mfaPct = computed(() => {
|
|
const t = mfaStatus.value?.total ?? 0
|
|
return t ? Math.round(((mfaStatus.value?.enrolled ?? 0) / t) * 100) : 0
|
|
})
|
|
|
|
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.' },
|
|
]
|
|
|
|
// Editable policy, seeded from the tenant doc; persisted via PATCH.
|
|
const policy = reactive({
|
|
mfaMode: 'optional' as 'all' | 'admins' | 'optional',
|
|
sessionIdleMinutes: 30,
|
|
sessionAbsoluteHours: 24,
|
|
allowedCountries: [] as string[],
|
|
ipAllowlist: [] as string[],
|
|
})
|
|
function seedPolicy() {
|
|
const p = tenant.value?.securityPolicy
|
|
policy.mfaMode = p?.mfaMode ?? 'optional'
|
|
policy.sessionIdleMinutes = p?.sessionIdleMinutes ?? 30
|
|
policy.sessionAbsoluteHours = p?.sessionAbsoluteHours ?? 24
|
|
policy.allowedCountries = [...(p?.allowedCountries ?? [])]
|
|
policy.ipAllowlist = [...(p?.ipAllowlist ?? [])]
|
|
}
|
|
seedPolicy()
|
|
watch(tenant, seedPolicy)
|
|
|
|
const savingPolicy = ref(false)
|
|
async function savePolicy() {
|
|
if (!slug.value) return
|
|
savingPolicy.value = true
|
|
try {
|
|
await request(`/api/tenants/${slug.value}/security-policy`, { method: 'PATCH', body: { ...policy } })
|
|
await fetchMe(true)
|
|
toast.ok('Security policy saved')
|
|
} catch (e: unknown) {
|
|
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
|
|
toast.bad('Could not save policy', Array.isArray(msg) ? msg.join(', ') : msg)
|
|
} finally {
|
|
savingPolicy.value = false
|
|
}
|
|
}
|
|
|
|
function removeCountry(c: string) {
|
|
policy.allowedCountries = policy.allowedCountries.filter((x) => x !== c)
|
|
}
|
|
function addCountry() {
|
|
const c = newAllowCountry.value.trim().toUpperCase()
|
|
if (c && !policy.allowedCountries.includes(c)) policy.allowedCountries.push(c)
|
|
addCountryOpen.value = false
|
|
newAllowCountry.value = ''
|
|
}
|
|
|
|
const newIp = ref('')
|
|
function addIp() {
|
|
const ip = newIp.value.trim()
|
|
if (ip && !policy.ipAllowlist.includes(ip)) policy.ipAllowlist.push(ip)
|
|
newIp.value = ''
|
|
}
|
|
function removeIp(ip: string) {
|
|
policy.ipAllowlist = policy.ipAllowlist.filter((x) => x !== ip)
|
|
}
|
|
|
|
// ── SSO apps (Dezky as IdP) — real Authentik OIDC providers/applications ──
|
|
const { data: ssoApps, refresh: refreshSso } = await useFetch<SsoApp[]>(
|
|
() => `/api/tenants/${slug.value}/sso-apps`,
|
|
{ key: 'admin-sso', default: () => [], immediate: !!slug.value, watch: [slug] },
|
|
)
|
|
|
|
const ssoOpen = ref(false)
|
|
const ssoName = ref('')
|
|
const ssoRedirects = ref('')
|
|
const creatingSso = ref(false)
|
|
const ssoCreated = ref<SsoAppCreated | null>(null)
|
|
|
|
function openSso() {
|
|
ssoName.value = ''
|
|
ssoRedirects.value = ''
|
|
ssoCreated.value = null
|
|
ssoOpen.value = true
|
|
}
|
|
async function createSso() {
|
|
const redirectUris = ssoRedirects.value.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
if (!ssoName.value.trim() || redirectUris.length === 0) return
|
|
creatingSso.value = true
|
|
try {
|
|
ssoCreated.value = await request<SsoAppCreated>(`/api/tenants/${slug.value}/sso-apps`, {
|
|
method: 'POST',
|
|
body: { name: ssoName.value.trim(), redirectUris },
|
|
})
|
|
await refreshSso()
|
|
toast.ok('SSO app created')
|
|
} catch (e: unknown) {
|
|
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
|
|
toast.bad('Could not create SSO app', Array.isArray(msg) ? msg.join(', ') : msg)
|
|
} finally {
|
|
creatingSso.value = false
|
|
}
|
|
}
|
|
|
|
const ssoDeleteId = ref<string | null>(null)
|
|
const ssoDeleteName = computed(() => (ssoApps.value ?? []).find((a) => a.id === ssoDeleteId.value)?.name ?? '')
|
|
async function confirmDeleteSso() {
|
|
const id = ssoDeleteId.value
|
|
if (!id) return
|
|
try {
|
|
await request(`/api/tenants/${slug.value}/sso-apps/${id}`, { method: 'DELETE' })
|
|
await refreshSso()
|
|
toast.ok('SSO app removed')
|
|
} catch (e: unknown) {
|
|
const msg = (e as { data?: { message?: string } })?.data?.message
|
|
toast.bad('Could not remove SSO app', msg)
|
|
} finally {
|
|
ssoDeleteId.value = null
|
|
}
|
|
}
|
|
|
|
function copyText(text?: string) {
|
|
if (!text) return
|
|
navigator.clipboard?.writeText(text)
|
|
toast.ok('Copied to clipboard')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Compliance"
|
|
title="Security & audit"
|
|
subtitle="Identity controls, network policy, and a full log of every administrative action."
|
|
>
|
|
<template #actions>
|
|
<UiButton v-if="tab === 'security'" variant="primary" :disabled="savingPolicy || !slug" @click="savePolicy">
|
|
<template #leading><UiIcon name="check" :size="14" /></template>
|
|
{{ savingPolicy ? 'Saving…' : 'Save policy' }}
|
|
</UiButton>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<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>
|
|
|
|
<!-- Live enrollment overview -->
|
|
<div class="mfa-overview">
|
|
<div class="mfa-stat">
|
|
<div class="mfa-num">{{ mfaStatus.enrolled }} / {{ mfaStatus.total }}</div>
|
|
<Mono dim>members enrolled</Mono>
|
|
</div>
|
|
<div class="mfa-bar-wrap">
|
|
<div class="mfa-bar"><span :style="{ width: `${mfaPct}%` }" /></div>
|
|
<Mono dim>{{ mfaPct }}% of active members have TOTP or WebAuthn</Mono>
|
|
</div>
|
|
</div>
|
|
<div v-if="notEnrolled.length" class="mfa-missing">
|
|
<Mono dim>Not enrolled:</Mono>
|
|
<span class="missing-names">{{ notEnrolled.map((m) => m.name || m.email).join(', ') }}</span>
|
|
</div>
|
|
|
|
<div class="sub-head">
|
|
<Eyebrow>Enforcement policy</Eyebrow>
|
|
<Badge tone="warn" dot>rolling out</Badge>
|
|
</div>
|
|
<div class="radio-big">
|
|
<label v-for="o in mfaOptions" :key="o.v" :class="{ active: policy.mfaMode === o.v }">
|
|
<span class="radio-dot"><span v-if="policy.mfaMode === o.v" /></span>
|
|
<input type="radio" :value="o.v" v-model="policy.mfaMode" />
|
|
<div>
|
|
<div class="radio-label">{{ o.label }}</div>
|
|
<div class="radio-d">{{ o.d }}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div class="enforce-note">
|
|
<UiIcon name="shield" :size="13" stroke="var(--text-mute)" />
|
|
<span>Saved now; automatic enforcement through your identity provider is being rolled out.</span>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div class="card-head card-head-inline">
|
|
<div>
|
|
<Eyebrow>Sessions</Eyebrow>
|
|
<div class="card-title">Session policy</div>
|
|
</div>
|
|
<Badge tone="neutral" dot>not enforced yet</Badge>
|
|
</div>
|
|
<div class="grid-2">
|
|
<label class="field"><Eyebrow>Idle timeout (minutes)</Eyebrow>
|
|
<input class="input" type="number" min="0" max="1440" v-model.number="policy.sessionIdleMinutes" />
|
|
</label>
|
|
<label class="field"><Eyebrow>Absolute timeout (hours)</Eyebrow>
|
|
<input class="input" type="number" min="0" max="8760" v-model.number="policy.sessionAbsoluteHours" />
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div class="card-head card-head-inline">
|
|
<div>
|
|
<Eyebrow>Network</Eyebrow>
|
|
<div class="card-title">Geo-fencing & allow-lists</div>
|
|
</div>
|
|
<Badge tone="neutral" dot>not enforced yet</Badge>
|
|
</div>
|
|
<div class="field">
|
|
<Eyebrow>Allowed countries</Eyebrow>
|
|
<div class="chip-row">
|
|
<Badge v-for="c in policy.allowedCountries" :key="c" tone="neutral">
|
|
{{ c }}
|
|
<button class="badge-x" @click="removeCountry(c)" aria-label="Remove country">
|
|
<UiIcon name="x" :size="10" />
|
|
</button>
|
|
</Badge>
|
|
<Mono v-if="policy.allowedCountries.length === 0" dim>Any country allowed</Mono>
|
|
<UiButton size="sm" variant="ghost" @click="addCountryOpen = true">
|
|
<template #leading><UiIcon name="plus" :size="12" /></template>
|
|
Add country
|
|
</UiButton>
|
|
</div>
|
|
</div>
|
|
<div class="field" style="margin-top: 16px">
|
|
<Eyebrow>IP allow-list (CIDR)</Eyebrow>
|
|
<div class="chip-row">
|
|
<Badge v-for="ip in policy.ipAllowlist" :key="ip" tone="neutral">
|
|
<Mono>{{ ip }}</Mono>
|
|
<button class="badge-x" @click="removeIp(ip)" aria-label="Remove IP range">
|
|
<UiIcon name="x" :size="10" />
|
|
</button>
|
|
</Badge>
|
|
<Mono v-if="policy.ipAllowlist.length === 0" dim>No IP restriction</Mono>
|
|
</div>
|
|
<div class="ip-add">
|
|
<input class="input" v-model="newIp" placeholder="e.g. 203.0.113.0/24" @keyup.enter="addIp" />
|
|
<UiButton size="sm" variant="secondary" :disabled="!newIp.trim()" @click="addIp">Add</UiButton>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<div class="card-head card-head-inline">
|
|
<div>
|
|
<Eyebrow>SSO</Eyebrow>
|
|
<div class="card-title">dezky as identity provider</div>
|
|
<div class="card-sub">Let external apps sign your team in with dezky via OpenID Connect.</div>
|
|
</div>
|
|
<UiButton size="sm" variant="primary" @click="openSso">
|
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
|
Add app
|
|
</UiButton>
|
|
</div>
|
|
<div class="sso-list">
|
|
<div v-for="a in ssoApps" :key="a.id" class="sso-row">
|
|
<div class="sso-icon">{{ a.name[0]?.toUpperCase() }}</div>
|
|
<div class="sso-meta">
|
|
<div class="sso-name">{{ a.name }}</div>
|
|
<Mono dim>{{ a.protocol.toUpperCase() }} · {{ a.clientId }}</Mono>
|
|
</div>
|
|
<Badge tone="ok" dot>connected</Badge>
|
|
<button class="icon-del" title="Remove" @click="ssoDeleteId = a.id"><UiIcon name="trash" :size="14" /></button>
|
|
</div>
|
|
<div v-if="(ssoApps?.length ?? 0) === 0" class="sso-empty">
|
|
<Mono dim>No SSO apps yet. Add one to let an external app authenticate with dezky.</Mono>
|
|
</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 v-model="search" placeholder="action, actor, target…" />
|
|
</div>
|
|
<select v-model="outcomeFilter" class="select">
|
|
<option value="">Any outcome</option>
|
|
<option value="success">Success</option>
|
|
<option value="failure">Failure</option>
|
|
</select>
|
|
<select v-model="sinceFilter" class="select">
|
|
<option value="">All time</option>
|
|
<option value="1d">Last 24h</option>
|
|
<option value="7d">Last 7 days</option>
|
|
<option value="30d">Last 30 days</option>
|
|
</select>
|
|
<div class="spacer" />
|
|
<UiButton variant="secondary" @click="exportCsv">
|
|
<template #leading><UiIcon name="download" :size="14" /></template>
|
|
Export CSV
|
|
</UiButton>
|
|
</div>
|
|
|
|
<div class="action-chips">
|
|
<button
|
|
v-for="qa in QUICK_ACTIONS"
|
|
:key="qa.value"
|
|
class="achip"
|
|
:class="{ on: actionFilter === qa.value }"
|
|
@click="actionFilter = qa.value"
|
|
>{{ qa.label }}</button>
|
|
</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 auditRows" :key="a._id">
|
|
<td><Mono>{{ fmtTime(a.at) }}</Mono></td>
|
|
<td>
|
|
<div class="actor-cell">
|
|
<Avatar v-if="a.actorType !== 'system'" :name="actorName(a)" :size="22" />
|
|
<div v-else class="sys">sys</div>
|
|
<span>{{ actorName(a) }}</span>
|
|
</div>
|
|
</td>
|
|
<td><Mono>{{ a.action }}</Mono></td>
|
|
<td class="target">{{ targetOf(a) }}</td>
|
|
<td><Mono dim>{{ a.actorIp || '—' }}</Mono></td>
|
|
<td class="right"><Badge :tone="auditTone(a)" dot>{{ a.outcome }}</Badge></td>
|
|
</tr>
|
|
<tr v-if="auditRows.length === 0" class="no-hover">
|
|
<td colspan="6" class="empty-row"><Mono dim>No audit events match your filters.</Mono></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
|
|
<div class="audit-foot">
|
|
<Mono dim>{{ auditRows.length }} event{{ auditRows.length === 1 ? '' : 's' }} shown</Mono>
|
|
<UiButton v-if="!reachedEnd && auditRows.length > 0" size="sm" variant="secondary" :disabled="loadingMore" @click="loadMore">
|
|
{{ loadingMore ? 'Loading…' : 'Load older' }}
|
|
</UiButton>
|
|
<Mono v-else-if="auditRows.length > 0" dim>· end of log</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="addCountry">
|
|
Add
|
|
</UiButton>
|
|
</template>
|
|
</Modal>
|
|
|
|
<!-- Add SSO app modal -->
|
|
<Modal :open="ssoOpen" eyebrow="Security · SSO" :title="ssoCreated ? 'SSO app created' : 'Add SSO app'" size="md" @close="ssoOpen = false">
|
|
<!-- Step 1: form -->
|
|
<div v-if="!ssoCreated" class="form-stack">
|
|
<label class="field"><Eyebrow>App name</Eyebrow>
|
|
<input class="input" v-model="ssoName" placeholder="e.g. Internal Wiki" />
|
|
</label>
|
|
<label class="field"><Eyebrow>Redirect URIs (one per line)</Eyebrow>
|
|
<textarea v-model="ssoRedirects" class="ta" rows="3" placeholder="https://app.example.com/oauth/callback" />
|
|
</label>
|
|
<div class="enforce-note">
|
|
<UiIcon name="key" :size="13" stroke="var(--text-mute)" />
|
|
<span>Creates an OpenID Connect app in your identity provider, accessible only to this workspace.</span>
|
|
</div>
|
|
</div>
|
|
<!-- Step 2: credentials (shown once) -->
|
|
<div v-else class="form-stack">
|
|
<div class="cred-warn">
|
|
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
|
|
<span>Copy the client secret now — it won't be shown again.</span>
|
|
</div>
|
|
<div class="cred-row"><Eyebrow>Client ID</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.clientId }}</Mono><button class="copy" @click="copyText(ssoCreated.clientId)"><UiIcon name="copy" :size="12" /></button></div></div>
|
|
<div class="cred-row"><Eyebrow>Client secret</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.clientSecret }}</Mono><button class="copy" @click="copyText(ssoCreated.clientSecret)"><UiIcon name="copy" :size="12" /></button></div></div>
|
|
<div class="cred-row"><Eyebrow>Discovery URL</Eyebrow><div class="cred-val"><Mono>{{ ssoCreated.wellKnownUrl }}</Mono><button class="copy" @click="copyText(ssoCreated.wellKnownUrl)"><UiIcon name="copy" :size="12" /></button></div></div>
|
|
</div>
|
|
<template #footer>
|
|
<template v-if="!ssoCreated">
|
|
<UiButton variant="ghost" @click="ssoOpen = false">Cancel</UiButton>
|
|
<UiButton variant="primary" :disabled="creatingSso || !ssoName.trim() || !ssoRedirects.trim()" @click="createSso">
|
|
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
{{ creatingSso ? 'Creating…' : 'Create app' }}
|
|
</UiButton>
|
|
</template>
|
|
<UiButton v-else variant="primary" @click="ssoOpen = false">Done</UiButton>
|
|
</template>
|
|
</Modal>
|
|
|
|
<!-- Delete SSO app confirm -->
|
|
<ConfirmDialog
|
|
:open="!!ssoDeleteId"
|
|
eyebrow="Security · SSO"
|
|
:title="`Remove ${ssoDeleteName}?`"
|
|
confirm-label="Remove app"
|
|
tone="danger"
|
|
@close="ssoDeleteId = null"
|
|
@confirm="confirmDeleteSso"
|
|
>
|
|
The app's OpenID Connect provider is deleted from your identity provider. Anyone using it to sign in will lose access immediately.
|
|
</ConfirmDialog>
|
|
</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; }
|
|
|
|
.select {
|
|
height: 36px;
|
|
padding: 0 10px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.action-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
.achip {
|
|
padding: 5px 12px;
|
|
border-radius: 999px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
}
|
|
.achip:hover { color: var(--text); }
|
|
.achip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
|
|
|
.audit-foot { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
|
.no-hover td { cursor: default; }
|
|
.empty-row { text-align: center; padding: 40px 16px; }
|
|
|
|
.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); }
|
|
|
|
/* MFA overview */
|
|
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
.mfa-overview { display: flex; align-items: center; gap: 24px; margin-bottom: 12px; }
|
|
.mfa-stat { flex-shrink: 0; }
|
|
.mfa-num { font-family: var(--font-display); font-weight: 600; font-size: 28px; letter-spacing: -0.01em; }
|
|
.mfa-bar-wrap { flex: 1; min-width: 0; }
|
|
.mfa-bar { height: 8px; background: var(--bg); border-radius: 999px; overflow: hidden; margin-bottom: 6px; }
|
|
.mfa-bar span { display: block; height: 100%; background: var(--ok); }
|
|
.mfa-missing { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; padding: 10px 12px; background: var(--bg); border-radius: 6px; font-size: 12px; }
|
|
.missing-names { color: var(--text-dim); }
|
|
|
|
.sub-head { display: flex; align-items: center; gap: 8px; margin: 20px 0 10px; }
|
|
.enforce-note { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 12px; color: var(--text-mute); }
|
|
|
|
.soon-box {
|
|
display: flex; gap: 10px; align-items: flex-start; padding: 14px;
|
|
background: var(--bg); border: 1px dashed var(--border-hi, var(--border));
|
|
border-radius: 6px; font-size: 12px; color: var(--text-dim); line-height: 1.5;
|
|
}
|
|
.ip-add { display: flex; gap: 8px; margin-top: 10px; }
|
|
.ip-add .input { flex: 1; }
|
|
|
|
/* SSO */
|
|
.sso-empty { padding: 16px 0; }
|
|
.icon-del {
|
|
background: transparent; border: 1px solid var(--border); border-radius: 6px;
|
|
width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
|
|
color: var(--text-mute); cursor: pointer;
|
|
}
|
|
.icon-del:hover { color: var(--bad); border-color: var(--bad); }
|
|
.ta {
|
|
width: 100%; box-sizing: border-box; padding: 10px 12px; background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: 6px; font-family: var(--font-mono);
|
|
font-size: 12px; color: var(--text); outline: none; resize: vertical; line-height: 1.6;
|
|
}
|
|
.ta:focus { border-color: var(--text); }
|
|
.cred-warn {
|
|
display: flex; gap: 10px; align-items: center; padding: 12px;
|
|
background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.2);
|
|
border-radius: 6px; font-size: 12px; color: var(--text-dim);
|
|
}
|
|
.cred-row { display: flex; flex-direction: column; gap: 6px; }
|
|
.cred-val {
|
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
font-size: 12px; overflow: hidden;
|
|
}
|
|
.cred-val :deep(.mono) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.copy { background: transparent; border: none; color: var(--text-mute); cursor: pointer; flex-shrink: 0; padding: 2px; }
|
|
.copy:hover { color: var(--text); }
|
|
</style>
|