Files
Ronni Baslund 0bd4e5498e 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
2026-05-28 20:00:33 +02:00

948 lines
41 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>