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 @@ + + +