0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
948 lines
41 KiB
Vue
948 lines
41 KiB
Vue
<script setup lang="ts">
|
||
// Security · password / two-factor / recovery codes / sign-in history.
|
||
// Faithfully ports project/platform-enduser.jsx `SecurityEndUserScreen`
|
||
// (lines 352–971) 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>
|