From 0bd4e5498eb7b59ff7dafa8b6e80588421d99cfb Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Thu, 28 May 2026 20:00:33 +0200 Subject: [PATCH] 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 --- .../components/InvitePartnerUserModal.vue | 312 ++++++ apps/operator/components/OpSidebar.vue | 1 + apps/operator/components/ToastStack.vue | 74 ++ apps/operator/components/UserMenu.vue | 8 +- apps/operator/composables/useMe.ts | 54 + apps/operator/composables/useToast.ts | 37 + apps/operator/layouts/default.vue | 1 + apps/operator/nuxt.config.ts | 22 + apps/operator/pages/partners/[slug].vue | 78 +- apps/operator/pages/pricing.vue | 346 +++++++ apps/operator/pages/signed-out.vue | 112 +++ apps/operator/server/api/auth/sign-out.get.ts | 35 + apps/operator/server/api/me.get.ts | 26 + .../server/api/partners/[slug]/users.get.ts | 6 + .../server/api/partners/[slug]/users.post.ts | 7 + .../operator/server/api/prices/[id].delete.ts | 6 + apps/operator/server/api/prices/[id].patch.ts | 7 + apps/operator/server/api/prices/index.get.ts | 7 + apps/operator/server/api/prices/index.post.ts | 6 + apps/portal/app.vue | 4 +- apps/portal/assets/styles/base.css | 9 + apps/portal/assets/styles/tokens.css | 38 +- apps/portal/components/AppLauncher.vue | 215 ++++ apps/portal/components/Avatar.vue | 48 + apps/portal/components/Badge.vue | 39 + apps/portal/components/Card.vue | 16 + apps/portal/components/ConfirmDialog.vue | 126 +++ apps/portal/components/CustomerModeBanner.vue | 76 ++ apps/portal/components/Eyebrow.vue | 16 + apps/portal/components/MetricCell.vue | 32 + apps/portal/components/Modal.vue | 124 +++ apps/portal/components/Mono.vue | 12 + apps/portal/components/NotificationDrawer.vue | 55 + apps/portal/components/PageHeader.vue | 47 + apps/portal/components/PortalSidebar.vue | 444 ++++++++ apps/portal/components/PortalTopbar.vue | 285 ++++++ apps/portal/components/PortalTweaksPanel.vue | 194 ++++ apps/portal/components/PortalUserMenu.vue | 251 +++++ apps/portal/components/SidePanel.vue | 138 +++ apps/portal/components/Stat.vue | 45 + apps/portal/components/StatusDot.vue | 22 + apps/portal/components/Tabs.vue | 63 ++ apps/portal/components/ToastStack.vue | 74 ++ apps/portal/components/UiButton.vue | 54 + apps/portal/components/UiIcon.vue | 85 +- apps/portal/components/admin/FilterChip.vue | 93 ++ apps/portal/components/admin/KebabMenu.vue | 165 +++ .../enduser/EnduserDeviceActions.vue | 121 +++ .../components/enduser/EnduserFormField.vue | 59 ++ .../enduser/EnduserPresenceSelector.vue | 129 +++ .../components/enduser/EnduserSaveBar.vue | 83 ++ .../components/enduser/EnduserToggle.vue | 47 + .../partner/CustomerCreateWizard.vue | 737 ++++++++++++++ .../components/partner/CustomerTaskPanel.vue | 246 +++++ .../components/partner/EditIdentityModal.vue | 194 ++++ .../partner/EmailTemplateEditor.vue | 204 ++++ .../partner/EnterCustomerConfirmModal.vue | 116 +++ .../partner/InviteTeammateModal.vue | 295 ++++++ .../partner/NewCustomReportModal.vue | 297 ++++++ apps/portal/components/partner/Sparkline.vue | 50 + .../components/partner/TeammatePanel.vue | 358 +++++++ apps/portal/composables/useAppLauncher.ts | 17 + apps/portal/composables/useMe.ts | 51 + .../composables/useNotificationDrawer.ts | 29 + apps/portal/composables/usePartnerMode.ts | 41 + apps/portal/composables/usePortalTweaks.ts | 88 ++ apps/portal/composables/useSidebar.ts | 11 + apps/portal/composables/useToast.ts | 37 + apps/portal/data/customers.ts | 100 ++ apps/portal/data/enduser.ts | 108 ++ apps/portal/data/notifications.ts | 63 ++ apps/portal/data/workspace.ts | 256 +++++ apps/portal/layouts/blank.vue | 3 + apps/portal/layouts/default.vue | 69 ++ .../middleware/partner-routing.global.ts | 25 + apps/portal/nuxt.config.ts | 49 +- apps/portal/package.json | 2 +- apps/portal/pages/admin/billing.vue | 569 +++++++++++ apps/portal/pages/admin/branding.vue | 784 +++++++++++++++ apps/portal/pages/admin/chat.vue | 403 ++++++++ apps/portal/pages/admin/domains.vue | 319 ++++++ apps/portal/pages/admin/domains/add.vue | 430 ++++++++ apps/portal/pages/admin/index.vue | 461 +++++++++ apps/portal/pages/admin/integrations.vue | 506 ++++++++++ apps/portal/pages/admin/mail.vue | 559 +++++++++++ apps/portal/pages/admin/meetings.vue | 380 +++++++ apps/portal/pages/admin/security.vue | 400 ++++++++ apps/portal/pages/admin/storage.vue | 108 ++ apps/portal/pages/admin/users.vue | 856 ++++++++++++++++ apps/portal/pages/auth/disabled.vue | 2 +- apps/portal/pages/auth/expired.vue | 2 +- apps/portal/pages/auth/login.vue | 7 +- apps/portal/pages/devices.vue | 316 ++++++ apps/portal/pages/help.vue | 484 +++++++++ apps/portal/pages/index.vue | 766 +++++++++++--- apps/portal/pages/partner/audit.vue | 407 ++++++++ apps/portal/pages/partner/billing.vue | 302 ++++++ apps/portal/pages/partner/branding.vue | 250 +++++ apps/portal/pages/partner/customers.vue | 530 ++++++++++ apps/portal/pages/partner/index.vue | 573 +++++++++++ apps/portal/pages/partner/reports.vue | 764 ++++++++++++++ apps/portal/pages/partner/settings.vue | 326 ++++++ apps/portal/pages/partner/team.vue | 286 ++++++ apps/portal/pages/profile.vue | 904 +++++++++++++++++ apps/portal/pages/security.vue | 947 ++++++++++++++++++ apps/portal/pages/signed-out.vue | 57 ++ apps/portal/server/api/auth/sign-out.get.ts | 42 + .../portal/server/api/partner/activity.get.ts | 28 + apps/portal/server/api/partner/mrr.get.ts | 23 + apps/portal/server/api/partner/tenants.get.ts | 23 + .../portal/server/api/partner/tenants.post.ts | 27 + apps/portal/server/api/partner/users.get.ts | 25 + apps/portal/server/api/prices.get.ts | 24 + docs/NEXT-STEPS.md | 32 + .../configs/authentik/rebrand-web.sh | 36 + .../docker-compose/docker-compose.yml | 29 +- packages/ui/components/CountrySelect.vue | 340 +++++++ packages/ui/package.json | 7 + services/platform-api/src/app.module.ts | 4 + .../platform-api/src/audit/audit.service.ts | 21 + .../src/integrations/authentik.client.ts | 6 + services/platform-api/src/me/me.module.ts | 22 + .../src/me/partner-me.controller.ts | 141 +++ .../src/partners/partners.controller.ts | 30 + .../src/partners/partners.module.ts | 7 +- .../src/prices/dto/create-price.dto.ts | 24 + .../src/prices/dto/update-price.dto.ts | 20 + .../src/prices/prices.controller.ts | 38 + .../platform-api/src/prices/prices.module.ts | 17 + .../platform-api/src/prices/prices.service.ts | 154 +++ .../platform-api/src/schemas/price.schema.ts | 55 + .../src/schemas/subscription.schema.ts | 30 + .../platform-api/src/schemas/tenant.schema.ts | 8 + .../platform-api/src/schemas/user.schema.ts | 9 + services/platform-api/src/seed/seed.module.ts | 2 + .../platform-api/src/seed/seed.service.ts | 7 + .../src/tenants/dto/create-tenant.dto.ts | 31 + .../src/tenants/dto/update-tenant.dto.ts | 16 +- .../src/tenants/tenants.module.ts | 7 + .../src/tenants/tenants.service.ts | 40 +- .../src/users/dto/invite-partner-user.dto.ts | 16 + .../src/users/users.controller.ts | 7 +- .../platform-api/src/users/users.module.ts | 13 + .../platform-api/src/users/users.service.ts | 460 +++++++++ 144 files changed, 22110 insertions(+), 209 deletions(-) create mode 100644 apps/operator/components/InvitePartnerUserModal.vue create mode 100644 apps/operator/components/ToastStack.vue create mode 100644 apps/operator/composables/useMe.ts create mode 100644 apps/operator/composables/useToast.ts create mode 100644 apps/operator/pages/pricing.vue create mode 100644 apps/operator/pages/signed-out.vue create mode 100644 apps/operator/server/api/auth/sign-out.get.ts create mode 100644 apps/operator/server/api/me.get.ts create mode 100644 apps/operator/server/api/partners/[slug]/users.get.ts create mode 100644 apps/operator/server/api/partners/[slug]/users.post.ts create mode 100644 apps/operator/server/api/prices/[id].delete.ts create mode 100644 apps/operator/server/api/prices/[id].patch.ts create mode 100644 apps/operator/server/api/prices/index.get.ts create mode 100644 apps/operator/server/api/prices/index.post.ts create mode 100644 apps/portal/components/AppLauncher.vue create mode 100644 apps/portal/components/Avatar.vue create mode 100644 apps/portal/components/Badge.vue create mode 100644 apps/portal/components/Card.vue create mode 100644 apps/portal/components/ConfirmDialog.vue create mode 100644 apps/portal/components/CustomerModeBanner.vue create mode 100644 apps/portal/components/Eyebrow.vue create mode 100644 apps/portal/components/MetricCell.vue create mode 100644 apps/portal/components/Modal.vue create mode 100644 apps/portal/components/Mono.vue create mode 100644 apps/portal/components/NotificationDrawer.vue create mode 100644 apps/portal/components/PageHeader.vue create mode 100644 apps/portal/components/PortalSidebar.vue create mode 100644 apps/portal/components/PortalTopbar.vue create mode 100644 apps/portal/components/PortalTweaksPanel.vue create mode 100644 apps/portal/components/PortalUserMenu.vue create mode 100644 apps/portal/components/SidePanel.vue create mode 100644 apps/portal/components/Stat.vue create mode 100644 apps/portal/components/StatusDot.vue create mode 100644 apps/portal/components/Tabs.vue create mode 100644 apps/portal/components/ToastStack.vue create mode 100644 apps/portal/components/UiButton.vue create mode 100644 apps/portal/components/admin/FilterChip.vue create mode 100644 apps/portal/components/admin/KebabMenu.vue create mode 100644 apps/portal/components/enduser/EnduserDeviceActions.vue create mode 100644 apps/portal/components/enduser/EnduserFormField.vue create mode 100644 apps/portal/components/enduser/EnduserPresenceSelector.vue create mode 100644 apps/portal/components/enduser/EnduserSaveBar.vue create mode 100644 apps/portal/components/enduser/EnduserToggle.vue create mode 100644 apps/portal/components/partner/CustomerCreateWizard.vue create mode 100644 apps/portal/components/partner/CustomerTaskPanel.vue create mode 100644 apps/portal/components/partner/EditIdentityModal.vue create mode 100644 apps/portal/components/partner/EmailTemplateEditor.vue create mode 100644 apps/portal/components/partner/EnterCustomerConfirmModal.vue create mode 100644 apps/portal/components/partner/InviteTeammateModal.vue create mode 100644 apps/portal/components/partner/NewCustomReportModal.vue create mode 100644 apps/portal/components/partner/Sparkline.vue create mode 100644 apps/portal/components/partner/TeammatePanel.vue create mode 100644 apps/portal/composables/useAppLauncher.ts create mode 100644 apps/portal/composables/useMe.ts create mode 100644 apps/portal/composables/useNotificationDrawer.ts create mode 100644 apps/portal/composables/usePartnerMode.ts create mode 100644 apps/portal/composables/usePortalTweaks.ts create mode 100644 apps/portal/composables/useSidebar.ts create mode 100644 apps/portal/composables/useToast.ts create mode 100644 apps/portal/data/customers.ts create mode 100644 apps/portal/data/enduser.ts create mode 100644 apps/portal/data/notifications.ts create mode 100644 apps/portal/data/workspace.ts create mode 100644 apps/portal/layouts/blank.vue create mode 100644 apps/portal/layouts/default.vue create mode 100644 apps/portal/middleware/partner-routing.global.ts create mode 100644 apps/portal/pages/admin/billing.vue create mode 100644 apps/portal/pages/admin/branding.vue create mode 100644 apps/portal/pages/admin/chat.vue create mode 100644 apps/portal/pages/admin/domains.vue create mode 100644 apps/portal/pages/admin/domains/add.vue create mode 100644 apps/portal/pages/admin/index.vue create mode 100644 apps/portal/pages/admin/integrations.vue create mode 100644 apps/portal/pages/admin/mail.vue create mode 100644 apps/portal/pages/admin/meetings.vue create mode 100644 apps/portal/pages/admin/security.vue create mode 100644 apps/portal/pages/admin/storage.vue create mode 100644 apps/portal/pages/admin/users.vue create mode 100644 apps/portal/pages/devices.vue create mode 100644 apps/portal/pages/help.vue create mode 100644 apps/portal/pages/partner/audit.vue create mode 100644 apps/portal/pages/partner/billing.vue create mode 100644 apps/portal/pages/partner/branding.vue create mode 100644 apps/portal/pages/partner/customers.vue create mode 100644 apps/portal/pages/partner/index.vue create mode 100644 apps/portal/pages/partner/reports.vue create mode 100644 apps/portal/pages/partner/settings.vue create mode 100644 apps/portal/pages/partner/team.vue create mode 100644 apps/portal/pages/profile.vue create mode 100644 apps/portal/pages/security.vue create mode 100644 apps/portal/pages/signed-out.vue create mode 100644 apps/portal/server/api/auth/sign-out.get.ts create mode 100644 apps/portal/server/api/partner/activity.get.ts create mode 100644 apps/portal/server/api/partner/mrr.get.ts create mode 100644 apps/portal/server/api/partner/tenants.get.ts create mode 100644 apps/portal/server/api/partner/tenants.post.ts create mode 100644 apps/portal/server/api/partner/users.get.ts create mode 100644 apps/portal/server/api/prices.get.ts create mode 100755 infrastructure/docker-compose/configs/authentik/rebrand-web.sh create mode 100644 packages/ui/components/CountrySelect.vue create mode 100644 packages/ui/package.json create mode 100644 services/platform-api/src/me/me.module.ts create mode 100644 services/platform-api/src/me/partner-me.controller.ts create mode 100644 services/platform-api/src/prices/dto/create-price.dto.ts create mode 100644 services/platform-api/src/prices/dto/update-price.dto.ts create mode 100644 services/platform-api/src/prices/prices.controller.ts create mode 100644 services/platform-api/src/prices/prices.module.ts create mode 100644 services/platform-api/src/prices/prices.service.ts create mode 100644 services/platform-api/src/schemas/price.schema.ts create mode 100644 services/platform-api/src/users/dto/invite-partner-user.dto.ts diff --git a/apps/operator/components/InvitePartnerUserModal.vue b/apps/operator/components/InvitePartnerUserModal.vue new file mode 100644 index 0000000..d3d6673 --- /dev/null +++ b/apps/operator/components/InvitePartnerUserModal.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/apps/operator/components/OpSidebar.vue b/apps/operator/components/OpSidebar.vue index 8f62166..d8e4558 100644 --- a/apps/operator/components/OpSidebar.vue +++ b/apps/operator/components/OpSidebar.vue @@ -24,6 +24,7 @@ const NAV: NavRow[] = [ { id: 'users', label: 'Users (global)', icon: 'users', href: '/users' }, { id: 'support', label: 'Support', icon: 'help', href: '/support' }, { sec: 'Commercial' }, + { id: 'pricing', label: 'Pricing', icon: 'card', href: '/pricing' }, { id: 'billing', label: 'Platform billing', icon: 'card', href: '/billing' }, { id: 'reports', label: 'Reports', icon: 'database', href: '/reports' }, { sec: 'Operations' }, diff --git a/apps/operator/components/ToastStack.vue b/apps/operator/components/ToastStack.vue new file mode 100644 index 0000000..a7c4a8d --- /dev/null +++ b/apps/operator/components/ToastStack.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/operator/components/UserMenu.vue b/apps/operator/components/UserMenu.vue index 7745563..d8648d1 100644 --- a/apps/operator/components/UserMenu.vue +++ b/apps/operator/components/UserMenu.vue @@ -4,7 +4,7 @@ // outside-click + Escape + route-change dismissal so the parent topbar stays // dumb. -const { user, logout } = useOidcAuth() +const { user } = useOidcAuth() const { state: tweaks, setTheme } = useTweaks() const route = useRoute() @@ -25,7 +25,11 @@ function flipTheme() { async function signOut() { close() - await logout() + // Use our custom endpoint instead of useOidcAuth().logout() — see + // apps/operator/server/api/auth/sign-out.get.ts. It ends BOTH the local + // session and the Authentik IdP session (required for shared-workstation + // safety on an elevated-privilege portal) and lands on /signed-out. + await navigateTo('/api/auth/sign-out', { external: true }) } watch(() => route.path, close) diff --git a/apps/operator/composables/useMe.ts b/apps/operator/composables/useMe.ts new file mode 100644 index 0000000..09c2a54 --- /dev/null +++ b/apps/operator/composables/useMe.ts @@ -0,0 +1,54 @@ +// Cached fetch of the signed-in operator's profile from platform-api. +// Mirrors the portal's useMe — kept here so any future middleware / +// layout in operator can read identity data SSR-safely without flashing +// the wrong layout to the browser. +// +// No current consumer; the portal version is what motivated this pattern +// (route middleware fetching /api/me with bare $fetch missed the session +// cookie on SSR, causing a flash of the end-user dashboard before the +// client-side redirect kicked in). Adding the same shape here means the +// trap is pre-disarmed if operator ever grows comparable middleware. + +interface MeProfile { + _id: string + authentikSubjectId: string + email: string + name: string + role: string + active: boolean + platformAdmin: boolean + tenantIds: string[] + partnerId?: string + partner?: { _id: string; slug: string; name: string; status: string } + lastLoginAt?: string +} + +interface MeResponse { + profile: MeProfile + tenants: unknown[] + subscriptions: unknown[] +} + +export function useMe() { + const state = useState('operator-me', () => null) + + async function fetchMe(force = false): Promise { + if (state.value && !force) return state.value + try { + // useRequestFetch on SSR forwards the incoming request's headers + // (including the nuxt-oidc-auth session cookie) when calling the + // Nitro route. Bare $fetch on SSR has no cookie context and would + // 401, producing a stale-state / wrong-layout flash on full reload. + const fetcher = useRequestFetch() + state.value = await fetcher('/api/me') + } catch { + state.value = null + } + return state.value + } + + const profile = computed(() => state.value?.profile ?? null) + const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin) + + return { state, profile, isPlatformAdmin, fetchMe } +} diff --git a/apps/operator/composables/useToast.ts b/apps/operator/composables/useToast.ts new file mode 100644 index 0000000..e79e60a --- /dev/null +++ b/apps/operator/composables/useToast.ts @@ -0,0 +1,37 @@ +// Lightweight toast stack. Used by buttons/actions that want to confirm +// they fired. Rendered by components/ToastStack.vue in the default layout. + +export type ToastTone = 'info' | 'ok' | 'warn' | 'bad' + +export interface Toast { + id: number + tone: ToastTone + message: string + hint?: string +} + +const toasts = ref([]) +let counter = 0 + +export const useToast = () => { + function push(tone: ToastTone, message: string, hint?: string) { + const id = ++counter + toasts.value = [...toasts.value, { id, tone, message, hint }] + const ttl = tone === 'bad' ? 7000 : 4000 + setTimeout(() => { + toasts.value = toasts.value.filter((t) => t.id !== id) + }, ttl) + } + function dismiss(id: number) { + toasts.value = toasts.value.filter((t) => t.id !== id) + } + return { + toasts, + push, + info: (m: string, h?: string) => push('info', m, h), + ok: (m: string, h?: string) => push('ok', m, h), + warn: (m: string, h?: string) => push('warn', m, h), + bad: (m: string, h?: string) => push('bad', m, h), + dismiss, + } +} diff --git a/apps/operator/layouts/default.vue b/apps/operator/layouts/default.vue index eacee86..eed2e34 100644 --- a/apps/operator/layouts/default.vue +++ b/apps/operator/layouts/default.vue @@ -61,6 +61,7 @@ onMounted(() => { + diff --git a/apps/operator/nuxt.config.ts b/apps/operator/nuxt.config.ts index 6968013..d0dad09 100644 --- a/apps/operator/nuxt.config.ts +++ b/apps/operator/nuxt.config.ts @@ -10,6 +10,17 @@ export default defineNuxtConfig({ css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'], + // Auto-import from the shared packages/ui workspace in addition to the + // app's own components/. /shared-packages is bind-mounted in + // docker-compose.yml — outside containers the same files live at + // /packages/ui/components/. The local dir keeps the default + // directory-based prefix; the shared dir uses no prefix so + // CountrySelect.vue is just . + components: [ + '~/components', + { path: '/shared-packages/ui/components', pathPrefix: false }, + ], + app: { head: { htmlAttrs: { 'data-theme': 'dark' }, @@ -55,6 +66,11 @@ export default defineNuxtConfig({ pkce: true, skipAccessTokenParsing: true, exposeAccessToken: true, + // Also expose id_token so /api/auth/sign-out can pass it as + // id_token_hint to Authentik's end-session endpoint. Without it + // Authentik can't identify the session to terminate and falls back + // to its own "you've logged out" confirmation page. + exposeIdToken: true, }, }, }, @@ -74,5 +90,11 @@ export default defineNuxtConfig({ routeRules: { '/api/**': { cors: true }, }, + // Persist nuxt-oidc-auth's session store on disk so HMR / dev-server + // restarts don't sign operators out. The default memory driver is fine + // in prod where one long-running container holds the state. + storage: { + oidc: { driver: 'fs', base: '.nuxt/oidc-store' }, + }, }, }) diff --git a/apps/operator/pages/partners/[slug].vue b/apps/operator/pages/partners/[slug].vue index 692220c..89d7127 100644 --- a/apps/operator/pages/partners/[slug].vue +++ b/apps/operator/pages/partners/[slug].vue @@ -225,6 +225,36 @@ async function confirmDetach() { } } +// ── Team (partner users) ────────────────────────────────────────────────── +// Lists users whose User.partnerId === this partner. Invite flow surfaces a +// modal that POSTs to /api/partners/:slug/users, which proxies platform-api +// and creates the Authentik user + group + local User doc atomically. + +interface PartnerUser { + _id: string + authentikSubjectId: string + email: string + name: string + role: string + active: boolean + lastLoginAt?: string + createdAt?: string +} + +const { data: team, refresh: refreshTeam } = await useFetch( + () => `/api/partners/${slug.value}/users`, + { default: () => [], watch: [slug] }, +) + +const inviteOpen = ref(false) + +function onInvited() { + // Don't close the modal — the user needs to see the recovery link / temp + // password. Just refresh the team list in the background so the new user + // is visible once they click Done. + void refreshTeam() +} + // ── Soft-terminate partner ──────────────────────────────────────────────── const terminateOpen = ref(false) const terminateBusy = ref(false) @@ -371,7 +401,7 @@ async function confirmTerminate() {
Country
-
+
@@ -420,6 +450,43 @@ async function confirmTerminate() { + +
+
+

Team

+

People at {{ partner.name }} who can sign in. partnerId on the user record points here.

+
+ + + Invite team member + +
+ + + + + + + + + + + + + + + + + + +
NameEmailRoleLast loginStatus
+ No team members yet. Click Invite team member to add one. +
+
{{ u.name }}
+ {{ u.authentikSubjectId }} +
{{ u.email }}{{ u.role }}{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'never' }}{{ u.active ? 'active' : 'disabled' }}
+
+

Soft-terminate partner

@@ -492,6 +559,15 @@ async function confirmTerminate() {

{{ terminateError }}

+ + + diff --git a/apps/operator/pages/pricing.vue b/apps/operator/pages/pricing.vue new file mode 100644 index 0000000..8d3e136 --- /dev/null +++ b/apps/operator/pages/pricing.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/apps/operator/pages/signed-out.vue b/apps/operator/pages/signed-out.vue new file mode 100644 index 0000000..e7c1518 --- /dev/null +++ b/apps/operator/pages/signed-out.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/operator/server/api/auth/sign-out.get.ts b/apps/operator/server/api/auth/sign-out.get.ts new file mode 100644 index 0000000..d2117a7 --- /dev/null +++ b/apps/operator/server/api/auth/sign-out.get.ts @@ -0,0 +1,35 @@ +// Sign-out for the operator portal. Same shape as the customer portal's +// sign-out (apps/portal/server/api/auth/sign-out.get.ts) — ends BOTH the +// local nuxt-oidc-auth session AND the Authentik IdP session so the next +// person at the same browser must re-enter credentials. Required for +// shared-workstation safety; operator portal carries elevated privileges. +// +// Flow: +// 1. Read the id_token off the local session (needed as id_token_hint). +// 2. Clear the local session (cookie + persistent store). +// 3. 302 the BROWSER through Authentik's dezky-operator end-session URL +// with post_logout_redirect_uri=/signed-out. +// +// The brief URL-bar flash to auth.dezky.local is unavoidable: that's the +// only host that can clear the Authentik session cookie (server-to-server +// invalidation alone leaves the browser cookie, which would let the next +// visit silently re-authorize). + +import { getUserSession, clearUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +const END_SESSION = 'https://auth.dezky.local/application/o/dezky-operator/end-session/' +const POST_LOGOUT_REDIRECT = 'https://operator.dezky.local/signed-out' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => ({} as any)) + const idToken: string | undefined = (session as any).idToken + + await clearUserSession(event).catch(() => {}) + + const params = new URLSearchParams({ + post_logout_redirect_uri: POST_LOGOUT_REDIRECT, + ...(idToken && { id_token_hint: idToken }), + }) + + return sendRedirect(event, `${END_SESSION}?${params.toString()}`, 302) +}) diff --git a/apps/operator/server/api/me.get.ts b/apps/operator/server/api/me.get.ts new file mode 100644 index 0000000..d1f98c0 --- /dev/null +++ b/apps/operator/server/api/me.get.ts @@ -0,0 +1,26 @@ +// Operator identity proxy. Same shape as the portal's /api/me — pulls +// /users/me from platform-api with the signed-in operator's access token, +// plus tenants + subscriptions for context. Consumed by the useMe() +// composable; no UI surface uses it yet, but the path is here so any +// future middleware / layout that needs profile data has a known endpoint. + +import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' + +export default defineEventHandler(async (event) => { + const session = await getUserSession(event).catch(() => null) + const accessToken = (session as { accessToken?: string } | null)?.accessToken + if (!accessToken) { + throw createError({ statusCode: 401, statusMessage: 'Not signed in or no access token' }) + } + + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + const headers = { Authorization: `Bearer ${accessToken}` } + + const [profile, tenants, subscriptions] = await Promise.all([ + $fetch(`${base}/users/me`, { headers }), + $fetch(`${base}/tenants`, { headers }), + $fetch(`${base}/subscriptions`, { headers }), + ]) + + return { profile, tenants, subscriptions } +}) diff --git a/apps/operator/server/api/partners/[slug]/users.get.ts b/apps/operator/server/api/partners/[slug]/users.get.ts new file mode 100644 index 0000000..fe021c7 --- /dev/null +++ b/apps/operator/server/api/partners/[slug]/users.get.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + return platformApi(event, `/partners/${slug}/users`) +}) diff --git a/apps/operator/server/api/partners/[slug]/users.post.ts b/apps/operator/server/api/partners/[slug]/users.post.ts new file mode 100644 index 0000000..6d853ad --- /dev/null +++ b/apps/operator/server/api/partners/[slug]/users.post.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const slug = getRouterParam(event, 'slug') + const body = await readBody(event) + return platformApi(event, `/partners/${slug}/users`, { method: 'POST', body }) +}) diff --git a/apps/operator/server/api/prices/[id].delete.ts b/apps/operator/server/api/prices/[id].delete.ts new file mode 100644 index 0000000..21e5e00 --- /dev/null +++ b/apps/operator/server/api/prices/[id].delete.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, 'id') + return platformApi(event, `/prices/${id}`, { method: 'DELETE' }) +}) diff --git a/apps/operator/server/api/prices/[id].patch.ts b/apps/operator/server/api/prices/[id].patch.ts new file mode 100644 index 0000000..6517713 --- /dev/null +++ b/apps/operator/server/api/prices/[id].patch.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, 'id') + const body = await readBody(event) + return platformApi(event, `/prices/${id}`, { method: 'PATCH', body }) +}) diff --git a/apps/operator/server/api/prices/index.get.ts b/apps/operator/server/api/prices/index.get.ts new file mode 100644 index 0000000..e622af3 --- /dev/null +++ b/apps/operator/server/api/prices/index.get.ts @@ -0,0 +1,7 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const q = getQuery(event) + const includeInactive = q.includeInactive === 'true' + return platformApi(event, `/prices${includeInactive ? '?includeInactive=true' : ''}`) +}) diff --git a/apps/operator/server/api/prices/index.post.ts b/apps/operator/server/api/prices/index.post.ts new file mode 100644 index 0000000..bcaedbe --- /dev/null +++ b/apps/operator/server/api/prices/index.post.ts @@ -0,0 +1,6 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + return platformApi(event, '/prices', { method: 'POST', body }) +}) diff --git a/apps/portal/app.vue b/apps/portal/app.vue index 8f62b8b..f8eacfa 100644 --- a/apps/portal/app.vue +++ b/apps/portal/app.vue @@ -1,3 +1,5 @@ diff --git a/apps/portal/assets/styles/base.css b/apps/portal/assets/styles/base.css index f3ee73b..82c5664 100644 --- a/apps/portal/assets/styles/base.css +++ b/apps/portal/assets/styles/base.css @@ -25,3 +25,12 @@ button { a { color: inherit; } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.25); border-radius: 6px; } +::-webkit-scrollbar-track { background: transparent; } + +:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; +} diff --git a/apps/portal/assets/styles/tokens.css b/apps/portal/assets/styles/tokens.css index ed4b73f..c42ce34 100644 --- a/apps/portal/assets/styles/tokens.css +++ b/apps/portal/assets/styles/tokens.css @@ -1,8 +1,9 @@ -/* Dezky design tokens — workspace surface (light/bone). - Ported from project/platform-tokens.jsx (THEMES.light + ACCENTS.signal). */ +/* Dezky portal design tokens. + Mirrors apps/operator/assets/styles/tokens.css (which ports project/platform-tokens.jsx + THEMES + ACCENTS + DENSITIES) so both portals share a visual vocabulary. */ :root { - /* Surface */ + /* Surface — light/bone */ --bg: #F4F3EE; /* bone */ --surface: #FAFAF7; /* paper */ --elevated: #FFFFFF; @@ -13,14 +14,20 @@ --text: #0A0A0A; /* carbon */ --text-dim: rgba(10, 10, 10, 0.55); --text-mute: rgba(10, 10, 10, 0.4); + --row-hover: rgba(10, 10, 10, 0.03); - /* Sidebar (always-dark for brand consistency) */ + /* Sidebar — always-dark for brand presence in light mode */ --side-bg: #0A0A0A; --side-surf: #141413; + --side-border: #1F1F1C; --side-text: #F4F3EE; + --side-dim: rgba(244, 243, 238, 0.55); + --side-mute: rgba(244, 243, 238, 0.35); + --side-hover: rgba(244, 243, 238, 0.06); + --side-active: rgba(244, 243, 238, 0.1); - /* Brand accent */ - --accent: #D4FF3A; /* signal */ + /* Brand accent — defaults to Signal yellow */ + --accent: #D4FF3A; --accent-fg: #0A0A0A; --signal: #D4FF3A; @@ -35,6 +42,11 @@ --font-display: 'Inter Tight', 'Inter', sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace; + /* Density (defaults to comfy) */ + --row-h: 56px; + --pad: 24px; + --gap: 20px; + /* Field input surface */ --input-bg: var(--bg); } @@ -48,9 +60,23 @@ --text: #F4F3EE; --text-dim: rgba(244, 243, 238, 0.72); --text-mute: rgba(244, 243, 238, 0.45); + --row-hover: rgba(244, 243, 238, 0.04); --ok: #34C77B; --warn: #F0B14A; --bad: #F05858; --info: #4D8BE8; --input-bg: rgba(244, 243, 238, 0.04); } + +[data-density='compact'] { + --row-h: 44px; + --pad: 16px; + --gap: 14px; +} + +/* Accent presets — toggled via TweaksPanel for the whitelabel preview. The + `--signal` brand-locked node-dot stays the same; only `--accent` flexes. */ +[data-accent='signal'] { --accent: #D4FF3A; --accent-fg: #0A0A0A; } +[data-accent='cobalt'] { --accent: #3F6BFF; --accent-fg: #FFFFFF; } +[data-accent='coral'] { --accent: #FF6B4A; --accent-fg: #FFFFFF; } +[data-accent='moss'] { --accent: #5B8C5A; --accent-fg: #FFFFFF; } diff --git a/apps/portal/components/AppLauncher.vue b/apps/portal/components/AppLauncher.vue new file mode 100644 index 0000000..b7c0745 --- /dev/null +++ b/apps/portal/components/AppLauncher.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/apps/portal/components/Avatar.vue b/apps/portal/components/Avatar.vue new file mode 100644 index 0000000..87f6fee --- /dev/null +++ b/apps/portal/components/Avatar.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/portal/components/Badge.vue b/apps/portal/components/Badge.vue new file mode 100644 index 0000000..aa92f60 --- /dev/null +++ b/apps/portal/components/Badge.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/apps/portal/components/Card.vue b/apps/portal/components/Card.vue new file mode 100644 index 0000000..cc4f89a --- /dev/null +++ b/apps/portal/components/Card.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/portal/components/ConfirmDialog.vue b/apps/portal/components/ConfirmDialog.vue new file mode 100644 index 0000000..8f2cf84 --- /dev/null +++ b/apps/portal/components/ConfirmDialog.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/apps/portal/components/CustomerModeBanner.vue b/apps/portal/components/CustomerModeBanner.vue new file mode 100644 index 0000000..bebb182 --- /dev/null +++ b/apps/portal/components/CustomerModeBanner.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/apps/portal/components/Eyebrow.vue b/apps/portal/components/Eyebrow.vue new file mode 100644 index 0000000..5182856 --- /dev/null +++ b/apps/portal/components/Eyebrow.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/portal/components/MetricCell.vue b/apps/portal/components/MetricCell.vue new file mode 100644 index 0000000..8bd0f44 --- /dev/null +++ b/apps/portal/components/MetricCell.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/apps/portal/components/Modal.vue b/apps/portal/components/Modal.vue new file mode 100644 index 0000000..a0d52ab --- /dev/null +++ b/apps/portal/components/Modal.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/apps/portal/components/Mono.vue b/apps/portal/components/Mono.vue new file mode 100644 index 0000000..b541372 --- /dev/null +++ b/apps/portal/components/Mono.vue @@ -0,0 +1,12 @@ + + + + + diff --git a/apps/portal/components/NotificationDrawer.vue b/apps/portal/components/NotificationDrawer.vue new file mode 100644 index 0000000..1d63f38 --- /dev/null +++ b/apps/portal/components/NotificationDrawer.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/apps/portal/components/PageHeader.vue b/apps/portal/components/PageHeader.vue new file mode 100644 index 0000000..e0be5c4 --- /dev/null +++ b/apps/portal/components/PageHeader.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/apps/portal/components/PortalSidebar.vue b/apps/portal/components/PortalSidebar.vue new file mode 100644 index 0000000..ca90802 --- /dev/null +++ b/apps/portal/components/PortalSidebar.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/apps/portal/components/PortalTopbar.vue b/apps/portal/components/PortalTopbar.vue new file mode 100644 index 0000000..12551ff --- /dev/null +++ b/apps/portal/components/PortalTopbar.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/apps/portal/components/PortalTweaksPanel.vue b/apps/portal/components/PortalTweaksPanel.vue new file mode 100644 index 0000000..536eaf8 --- /dev/null +++ b/apps/portal/components/PortalTweaksPanel.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/apps/portal/components/PortalUserMenu.vue b/apps/portal/components/PortalUserMenu.vue new file mode 100644 index 0000000..9453570 --- /dev/null +++ b/apps/portal/components/PortalUserMenu.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/apps/portal/components/SidePanel.vue b/apps/portal/components/SidePanel.vue new file mode 100644 index 0000000..760022d --- /dev/null +++ b/apps/portal/components/SidePanel.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/apps/portal/components/Stat.vue b/apps/portal/components/Stat.vue new file mode 100644 index 0000000..18ec80d --- /dev/null +++ b/apps/portal/components/Stat.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/portal/components/StatusDot.vue b/apps/portal/components/StatusDot.vue new file mode 100644 index 0000000..76b16f9 --- /dev/null +++ b/apps/portal/components/StatusDot.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/apps/portal/components/Tabs.vue b/apps/portal/components/Tabs.vue new file mode 100644 index 0000000..bd10c03 --- /dev/null +++ b/apps/portal/components/Tabs.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/apps/portal/components/ToastStack.vue b/apps/portal/components/ToastStack.vue new file mode 100644 index 0000000..a7c4a8d --- /dev/null +++ b/apps/portal/components/ToastStack.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/portal/components/UiButton.vue b/apps/portal/components/UiButton.vue new file mode 100644 index 0000000..30a36ef --- /dev/null +++ b/apps/portal/components/UiButton.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/apps/portal/components/UiIcon.vue b/apps/portal/components/UiIcon.vue index da2fa95..49e9d84 100644 --- a/apps/portal/components/UiIcon.vue +++ b/apps/portal/components/UiIcon.vue @@ -1,9 +1,22 @@ + + + + diff --git a/apps/portal/components/admin/KebabMenu.vue b/apps/portal/components/admin/KebabMenu.vue new file mode 100644 index 0000000..8ab2797 --- /dev/null +++ b/apps/portal/components/admin/KebabMenu.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/apps/portal/components/enduser/EnduserDeviceActions.vue b/apps/portal/components/enduser/EnduserDeviceActions.vue new file mode 100644 index 0000000..3941ae9 --- /dev/null +++ b/apps/portal/components/enduser/EnduserDeviceActions.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/apps/portal/components/enduser/EnduserFormField.vue b/apps/portal/components/enduser/EnduserFormField.vue new file mode 100644 index 0000000..aa39574 --- /dev/null +++ b/apps/portal/components/enduser/EnduserFormField.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/portal/components/enduser/EnduserPresenceSelector.vue b/apps/portal/components/enduser/EnduserPresenceSelector.vue new file mode 100644 index 0000000..60390a5 --- /dev/null +++ b/apps/portal/components/enduser/EnduserPresenceSelector.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/apps/portal/components/enduser/EnduserSaveBar.vue b/apps/portal/components/enduser/EnduserSaveBar.vue new file mode 100644 index 0000000..e6d8c50 --- /dev/null +++ b/apps/portal/components/enduser/EnduserSaveBar.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/apps/portal/components/enduser/EnduserToggle.vue b/apps/portal/components/enduser/EnduserToggle.vue new file mode 100644 index 0000000..d1fea5e --- /dev/null +++ b/apps/portal/components/enduser/EnduserToggle.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/apps/portal/components/partner/CustomerCreateWizard.vue b/apps/portal/components/partner/CustomerCreateWizard.vue new file mode 100644 index 0000000..f917fd2 --- /dev/null +++ b/apps/portal/components/partner/CustomerCreateWizard.vue @@ -0,0 +1,737 @@ + + + + + diff --git a/apps/portal/components/partner/CustomerTaskPanel.vue b/apps/portal/components/partner/CustomerTaskPanel.vue new file mode 100644 index 0000000..3f287f2 --- /dev/null +++ b/apps/portal/components/partner/CustomerTaskPanel.vue @@ -0,0 +1,246 @@ + + +