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,316 @@
|
||||
<script setup lang="ts">
|
||||
// Devices & sessions. Faithfully ports project/platform-enduser.jsx
|
||||
// `DevicesScreen` lines 37–233. Two grouped sections: Desktop / Mobile &
|
||||
// tablet. Per-row "..." menu is portaled (see EnduserDeviceActions).
|
||||
|
||||
|
||||
import { devices } from '~/data/enduser'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Source groups desktop (kind === 'desktop') vs mobile + tablet. Our `devices`
|
||||
// fixture uses `laptop` for desktop, plus `phone` / `tablet`.
|
||||
const desktops = computed(() => devices.filter((d) => d.kind === 'laptop'))
|
||||
const mobiles = computed(() => devices.filter((d) => d.kind === 'phone' || d.kind === 'tablet'))
|
||||
|
||||
const signOutOpen = ref(false)
|
||||
const keepCurrent = ref(true)
|
||||
const forceMfa = ref(false)
|
||||
const renameDevice = ref<typeof devices[number] | null>(null)
|
||||
const renameValue = ref('')
|
||||
const revokeDevice = ref<typeof devices[number] | null>(null)
|
||||
|
||||
function onRename(d: typeof devices[number]) {
|
||||
renameDevice.value = d
|
||||
renameValue.value = d.label
|
||||
}
|
||||
function onRevoke(d: typeof devices[number]) {
|
||||
if (d.current) return
|
||||
revokeDevice.value = d
|
||||
}
|
||||
function confirmRevoke() {
|
||||
toast.warn(`Revoked ${revokeDevice.value?.label}`, 'Session ended within 30s')
|
||||
revokeDevice.value = null
|
||||
}
|
||||
function confirmRename() {
|
||||
toast.ok(`Renamed to "${renameValue.value}"`)
|
||||
renameDevice.value = null
|
||||
}
|
||||
function confirmSignOutAll() {
|
||||
signOutOpen.value = false
|
||||
const n = keepCurrent.value ? devices.length - 1 : devices.length
|
||||
toast.warn(`Signed out ${n} sessions`, forceMfa.value ? 'MFA re-enrolment required on next sign-in' : '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Account"
|
||||
title="Devices & sessions"
|
||||
subtitle="Everywhere you've signed into dezky. Revoke anything you don't recognize."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="danger" @click="signOutOpen = true">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Sign out everywhere
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<!-- Intro card · "Currently signed in" -->
|
||||
<Card style="margin-bottom: 16px;">
|
||||
<div class="intro">
|
||||
<div>
|
||||
<Eyebrow>Currently signed in</Eyebrow>
|
||||
<h3>{{ devices.length }} sessions across {{ desktops.length }} desktops and {{ mobiles.length }} mobile / tablet</h3>
|
||||
</div>
|
||||
<Mono dim>last refresh · now</Mono>
|
||||
</div>
|
||||
<p class="intro-body">
|
||||
We track every active session. If you sign out everywhere, you'll need to sign in again on each device — including this one.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Desktop -->
|
||||
<div class="section-label">
|
||||
<UiIcon name="device" :size="13" stroke="var(--text-mute)" />
|
||||
<Mono dim>Desktop · {{ desktops.length }}</Mono>
|
||||
</div>
|
||||
<ul class="device-list">
|
||||
<li v-for="d in desktops" :key="d.id">
|
||||
<Card :pad="16">
|
||||
<div class="device">
|
||||
<div class="device-icon">
|
||||
<span class="laptop" />
|
||||
</div>
|
||||
<div class="device-text">
|
||||
<div class="device-row">
|
||||
<span class="device-name">{{ d.label }}</span>
|
||||
<Mono dim>{{ d.os }}</Mono>
|
||||
<Badge v-if="d.current" tone="ok" dot>this device</Badge>
|
||||
<Badge v-if="d.trusted && !d.current" tone="info">trusted</Badge>
|
||||
<Badge v-if="d.stale" tone="warn">inactive</Badge>
|
||||
</div>
|
||||
<div class="device-meta">
|
||||
<Mono>{{ d.app }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.location }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.ip }}</Mono>
|
||||
<span>·</span>
|
||||
<span>active {{ d.lastActive }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton v-if="!d.current" size="sm" variant="ghost" @click="onRevoke(d)">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Revoke
|
||||
</UiButton>
|
||||
<EnduserDeviceActions
|
||||
:device="d"
|
||||
@rename="onRename"
|
||||
@trust="toast.ok(`${$event.label} ${$event.trusted ? 'untrusted' : 'trusted'}`)"
|
||||
@history="toast.info(`Viewing history of ${$event.label}`)"
|
||||
@revoke="onRevoke"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Mobile & tablet -->
|
||||
<div class="section-label" style="margin-top: 24px;">
|
||||
<UiIcon name="device" :size="13" stroke="var(--text-mute)" />
|
||||
<Mono dim>Mobile & tablet · {{ mobiles.length }}</Mono>
|
||||
</div>
|
||||
<ul class="device-list">
|
||||
<li v-for="d in mobiles" :key="d.id">
|
||||
<Card :pad="16">
|
||||
<div class="device">
|
||||
<div class="device-icon">
|
||||
<span :class="d.kind === 'tablet' ? 'tablet' : 'phone'" />
|
||||
</div>
|
||||
<div class="device-text">
|
||||
<div class="device-row">
|
||||
<span class="device-name">{{ d.label }}</span>
|
||||
<Mono dim>{{ d.os }}</Mono>
|
||||
<Badge v-if="d.trusted" tone="info">trusted</Badge>
|
||||
</div>
|
||||
<div class="device-meta">
|
||||
<Mono>{{ d.app }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.location }}</Mono>
|
||||
<span>·</span>
|
||||
<Mono>{{ d.ip }}</Mono>
|
||||
<span>·</span>
|
||||
<span>active {{ d.lastActive }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="onRevoke(d)">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Revoke
|
||||
</UiButton>
|
||||
<EnduserDeviceActions
|
||||
:device="d"
|
||||
@rename="onRename"
|
||||
@trust="toast.ok(`${$event.label} ${$event.trusted ? 'untrusted' : 'trusted'}`)"
|
||||
@history="toast.info(`Viewing history of ${$event.label}`)"
|
||||
@revoke="onRevoke"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Sign-out everywhere modal -->
|
||||
<Modal :open="signOutOpen" eyebrow="Destructive · all sessions" title="Sign out everywhere?" size="md" @close="signOutOpen = false">
|
||||
<div class="modal-stack">
|
||||
<div class="callout-bad">
|
||||
<UiIcon name="shield" :size="16" />
|
||||
<div>All other sessions will be revoked immediately. On each device, you'll need to sign in again with your password and MFA. Anyone using a stolen session token will lose access.</div>
|
||||
</div>
|
||||
<div class="rows">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="row-title">Keep this device signed in</div>
|
||||
<Mono dim>recommended · you won't get locked out</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="keepCurrent" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="row-title">Force MFA re-enrolment</div>
|
||||
<Mono dim>use if you think your authenticator was compromised</Mono>
|
||||
</div>
|
||||
<EnduserToggle v-model="forceMfa" />
|
||||
</div>
|
||||
</div>
|
||||
<Mono dim>any pending file uploads or chat messages on the other devices will be lost</Mono>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="signOutOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="confirmSignOutAll">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
{{ keepCurrent ? `Sign out ${devices.length - 1} other sessions` : `Sign out all ${devices.length} sessions` }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Rename modal -->
|
||||
<Modal :open="renameDevice !== null" eyebrow="Device · rename" :title="renameDevice ? `Rename ${renameDevice.label}` : ''" size="sm" @close="renameDevice = null">
|
||||
<EnduserFormField label="Device name">
|
||||
<input v-model="renameValue" placeholder="e.g. Work laptop, Anne's iPhone" />
|
||||
</EnduserFormField>
|
||||
<Mono dim style="margin-top: 10px; display: block;">shown to you here and on the workspace audit log · co-workers do not see it</Mono>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="renameDevice = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="!renameValue.trim()" @click="confirmRename">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save name
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Revoke confirm (Modal · matches source DefList layout) -->
|
||||
<Modal :open="revokeDevice !== null" eyebrow="Revoke session" :title="revokeDevice ? `Sign out ${revokeDevice.label}?` : ''" size="md" @close="revokeDevice = null">
|
||||
<div class="revoke-stack">
|
||||
<div class="revoke-detail">
|
||||
<dl>
|
||||
<div><dt>Device</dt><dd>{{ revokeDevice?.label }}</dd></div>
|
||||
<div><dt>OS</dt><dd>{{ revokeDevice?.os }}</dd></div>
|
||||
<div><dt>App</dt><dd>{{ revokeDevice?.app }}</dd></div>
|
||||
<div><dt>Location</dt><dd>{{ revokeDevice?.location }} · {{ revokeDevice?.ip }}</dd></div>
|
||||
<div><dt>Last active</dt><dd>{{ revokeDevice?.lastActive }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<p class="revoke-text">
|
||||
This device will be signed out within 30 seconds. The person using it will be returned to the sign-in screen and any unsaved work in the app may be lost.
|
||||
</p>
|
||||
<div v-if="revokeDevice?.trusted" class="callout-warn">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<div>This is a <b>trusted device</b>. Revoking removes the trust — next sign-in will require MFA.</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="revokeDevice = null">Cancel</UiButton>
|
||||
<UiButton variant="danger" @click="confirmRevoke">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Sign out device
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 20px 40px 64px 40px; max-width: 1000px; }
|
||||
|
||||
.intro { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
|
||||
.intro h3 { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin: 4px 0 0 0; }
|
||||
.intro-body { margin: 12px 0 0 0; font-size: 13px; color: var(--text-mute); line-height: 1.6; }
|
||||
|
||||
.section-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--text-mute); }
|
||||
|
||||
.device-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.device { display: flex; align-items: center; gap: 14px; }
|
||||
.device-icon {
|
||||
width: 48px; height: 48px; 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;
|
||||
}
|
||||
/* Source CSS device glyphs — laptop = rounded rect + base nub,
|
||||
phone = tall portrait rect, tablet = wider rect. */
|
||||
.laptop { width: 38px; height: 26px; border: 1.5px solid currentColor; border-radius: 4px; position: relative; }
|
||||
.laptop::after { content: ''; position: absolute; left: 50%; bottom: -5px; transform: translateX(-50%); width: 14px; height: 2px; background: currentColor; border-radius: 1px; }
|
||||
.phone { width: 22px; height: 36px; border: 1.5px solid currentColor; border-radius: 12px; position: relative; }
|
||||
.phone::after { content: ''; position: absolute; left: 50%; bottom: 3px; transform: translateX(-50%); width: 8px; height: 1px; background: currentColor; opacity: 0.6; border-radius: 1px; }
|
||||
.tablet { width: 30px; height: 36px; border: 1.5px solid currentColor; border-radius: 10px; position: relative; }
|
||||
.tablet::after { content: ''; position: absolute; left: 50%; bottom: 3px; transform: translateX(-50%); width: 8px; height: 1px; background: currentColor; opacity: 0.6; border-radius: 1px; }
|
||||
|
||||
.device-text { flex: 1; min-width: 0; }
|
||||
.device-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.device-name { font-size: 14px; font-weight: 500; }
|
||||
.device-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--text-mute); flex-wrap: wrap; }
|
||||
|
||||
.modal-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.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: 12px;
|
||||
background: rgba(232, 154, 31, 0.06);
|
||||
border: 1px solid rgba(232, 154, 31, 0.20);
|
||||
border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-dim); line-height: 1.55;
|
||||
display: flex; gap: 10px;
|
||||
}
|
||||
.callout-warn :deep(svg) { color: var(--warn); margin-top: 2px; flex-shrink: 0; }
|
||||
|
||||
.rows {
|
||||
padding: 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.row + .row { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.row-title { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.revoke-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.revoke-detail { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.revoke-detail dl { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.revoke-detail dl > div { display: flex; gap: 12px; }
|
||||
.revoke-detail 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; }
|
||||
.revoke-detail dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
.revoke-text { margin: 0; font-size: 13px; line-height: 1.6; color: var(--text-dim); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user