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,121 @@
|
||||
<script setup lang="ts">
|
||||
// "..." menu for a single device row. The menu is teleported to <body> so it
|
||||
// escapes any overflow/clip on the table — same pattern as the React design
|
||||
// source. Closes on outside-click, Escape, or scroll.
|
||||
|
||||
interface DeviceLike {
|
||||
id: string
|
||||
current?: boolean
|
||||
trusted?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ device: DeviceLike }>()
|
||||
const emit = defineEmits<{
|
||||
rename: [DeviceLike]
|
||||
trust: [DeviceLike]
|
||||
history: [DeviceLike]
|
||||
revoke: [DeviceLike]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const pos = ref({ top: 0, right: 0 })
|
||||
|
||||
function toggle(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (!triggerRef.value) return
|
||||
const r = triggerRef.value.getBoundingClientRect()
|
||||
pos.value = { top: r.bottom + 4, right: window.innerWidth - r.right }
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function close() { open.value = false }
|
||||
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (!open.value) return
|
||||
const t = e.target as Node
|
||||
if (menuRef.value?.contains(t) || triggerRef.value?.contains(t)) return
|
||||
open.value = false
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') open.value = false
|
||||
}
|
||||
function onScroll() { open.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onDoc)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="triggerRef" class="more-wrap">
|
||||
<UiButton size="sm" variant="ghost" @click="toggle">
|
||||
<UiIcon name="more" :size="14" />
|
||||
</UiButton>
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<Transition name="pop">
|
||||
<div v-if="open" ref="menuRef" class="menu" :style="{ top: pos.top + 'px', right: pos.right + 'px' }">
|
||||
<button @click="close(); emit('rename', device)"><UiIcon name="brush" :size="14" /> Rename device</button>
|
||||
<button @click="close(); emit('trust', device)"><UiIcon name="shield" :size="14" /> {{ device.trusted ? 'Distrust device' : 'Trust this device · skip MFA for 30d' }}</button>
|
||||
<button @click="close(); emit('history', device)"><UiIcon name="file" :size="14" /> View sign-in history</button>
|
||||
<span class="sep" />
|
||||
<button
|
||||
class="danger"
|
||||
:disabled="device.current"
|
||||
@click="close(); !device.current && emit('revoke', device)"
|
||||
>
|
||||
<UiIcon name="logout" :size="14" />
|
||||
{{ device.current ? 'Cannot revoke current device' : 'Revoke session' }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.more-wrap { display: inline-flex; }
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
min-width: 260px;
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.menu button:hover { background: var(--row-hover); }
|
||||
.menu button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.menu .danger { color: var(--bad); }
|
||||
.menu .sep { display: block; height: 1px; background: var(--border); margin: 4px 0; }
|
||||
|
||||
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
|
||||
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// Simple label + slot wrapper used across the profile/security pages.
|
||||
defineProps<{ label: string; hint?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="field">
|
||||
<span class="label">{{ label }}</span>
|
||||
<slot />
|
||||
<span v-if="hint" class="hint">{{ hint }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.field :deep(input),
|
||||
.field :deep(textarea),
|
||||
.field :deep(select) {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.field :deep(textarea) {
|
||||
min-height: 110px;
|
||||
padding: 10px 12px;
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.field :deep(input:focus),
|
||||
.field :deep(textarea:focus),
|
||||
.field :deep(select:focus) {
|
||||
border-color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
.field :deep(input:disabled) { background: var(--bg); color: var(--text-mute); cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
// Presence selector for the dashboard greeting strip. Drop-down trigger styled
|
||||
// as a pill with a status dot, label, and short hint. Visual only — no API call.
|
||||
|
||||
type Presence = 'available' | 'meeting' | 'focus' | 'away'
|
||||
|
||||
interface PresenceOption {
|
||||
value: Presence
|
||||
label: string
|
||||
hint: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const opts: PresenceOption[] = [
|
||||
{ value: 'available', label: 'Available', hint: 'visible to teammates', color: 'var(--ok)' },
|
||||
{ value: 'meeting', label: 'In a meeting', hint: 'silenced · auto-clears', color: 'var(--warn)' },
|
||||
{ value: 'focus', label: 'Focus', hint: 'no notifications', color: 'var(--info)' },
|
||||
{ value: 'away', label: 'Away', hint: 'be right back', color: 'var(--text-mute)' },
|
||||
]
|
||||
|
||||
const props = defineProps<{ modelValue: Presence }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [Presence] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0])
|
||||
|
||||
function pick(v: Presence) {
|
||||
emit('update:modelValue', v)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!rootRef.value) return
|
||||
if (!rootRef.value.contains(e.target as Node)) open.value = false
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="presence">
|
||||
<button class="trigger" @click="open = !open" :aria-expanded="open">
|
||||
<StatusDot :color="current.color" :size="8" />
|
||||
<div class="trigger-text">
|
||||
<span class="trigger-label">{{ current.label }}</span>
|
||||
<span class="trigger-hint">{{ current.hint }}</span>
|
||||
</div>
|
||||
<UiIcon name="chevDown" :size="12" />
|
||||
</button>
|
||||
<Transition name="pop">
|
||||
<div v-if="open" class="menu">
|
||||
<button
|
||||
v-for="o in opts"
|
||||
:key="o.value"
|
||||
class="opt"
|
||||
:class="{ active: o.value === modelValue }"
|
||||
@click="pick(o.value)"
|
||||
>
|
||||
<StatusDot :color="o.color" :size="8" />
|
||||
<div class="opt-text">
|
||||
<span class="opt-label">{{ o.label }}</span>
|
||||
<span class="opt-hint">{{ o.hint }}</span>
|
||||
</div>
|
||||
<UiIcon v-if="o.value === modelValue" name="check" :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.presence { position: relative; }
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
.trigger:hover { border-color: var(--border-hi); }
|
||||
|
||||
.trigger-text { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
|
||||
.trigger-label { font-size: 13px; font-weight: 500; }
|
||||
.trigger-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
padding: 4px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.opt:hover, .opt.active { background: var(--row-hover); }
|
||||
.opt-text { display: flex; flex-direction: column; flex: 1; gap: 1px; }
|
||||
.opt-label { font-size: 13px; font-weight: 500; }
|
||||
.opt-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
|
||||
|
||||
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
|
||||
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
// Sticky save bar — appears at bottom-center of the page when any field
|
||||
// in the active tab is dirty. Dark pill with Discard + Save changes.
|
||||
|
||||
defineProps<{ dirty: boolean }>()
|
||||
defineEmits<{ discard: []; save: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="lift">
|
||||
<div v-if="dirty" class="save-bar">
|
||||
<span class="pulse" />
|
||||
<div class="text">
|
||||
<span class="t1">You have unsaved changes</span>
|
||||
<span class="t2">changes are queued · not yet applied</span>
|
||||
</div>
|
||||
<button class="discard" @click="$emit('discard')">Discard</button>
|
||||
<button class="save" @click="$emit('save')">
|
||||
<UiIcon name="check" :size="13" :stroke-width="2.4" />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 420px;
|
||||
padding: 12px 14px 12px 20px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28);
|
||||
z-index: 70;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 6px; height: 6px; border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(212, 255, 58, 0.18);
|
||||
}
|
||||
|
||||
.text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.t1 { font-size: 13px; font-weight: 500; }
|
||||
.t2 { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; opacity: 0.55; }
|
||||
|
||||
.discard, .save {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.discard { background: transparent; color: var(--bg); opacity: 0.7; }
|
||||
.discard:hover { opacity: 1; }
|
||||
|
||||
.save {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.save:hover { filter: brightness(0.94); }
|
||||
|
||||
.lift-enter-active, .lift-leave-active {
|
||||
transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.18s;
|
||||
}
|
||||
.lift-enter-from, .lift-leave-to { transform: translate(-50%, 24px); opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// Compact toggle pill used inside profile/notification cards.
|
||||
const props = defineProps<{ modelValue: boolean; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
||||
|
||||
function flip() {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="toggle"
|
||||
:data-on="modelValue"
|
||||
:disabled="disabled"
|
||||
:aria-pressed="modelValue"
|
||||
@click="flip"
|
||||
>
|
||||
<span class="knob" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toggle {
|
||||
width: 38px; height: 22px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.toggle[data-on='true'] { background: var(--text); border-color: var(--text); }
|
||||
.toggle:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
transition: transform 0.18s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.toggle[data-on='true'] .knob { transform: translateX(16px); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user