Files
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00

317 lines
14 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Devices & sessions. Faithfully ports project/platform-enduser.jsx
// `DevicesScreen` lines 37233. 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>