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:
@@ -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 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>
|
||||
Reference in New Issue
Block a user