feat: portal redesign, pricing catalog, partner-staff invites

- 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
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+947
View File
@@ -0,0 +1,947 @@
<script setup lang="ts">
// Security · password / two-factor / recovery codes / sign-in history.
// Faithfully ports project/platform-enduser.jsx `SecurityEndUserScreen`
// (lines 352971) and its sub-modals.
import { mfaMethods, recoveryCodes, signInHistory } from '~/data/enduser'
const toast = useToast()
const tab = ref('password')
// --- Password ---
const pwd = reactive({ current: '••••••••••••', next: 'northern-coffee-bridge-april', confirm: 'northern-coffee-bridge-april' })
const showPwd = reactive({ current: false, next: false, confirm: false })
const savedFlash = ref(false)
const strength = computed(() => {
const v = pwd.next
if (!v) return 0
let s = Math.min(100, v.length * 8)
if (/[A-Z]/.test(v)) s += 8
if (/[0-9]/.test(v)) s += 8
if (/[^A-Za-z0-9]/.test(v)) s += 10
return Math.min(100, s)
})
const strengthLabel = computed(() => {
const s = strength.value
if (s > 80) return 'excellent'
if (s > 60) return 'strong'
if (s > 30) return 'fair'
return 'weak'
})
const strengthColor = computed(() => {
const s = strength.value
if (s > 60) return 'var(--ok)'
if (s > 30) return 'var(--warn)'
return 'var(--bad)'
})
// Criteria list matches source's 4 fixed entries (all marked passing).
const criteria = [
{ id: 'len', label: 'At least 14 characters', ok: true },
{ id: 'mix', label: 'Mix of letters, numbers, or words', ok: true },
{ id: 'breach', label: 'Not in known breach lists', ok: true },
{ id: 'rotate', label: 'Different from your last 5 passwords', ok: true },
]
const canSubmitPwd = computed(() => !!pwd.next && pwd.next === pwd.confirm)
function submitPwd() {
if (!canSubmitPwd.value) return
savedFlash.value = true
setTimeout(() => (savedFlash.value = false), 3000)
}
function resetPwd() {
pwd.next = 'northern-coffee-bridge-april'
pwd.confirm = 'northern-coffee-bridge-april'
savedFlash.value = false
}
const backupEmail = ref('anne@gmail.com')
const verifyOpen = ref(false)
const verifyCode = ref('')
// --- MFA ---
const renameMfa = ref<typeof mfaMethods[number] | null>(null)
const renameMfaValue = ref('')
const removeMfa = ref<typeof mfaMethods[number] | null>(null)
const mfaSetup = ref<'webauthn' | 'totp' | null>(null)
const mfaSetupStep = ref(1)
function openSetup(kind: 'webauthn' | 'totp') {
mfaSetup.value = kind
mfaSetupStep.value = 1
}
const mfaMenuFor = ref<string | null>(null)
const mfaMenuPos = ref({ top: 0, right: 0 })
const mfaTriggerRefs = ref<Record<string, HTMLElement | null>>({})
function openMfaMenu(id: string, e: MouseEvent) {
e.stopPropagation()
const btn = mfaTriggerRefs.value[id]
if (btn) {
const r = btn.getBoundingClientRect()
mfaMenuPos.value = { top: r.bottom + 4, right: window.innerWidth - r.right }
}
mfaMenuFor.value = mfaMenuFor.value === id ? null : id
}
function setMfaTriggerRef(id: string, el: any) {
mfaTriggerRefs.value[id] = el as HTMLElement | null
}
onMounted(() => {
const close = () => (mfaMenuFor.value = null)
const onScroll = () => (mfaMenuFor.value = null)
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') mfaMenuFor.value = null }
document.addEventListener('mousedown', close)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
onBeforeUnmount(() => {
document.removeEventListener('mousedown', close)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
})
// --- Recovery codes ---
const showCodesOpen = ref(false)
const regenOpen = ref(false)
const regenAck = ref(false)
const copyFlash = ref(false)
const downloadFlash = ref(false)
// Source marks first 2 codes as "used" via opacity/strikethrough.
const usedCount = 2
function copyCodes() {
const text = recoveryCodes
.map((c, i) => `${String(i + 1).padStart(2, '0')} ${c}`)
.join('\n')
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {})
copyFlash.value = true
setTimeout(() => (copyFlash.value = false), 1800)
}
function downloadCodes() {
downloadFlash.value = true
setTimeout(() => (downloadFlash.value = false), 1800)
}
// --- Sign-in history ---
const exportOpen = ref(false)
const exportPeriod = ref<'30d' | '90d' | '12mo' | 'all'>('90d')
const exportFormat = ref<'csv' | 'json'>('csv')
const exportIncludeFailed = ref(true)
const exportDelivery = ref<'download' | 'email'>('download')
const exportRowEst = computed(() => {
if (exportPeriod.value === '30d') return 14
if (exportPeriod.value === '90d') return 42
if (exportPeriod.value === '12mo') return 186
return 312
})
</script>
<template>
<div>
<PageHeader
eyebrow="Account"
title="Security"
subtitle="Password, multi-factor methods, recovery codes, and a complete history of sign-ins on your account."
/>
<div class="tabs-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'password', label: 'Password' },
{ value: 'mfa', label: 'Two-factor', count: mfaMethods.length },
{ value: 'recovery', label: 'Recovery codes' },
{ value: 'history', label: 'Sign-in history', count: signInHistory.length },
]"
/>
</div>
<div class="content">
<!-- Password tab -->
<section v-if="tab === 'password'" class="stack">
<Card>
<header class="card-header">
<Eyebrow>Change password</Eyebrow>
<h2>Choose a strong password</h2>
<p>At least 14 characters · we recommend a passphrase like 'wide-lemon-tunnel-corn' or use your password manager.</p>
</header>
<div class="pwd-form">
<EnduserFormField label="Current password">
<div class="pwd-input">
<input :type="showPwd.current ? 'text' : 'password'" v-model="pwd.current" />
<button type="button" class="eye" @click="showPwd.current = !showPwd.current">
<UiIcon name="key" :size="14" />
</button>
</div>
</EnduserFormField>
<EnduserFormField label="New password">
<div class="pwd-input">
<input :type="showPwd.next ? 'text' : 'password'" v-model="pwd.next" />
<button type="button" class="eye" @click="showPwd.next = !showPwd.next">
<UiIcon :name="showPwd.next ? 'x' : 'key'" :size="14" />
</button>
</div>
</EnduserFormField>
<div class="strength">
<div class="strength-head">
<Mono dim>STRENGTH</Mono>
<Mono :style="{ color: strengthColor }">{{ strengthLabel }}</Mono>
</div>
<div class="bar"><span :style="{ width: strength + '%', background: strengthColor }" /></div>
<ul class="criteria">
<li v-for="c in criteria" :key="c.id" :data-ok="c.ok">
<UiIcon :name="c.ok ? 'check' : 'x'" :size="11" :stroke-width="2.4" />
<span>{{ c.label }}</span>
</li>
</ul>
</div>
<EnduserFormField label="Confirm new password">
<div class="pwd-input">
<input :type="showPwd.confirm ? 'text' : 'password'" v-model="pwd.confirm" />
<button type="button" class="eye" @click="showPwd.confirm = !showPwd.confirm">
<UiIcon :name="showPwd.confirm ? 'x' : 'key'" :size="14" />
</button>
</div>
</EnduserFormField>
</div>
<div class="actions">
<span v-if="savedFlash" class="flash-ok">
<UiIcon name="check" :size="13" :stroke-width="2.5" />
Password updated · confirmation email sent
</span>
<div style="flex: 1;" />
<UiButton variant="ghost" @click="resetPwd">Cancel</UiButton>
<UiButton variant="primary" :disabled="!canSubmitPwd" @click="submitPwd">
<template #leading><UiIcon name="check" :size="13" /></template>
Update password
</UiButton>
</div>
<p class="footer-note">
<Mono>// after you change</Mono> we'll sign you out everywhere except this device. You'll get an email confirmation. If you didn't make this change, contact your admin immediately.
</p>
</Card>
<Card>
<header class="card-header with-actions">
<div class="head-text">
<Eyebrow>Account recovery</Eyebrow>
<h2>Backup recovery email</h2>
<p>If you lose access to dezky, we can send a recovery link to this address. Use a personal address, not a work one.</p>
</div>
<Badge tone="ok" dot>verified</Badge>
</header>
<div class="recovery">
<div class="email-input">
<UiIcon name="mail" :size="14" stroke="var(--text-mute)" />
<input v-model="backupEmail" />
</div>
<UiButton size="sm" variant="secondary" @click="verifyOpen = true">Verify new address</UiButton>
</div>
<div class="callout-info">
<UiIcon name="shield" :size="14" />
<span>We'll never use this address for marketing or workspace announcements — only account recovery and security alerts. Verified <Mono>14 Jan 2026</Mono> · last reachability check <Mono>2 days ago</Mono>.</span>
</div>
</Card>
</section>
<!-- MFA tab -->
<section v-else-if="tab === 'mfa'" class="stack">
<Card>
<div class="mfa-on">
<span class="mfa-shield"><UiIcon name="shield" :size="20" /></span>
<div>
<h3>Two-factor is on</h3>
<p>You have <b>{{ mfaMethods.length }} methods</b> set up · workspace requires MFA for admins.</p>
</div>
</div>
</Card>
<Card :pad="0">
<header class="mfa-head">
<Eyebrow>Active methods</Eyebrow>
<Mono dim>{{ mfaMethods.length }}</Mono>
</header>
<ul class="mfa-list">
<li v-for="m in mfaMethods" :key="m.id">
<span class="mfa-icon">
<UiIcon :name="m.kind === 'webauthn' ? 'key' : 'device'" :size="16" />
</span>
<div class="mfa-text">
<div class="mfa-row">
<span class="mfa-name">{{ m.label }}</span>
<Badge v-if="m.primary" tone="invert">primary</Badge>
<Badge tone="neutral">{{ m.kind === 'webauthn' ? 'hardware key' : 'authenticator app' }}</Badge>
</div>
<Mono dim>added {{ m.enrolledOn }} · last used {{ m.lastUsed }}</Mono>
</div>
<span :ref="(el) => setMfaTriggerRef(m.id, el)" class="mfa-trigger">
<UiButton size="sm" variant="ghost" @click="openMfaMenu(m.id, $event)">
<UiIcon name="more" :size="14" />
</UiButton>
</span>
</li>
</ul>
<div class="mfa-add">
<UiButton variant="secondary" @click="openSetup('webauthn')">
<template #leading><UiIcon name="key" :size="13" /></template>
Add security key (WebAuthn)
</UiButton>
<UiButton variant="secondary" @click="openSetup('totp')">
<template #leading><UiIcon name="device" :size="13" /></template>
Add authenticator app (TOTP)
</UiButton>
</div>
</Card>
<!-- Portaled MFA method action menus -->
<Teleport to="body">
<div
v-if="mfaMenuFor"
class="mfa-menu"
:style="{ top: mfaMenuPos.top + 'px', right: mfaMenuPos.right + 'px' }"
@mousedown.stop
>
<template v-for="m in mfaMethods" :key="m.id">
<template v-if="m.id === mfaMenuFor">
<button @click="renameMfa = m; renameMfaValue = m.label; mfaMenuFor = null">
<UiIcon name="brush" :size="14" />
<span>Rename method</span>
</button>
<button :disabled="m.primary" @click="!m.primary && toast.ok(`${m.label} is now primary`); mfaMenuFor = null">
<UiIcon name="shield" :size="14" />
<span>{{ m.primary ? 'Already primary' : 'Set as primary' }}</span>
</button>
<button @click="toast.info(`Testing ${m.label}`); mfaMenuFor = null">
<UiIcon name="refresh" :size="14" />
<span>Test this method</span>
</button>
<span class="sep" />
<button class="danger" :disabled="m.primary" @click="!m.primary && (removeMfa = m); mfaMenuFor = null">
<UiIcon name="trash" :size="14" />
<span>{{ m.primary ? 'Remove · pick a new primary first' : 'Remove method' }}</span>
</button>
</template>
</template>
</div>
</Teleport>
</section>
<!-- Recovery codes tab -->
<section v-else-if="tab === 'recovery'" class="stack">
<Card>
<header class="card-header">
<Eyebrow>Recovery codes</Eyebrow>
<h2>One-time codes for when you lose access</h2>
<p>If you lose your phone and security key, these codes get you back in. Treat them like passwords — store offline.</p>
</header>
<div class="codes-summary">
<span class="codes-tile"><UiIcon name="shield" :size="18" /></span>
<div class="codes-meta">
<div class="codes-title">{{ recoveryCodes.length }} codes generated · {{ recoveryCodes.length - usedCount }} unused</div>
<Mono dim>last regenerated · 14 Jan 2026 · {{ usedCount }} used</Mono>
</div>
<UiButton variant="secondary" @click="showCodesOpen = true">
<template #leading><UiIcon name="key" :size="13" /></template>
View codes
</UiButton>
</div>
<div class="callout-warn">
<UiIcon name="bell" :size="14" />
<div class="cw-body">
<b>Keep these somewhere safe</b> — printed, password manager, or offline note. Each code works once. If you suspect they've been seen by someone else, regenerate immediately.
</div>
<UiButton size="sm" variant="ghost" @click="regenOpen = true; regenAck = false">Regenerate</UiButton>
</div>
</Card>
</section>
<!-- Sign-in history tab -->
<section v-else-if="tab === 'history'" class="stack">
<div class="hist-bar">
<div class="hist-intro">
Every sign-in attempt on your account, successful or not. If you see something you don't recognize, change your password and revoke unknown sessions.
</div>
<UiButton variant="secondary" @click="exportOpen = true">
<template #leading><UiIcon name="download" :size="13" /></template>
Export
</UiButton>
</div>
<Card :pad="0">
<table class="history">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>Location</th>
<th>Client</th>
<th>Method</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="r in signInHistory" :key="r.id">
<td><Mono>{{ r.when }}</Mono></td>
<td><Mono>{{ r.ip }}</Mono></td>
<td><span :class="{ unknown: r.location === 'Unknown' }">{{ r.location }}</span></td>
<td><Mono dim>{{ r.ua }}</Mono></td>
<td><Mono>{{ r.method }}</Mono></td>
<td>
<Badge :tone="r.result === 'ok' ? 'ok' : 'bad'" dot>
{{ r.result === 'ok' ? 'success' : 'failed' }}
</Badge>
</td>
</tr>
</tbody>
</table>
</Card>
<div class="callout-bad-strong">
<UiIcon name="shield" :size="14" />
<div class="cb-text">
<div class="cb-title">3 failed attempts from 203.0.113.4 yesterday at 18:02</div>
<Mono dim>Unknown location. Your password is still safe, but consider rotating it.</Mono>
</div>
<UiButton size="sm" variant="primary" @click="tab = 'password'">Change password</UiButton>
</div>
</section>
</div>
<!-- Verify backup email modal -->
<Modal :open="verifyOpen" eyebrow="Account recovery" title="Verify backup email" size="sm" @close="verifyOpen = false">
<p class="modal-body">
We sent a 6-digit code to <Mono>{{ backupEmail }}</Mono>. Paste it below to confirm you control the inbox.
</p>
<EnduserFormField label="Verification code">
<input v-model="verifyCode" maxlength="6" placeholder="6-digit code" />
</EnduserFormField>
<Mono dim style="display: block; text-align: center; margin-top: 12px;">
didn't get it? <a href="#" @click.prevent="toast.info('Code resent')">resend</a> · expires in 10 min
</Mono>
<template #footer>
<UiButton variant="ghost" @click="verifyOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="verifyCode.length < 6" @click="verifyOpen = false; toast.ok('Backup email verified')">
<template #leading><UiIcon name="check" :size="13" /></template>
Verify
</UiButton>
</template>
</Modal>
<!-- Rename MFA modal -->
<Modal :open="renameMfa !== null" eyebrow="Two-factor · rename" :title="renameMfa ? `Rename ${renameMfa.label}` : ''" size="sm" @close="renameMfa = null">
<EnduserFormField label="Method name">
<input v-model="renameMfaValue" placeholder="e.g. YubiKey 5C · work laptop" />
</EnduserFormField>
<Mono dim style="display: block; margin-top: 12px;">helps you tell methods apart when signing in or removing one</Mono>
<template #footer>
<UiButton variant="ghost" @click="renameMfa = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="!renameMfaValue.trim()" @click="toast.ok(`Renamed to ${renameMfaValue}`); renameMfa = null">
<template #leading><UiIcon name="check" :size="13" /></template>
Save name
</UiButton>
</template>
</Modal>
<!-- Remove MFA method modal (matches source DefList layout) -->
<Modal :open="removeMfa !== null" eyebrow="Two-factor · remove" :title="removeMfa ? `Remove ${removeMfa.label}?` : ''" size="md" @close="removeMfa = null">
<div class="modal-stack">
<div class="callout-bad">
<UiIcon name="shield" :size="16" />
<div>You'll be required to re-enter your password to confirm. We'll send a confirmation email if you didn't do this, contact your admin immediately.</div>
</div>
<div class="def-block">
<dl>
<div><dt>Method</dt><dd>{{ removeMfa?.label }}</dd></div>
<div><dt>Kind</dt><dd>{{ removeMfa?.kind === 'webauthn' ? 'hardware security key' : 'authenticator app (TOTP)' }}</dd></div>
<div><dt>Added</dt><dd>{{ removeMfa?.enrolledOn }}</dd></div>
<div><dt>Last used</dt><dd>{{ removeMfa?.lastUsed }}</dd></div>
</dl>
</div>
<Mono dim>at least one MFA method must remain — workspace requires MFA for admins</Mono>
</div>
<template #footer>
<UiButton variant="ghost" @click="removeMfa = null">Cancel</UiButton>
<UiButton variant="danger" @click="toast.warn(`${removeMfa?.label} removed`); removeMfa = null">
<template #leading><UiIcon name="trash" :size="13" /></template>
Remove method
</UiButton>
</template>
</Modal>
<!-- MFA setup wizard -->
<Modal :open="mfaSetup !== null" :eyebrow="`Step ${mfaSetupStep} of 3`" :title="mfaSetup === 'webauthn' ? 'Add a security key' : 'Add an authenticator app'" size="md" @close="mfaSetup = null">
<div v-if="mfaSetupStep === 1">
<p class="modal-body">
<template v-if="mfaSetup === 'webauthn'">Security keys are the strongest form of MFA. Use a hardware token (YubiKey) or your laptop's built-in Touch ID / Windows Hello.</template>
<template v-else>You'll scan a QR code with an authenticator app like 1Password, Bitwarden, Authy, or Google Authenticator.</template>
</p>
<EnduserFormField label="Name this method">
<input :value="mfaSetup === 'webauthn' ? 'MacBook Pro · Touch ID' : '1Password · main vault'" />
</EnduserFormField>
</div>
<div v-else-if="mfaSetupStep === 2 && mfaSetup === 'webauthn'" class="wait-key">
<div class="key-circle"><UiIcon name="key" :size="48" :stroke-width="1.4" /></div>
<div class="key-title">Waiting for your key</div>
<Mono dim style="margin-top: 8px; display: block;">// touch your YubiKey or use Touch ID</Mono>
</div>
<div v-else-if="mfaSetupStep === 2 && mfaSetup === 'totp'">
<p class="modal-body">
Scan this code with your authenticator app. Or copy the setup key and add it manually.
</p>
<div class="qr-setup">
<div class="qr-big">
<span v-for="n in 441" :key="n" :style="{ background: (n * 7919 % 100) < 48 ? '#0A0A0A' : 'transparent' }" />
</div>
<div class="qr-meta">
<Mono dim>SETUP KEY</Mono>
<div class="qr-key">JBSW Y3DP EHPK 3PXP</div>
<UiButton size="sm" variant="ghost">
<template #leading><UiIcon name="copy" :size="13" /></template>
Copy key
</UiButton>
</div>
</div>
<EnduserFormField label="Enter the 6-digit code from your app">
<input value="481" />
</EnduserFormField>
</div>
<div v-else-if="mfaSetupStep === 3">
<div class="success">
<span class="success-tile"><UiIcon name="check" :size="22" :stroke-width="2.5" /></span>
<div>
<h3>{{ mfaSetup === 'webauthn' ? 'Security key added' : 'App registered' }}</h3>
<Mono dim>verified · ready to use on next sign-in</Mono>
</div>
</div>
<div class="success-note">
<Mono>// next time you sign in</Mono><br />
We'll ask you to use this method as your second factor. Make sure you have recovery codes saved somewhere safe in case you lose access to your authenticator.
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="mfaSetup = null">Cancel</UiButton>
<div style="flex: 1;" />
<UiButton v-if="mfaSetupStep > 1" variant="secondary" @click="mfaSetupStep--">Back</UiButton>
<UiButton v-if="mfaSetupStep < 3" variant="primary" @click="mfaSetupStep++">Continue</UiButton>
<UiButton v-else variant="primary" @click="toast.ok('Method added'); mfaSetup = null">
<template #leading><UiIcon name="check" :size="13" /></template>
Done · use this method
</UiButton>
</template>
</Modal>
<!-- Recovery codes modal -->
<Modal :open="showCodesOpen" eyebrow="One-time use · keep safe" title="Your recovery codes" size="md" @close="showCodesOpen = false">
<p class="modal-body">
Save these somewhere offline. Each code works <b>once</b>. We'll show you any codes you haven't used yet.
</p>
<div class="codes-grid">
<div
v-for="(c, i) in recoveryCodes"
:key="c"
class="code"
:class="{ used: i < usedCount }"
>
<span class="code-idx">{{ i + 1 < 10 ? '0' + (i + 1) : i + 1 }}</span>
<span class="code-val">{{ c }}</span>
<Mono v-if="i < usedCount" dim>used</Mono>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="showCodesOpen = false">Close</UiButton>
<div style="flex: 1;" />
<UiButton variant="secondary" @click="copyCodes">
<template #leading><UiIcon :name="copyFlash ? 'check' : 'copy'" :size="13" /></template>
{{ copyFlash ? 'Copied · paste somewhere safe' : 'Copy all' }}
</UiButton>
<UiButton variant="primary" @click="downloadCodes">
<template #leading><UiIcon :name="downloadFlash ? 'check' : 'download'" :size="13" /></template>
{{ downloadFlash ? 'Downloaded' : 'Download .txt' }}
</UiButton>
</template>
</Modal>
<!-- Regenerate recovery codes modal -->
<Modal :open="regenOpen" eyebrow="Destructive · invalidates existing codes" title="Regenerate recovery codes?" size="md" @close="regenOpen = false">
<div class="modal-stack">
<div class="callout-bad">
<UiIcon name="shield" :size="16" />
<div>Your <b>10 existing codes</b> ({{ recoveryCodes.length - usedCount }} still unused) will be invalidated immediately. Anyone holding a printed copy will no longer be able to use them.</div>
</div>
<div class="def-block">
<Mono dim>// when to do this</Mono>
<div class="def-body">You're seeing this option to <b>regenerate</b> because either you used a code recently, or you suspect someone else has seen your saved codes. If neither, you can keep the existing ones.</div>
</div>
<label class="check-row">
<input type="checkbox" v-model="regenAck" />
I understand the old codes will stop working
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="regenOpen = false">Cancel</UiButton>
<UiButton variant="danger" :disabled="!regenAck" @click="regenOpen = false; showCodesOpen = true; toast.ok('10 new codes generated')">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Generate 10 new codes
</UiButton>
</template>
</Modal>
<!-- Export sign-in history modal -->
<Modal :open="exportOpen" eyebrow="Sign-in history · export" title="Export sign-in history" size="md" @close="exportOpen = false">
<div class="modal-stack">
<div>
<Eyebrow style="display: block; margin-bottom: 8px;">Period</Eyebrow>
<div class="period">
<button
v-for="p in [
{ v: '30d', l: '30 days' },
{ v: '90d', l: '90 days' },
{ v: '12mo', l: '12 months' },
{ v: 'all', l: 'All time' },
]"
:key="p.v"
:class="{ active: exportPeriod === p.v }"
@click="exportPeriod = p.v as typeof exportPeriod"
>{{ p.l }}</button>
</div>
</div>
<EnduserFormField label="Format">
<div class="seg">
<button :class="{ active: exportFormat === 'csv' }" @click="exportFormat = 'csv'">CSV</button>
<button :class="{ active: exportFormat === 'json' }" @click="exportFormat = 'json'">JSON</button>
</div>
</EnduserFormField>
<label class="check-row">
<input type="checkbox" v-model="exportIncludeFailed" />
Include failed attempts
</label>
<div>
<Eyebrow style="display: block; margin-bottom: 8px;">Delivery</Eyebrow>
<div class="delivery">
<button
:class="{ active: exportDelivery === 'download' }"
@click="exportDelivery = 'download'"
>
<UiIcon name="download" :size="15" />
<div class="d-text">
<div class="d-l">Download now</div>
<Mono dim>browser download</Mono>
</div>
</button>
<button
:class="{ active: exportDelivery === 'email' }"
@click="exportDelivery = 'email'"
>
<UiIcon name="mail" :size="15" />
<div class="d-text">
<div class="d-l">Email to me</div>
<Mono dim>sent to anne@dezky.com</Mono>
</div>
</button>
</div>
</div>
<div class="def-block">
<Mono dim>// estimated</Mono>
<div class="def-body">~{{ exportRowEst }} rows · {{ exportIncludeFailed ? 'includes failed attempts' : 'successes only' }} · {{ exportFormat.toUpperCase() }}</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="exportOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="exportOpen = false; toast.ok(`Exported · ${exportFormat.toUpperCase()}`)">
<template #leading><UiIcon :name="exportDelivery === 'email' ? 'mail' : 'download'" :size="13" /></template>
{{ exportDelivery === 'email' ? 'Email export' : 'Download' }}
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 20px 40px 64px 40px; max-width: 900px; }
.stack { display: flex; flex-direction: column; gap: 16px; }
.card-header { margin-bottom: 16px; }
.card-header.with-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.card-header .head-text { min-width: 0; flex: 1; }
.card-header h2 { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin: 6px 0 0 0; letter-spacing: -0.015em; }
.card-header p { margin: 8px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.55; max-width: 600px; }
/* Password form */
.pwd-form { display: flex; flex-direction: column; gap: 14px; max-width: 480px; }
.pwd-input { position: relative; }
.pwd-input input { width: 100%; height: 36px; padding: 0 36px 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; font-family: inherit; color: var(--text); outline: none; box-sizing: border-box; }
.pwd-input input:focus { border-color: var(--text); background: var(--bg); }
.eye { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; padding: 6px; color: var(--text-mute); }
.eye:hover { color: var(--text); }
.strength { padding: 0; }
.strength-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.bar { height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
.bar span { display: block; height: 100%; border-radius: 999px; transition: width 0.18s, background 0.18s; }
.criteria { list-style: none; padding: 0; margin: 10px 0 0 0; display: flex; flex-direction: column; gap: 6px; }
.criteria li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-mute); }
.criteria li[data-ok='true'] :deep(svg) { color: var(--ok); }
.criteria li[data-ok='false'] :deep(svg) { color: var(--bad); opacity: 0.55; }
.actions { display: flex; align-items: center; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); }
.flash-ok { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 13px; }
.footer-note { margin-top: 14px; font-size: 12px; color: var(--text-mute); line-height: 1.6; }
/* Backup recovery email */
.recovery { display: flex; align-items: center; gap: 12px; }
.email-input {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
flex: 1; max-width: 360px;
}
.email-input input {
flex: 1; border: none; outline: none; background: transparent;
font-size: 13px; color: var(--text); font-family: inherit;
}
.email-input :deep(svg) { color: var(--text-mute); }
/* Callouts */
.callout-info {
margin-top: 14px;
padding: 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
font-size: 12px; color: var(--text-dim); line-height: 1.55;
display: flex; gap: 10px; align-items: flex-start;
}
.callout-info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
.callout-bad {
padding: 14px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.20);
border-radius: 6px;
display: flex; gap: 10px;
font-size: 13px; color: var(--text-dim); line-height: 1.5;
}
.callout-bad :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
.callout-warn {
padding: 14px;
background: rgba(232, 154, 31, 0.06);
border: 1px solid rgba(232, 154, 31, 0.24);
border-left: 3px solid var(--warn);
border-radius: 6px;
display: flex; gap: 12px; align-items: flex-start;
font-size: 13px; color: var(--text-dim); line-height: 1.6;
}
.callout-warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
.cw-body { flex: 1; }
.cw-body b { color: var(--text); }
.callout-bad-strong {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px;
background: rgba(226, 48, 48, 0.04);
border: 1px solid rgba(226, 48, 48, 0.18);
border-left: 3px solid var(--bad);
border-radius: 6px;
}
.callout-bad-strong :deep(svg) { color: var(--bad); flex-shrink: 0; margin-top: 2px; }
.cb-text { flex: 1; font-size: 13px; }
.cb-title { font-weight: 600; }
.cb-text :deep(.mono) { color: var(--text-mute); margin-top: 4px; }
/* MFA */
.mfa-on { display: flex; align-items: center; gap: 14px; }
.mfa-shield { width: 44px; height: 44px; border-radius: 10px; background: rgba(31, 138, 91, 0.12); color: var(--ok); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mfa-on h3 { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin: 0; }
.mfa-on p { margin: 4px 0 0 0; font-size: 13px; color: var(--text-mute); }
.mfa-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); }
.mfa-list { list-style: none; padding: 0; margin: 0; }
.mfa-list > li {
display: flex; align-items: center; gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
}
.mfa-list > li:last-child { border-bottom: none; }
.mfa-icon {
width: 36px; height: 36px; border-radius: 8px;
background: var(--bg); border: 1px solid var(--border);
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-dim); flex-shrink: 0;
}
.mfa-text { flex: 1; min-width: 0; }
.mfa-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mfa-name { font-size: 13px; font-weight: 500; }
.mfa-trigger { display: inline-flex; }
.mfa-add {
padding: 16px 20px;
background: var(--bg);
display: flex; gap: 8px;
}
/* Portaled MFA action menu */
.mfa-menu {
position: fixed;
min-width: 240px;
padding: 4px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
z-index: 100;
}
.mfa-menu button {
display: flex; align-items: center; gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 5px;
background: transparent; border: none;
cursor: pointer;
font-family: inherit; font-size: 13px; text-align: left;
color: var(--text);
}
.mfa-menu button:hover:not(:disabled) { background: var(--row-hover); }
.mfa-menu button:disabled { opacity: 0.5; cursor: not-allowed; color: var(--text-mute); }
.mfa-menu .danger { color: var(--bad); }
.mfa-menu .sep { display: block; height: 1px; background: var(--border); margin: 4px 0; }
/* Recovery codes */
.codes-summary {
display: flex; align-items: center; gap: 18px;
padding: 16px;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
margin-bottom: 16px;
}
.codes-tile {
width: 40px; height: 40px; border-radius: 8px;
background: var(--surface);
display: inline-flex; align-items: center; justify-content: center;
color: var(--text-dim); flex-shrink: 0;
}
.codes-meta { flex: 1; }
.codes-title { font-size: 14px; font-weight: 500; }
.codes-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
margin-top: 18px;
padding: 18px;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
}
.code {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 5px;
font-family: var(--font-mono); font-size: 14px; font-weight: 600;
}
.code.used { opacity: 0.35; text-decoration: line-through; }
.code-idx { font-size: 10px; color: var(--text-mute); min-width: 16px; }
.code-val { flex: 1; letter-spacing: 0.04em; }
/* History */
.hist-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.hist-intro { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
.history { width: 100%; border-collapse: collapse; }
.history thead th {
text-align: left;
padding: 12px 22px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.history tbody td {
padding: 12px 22px;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
.history tbody tr:last-child td { border-bottom: none; }
.history .unknown { color: var(--warn); }
/* Modal body */
.modal-body { margin: 0 0 14px 0; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
.modal-stack { display: flex; flex-direction: column; gap: 14px; }
.def-block { padding: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--text-dim); line-height: 1.55; }
.def-body { margin-top: 6px; }
.def-block dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.def-block dl > div { display: flex; gap: 12px; }
.def-block dt { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-mute); width: 110px; flex-shrink: 0; }
.def-block dd { margin: 0; font-size: 13px; color: var(--text); }
/* MFA setup wizard inner */
.wait-key { text-align: center; padding: 12px 0; }
.key-circle {
width: 120px; height: 120px; border-radius: 999px;
background: rgba(212, 255, 58, 0.10);
border: 2px solid var(--accent);
margin: 0 auto 18px auto;
display: inline-flex; align-items: center; justify-content: center;
animation: keyPulse 1.4s ease-in-out infinite;
}
.key-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
@keyframes keyPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.04); opacity: 0.85; } }
.qr-setup { display: flex; gap: 18px; align-items: center; padding: 20px; background: var(--bg); border-radius: 8px; margin-top: 8px; margin-bottom: 14px; }
.qr-big {
width: 84px; height: 84px;
display: grid; grid-template-columns: repeat(21, 1fr); gap: 0;
background: #fff; padding: 6px; border-radius: 6px; flex-shrink: 0;
}
.qr-big span { display: block; aspect-ratio: 1; }
.qr-meta { flex: 1; }
.qr-key { font-family: var(--font-mono); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; margin: 6px 0 10px 0; }
.success { display: flex; align-items: center; gap: 14px; }
.success-tile { width: 44px; height: 44px; border-radius: 10px; background: var(--accent); color: var(--accent-fg); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.success h3 { font-family: var(--font-display); font-weight: 600; font-size: 18px; margin: 0; }
.success-note { margin-top: 20px; padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
.check-row { display: flex; align-items: center; gap: 10px; font-size: 13px; cursor: pointer; }
.check-row input { width: 16px; height: 16px; accent-color: var(--text); }
/* Period / format chip groups */
.period { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.period button {
padding: 8px 0; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
}
.period button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.seg { display: flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; background: var(--surface); }
.seg button {
flex: 1; padding: 6px 0; border: none; border-radius: 4px;
background: transparent; color: var(--text);
font-family: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
}
.seg button.active { background: var(--text); color: var(--bg); }
.delivery { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.delivery button {
padding: 12px; border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface); color: var(--text);
font-family: inherit; cursor: pointer;
display: flex; align-items: center; gap: 10px;
text-align: left;
}
.delivery button.active { border-color: var(--text); background: var(--bg); }
.d-text { flex: 1; }
.d-l { font-size: 13px; font-weight: 500; }
</style>