@bemaestro/saas-ui 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,741 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
3
+ import {
4
+ type AuthLoginResult,
5
+ type MaestroClient,
6
+ type RuntimeSurface,
7
+ type RuntimeView,
8
+ } from '@bemaestro/sdk/runtime'
9
+ import { ViewRenderer } from '@bemaestro/views'
10
+ import {
11
+ type AuthScreen,
12
+ parseAuthRoute,
13
+ pushAuthRoute,
14
+ readBrowserAuthRoute,
15
+ } from './auth/auth-route.js'
16
+ import AuthCard from './auth/AuthCard.vue'
17
+ import SignInForm from './auth/SignInForm.vue'
18
+ import SignUpForm from './auth/SignUpForm.vue'
19
+ import ForgotPasswordForm from './auth/ForgotPasswordForm.vue'
20
+ import ResetPasswordForm from './auth/ResetPasswordForm.vue'
21
+ import VerifyEmailPanel from './auth/VerifyEmailPanel.vue'
22
+ import AcceptInvitationPanel from './auth/AcceptInvitationPanel.vue'
23
+ import SelectOrgPanel from './auth/SelectOrgPanel.vue'
24
+ import CreateOrgPanel from './auth/CreateOrgPanel.vue'
25
+ import {
26
+ clearSession,
27
+ createAuthenticatedClient,
28
+ persistSession,
29
+ readPersistedSession,
30
+ sessionFromLogin,
31
+ type SaasSession,
32
+ } from './session.js'
33
+
34
+ export type SaasAccountSection =
35
+ | 'profile'
36
+ | 'members'
37
+ | 'billing'
38
+ | 'plans'
39
+ | 'activity'
40
+ | 'notifications'
41
+ | 'messaging'
42
+
43
+ type SessionGate = 'create-org' | 'select-org' | null
44
+
45
+ const props = withDefaults(
46
+ defineProps<{
47
+ config: { baseUrl: string; app: string; appName?: string }
48
+ /** Prefer web/pwa surfaces (product); never admin_runtime. */
49
+ mode?: 'client' | 'web'
50
+ }>(),
51
+ { mode: 'client' },
52
+ )
53
+
54
+ const title = computed(() => props.config.appName ?? props.config.app)
55
+
56
+ const initialRoute = readBrowserAuthRoute()
57
+ const authScreen = ref<AuthScreen>(initialRoute.screen)
58
+ const authToken = ref<string | null>(initialRoute.token)
59
+ const authRedirect = ref<string | null>(initialRoute.redirect)
60
+ const sessionGate = ref<SessionGate>(null)
61
+ const bootError = ref<string | null>(null)
62
+ const loading = ref(false)
63
+
64
+ const client = shallowRef<MaestroClient | null>(null)
65
+ const session = ref<SaasSession | null>(null)
66
+ const surfaces = ref<RuntimeSurface[]>([])
67
+ const activeSurfaceKey = ref<string | null>(null)
68
+ const activeViewKey = ref<string | null>(null)
69
+ const accountSection = ref<SaasAccountSection | null>(null)
70
+
71
+ const activeSurface = computed(
72
+ () => surfaces.value.find((s) => s.key === activeSurfaceKey.value) ?? null,
73
+ )
74
+ const activeView = computed<RuntimeView | null>(
75
+ () => activeSurface.value?.views.find((v) => v.key === activeViewKey.value) ?? null,
76
+ )
77
+
78
+ const canManageMembers = computed(() =>
79
+ ['owner', 'admin'].includes(session.value?.role ?? ''),
80
+ )
81
+ const canManageBilling = computed(() =>
82
+ ['owner', 'admin'].includes(session.value?.role ?? ''),
83
+ )
84
+
85
+ const isTokenAuthScreen = computed(() =>
86
+ ['reset-password', 'verify-email', 'invitation'].includes(authScreen.value),
87
+ )
88
+
89
+ /** App chrome only when org context is ready and we are not on a token deep-link. */
90
+ const showAppShell = computed(
91
+ () =>
92
+ Boolean(client.value && session.value?.orgId && !sessionGate.value) &&
93
+ !isTokenAuthScreen.value,
94
+ )
95
+
96
+ function applyRouteFromLocation() {
97
+ const route = readBrowserAuthRoute()
98
+ authScreen.value = route.screen
99
+ authToken.value = route.token
100
+ authRedirect.value = route.redirect
101
+ }
102
+
103
+ function navigateAuth(screen: AuthScreen, opts?: { token?: string | null; redirect?: string | null }) {
104
+ const token = opts?.token !== undefined ? opts.token : null
105
+ const redirect = opts?.redirect !== undefined ? opts.redirect : authRedirect.value
106
+ authScreen.value = screen
107
+ authToken.value = token
108
+ authRedirect.value = redirect
109
+ pushAuthRoute(screen, { token, redirect })
110
+ }
111
+
112
+ function preferredSurface(list: RuntimeSurface[]): RuntimeSurface | undefined {
113
+ return list.find((s) => s.delivery === 'web' || s.delivery === 'pwa') ?? list[0]
114
+ }
115
+
116
+ function selectRuntime(list: RuntimeSurface[]) {
117
+ surfaces.value = list
118
+ const surface = preferredSurface(list)
119
+ activeSurfaceKey.value = surface?.key ?? null
120
+ activeViewKey.value = surface?.views[0]?.key ?? null
121
+ accountSection.value = null
122
+ }
123
+
124
+ async function loadRuntime(active: MaestroClient) {
125
+ const runtime = await active.surfaces.runtime()
126
+ selectRuntime(runtime.data.surfaces)
127
+ }
128
+
129
+ function attachClient(tokens: { accessToken: string; refreshToken: string }, next: SaasSession) {
130
+ session.value = next
131
+ persistSession(tokens, next)
132
+ client.value = createAuthenticatedClient({
133
+ baseUrl: props.config.baseUrl,
134
+ app: props.config.app,
135
+ accessToken: tokens.accessToken,
136
+ refreshToken: tokens.refreshToken,
137
+ orgId: next.orgId,
138
+ onTokens: (fresh) => {
139
+ if (!session.value) return
140
+ persistSession(fresh, session.value)
141
+ },
142
+ })
143
+ }
144
+
145
+ function gateForSession(next: SaasSession, opts?: { forceSelectOrg?: boolean }): SessionGate {
146
+ if (next.orgs.length === 0) return 'create-org'
147
+ if (opts?.forceSelectOrg && next.orgs.length > 1) return 'select-org'
148
+ if (!next.orgId) {
149
+ return next.orgs.length > 1 ? 'select-org' : 'create-org'
150
+ }
151
+ return null
152
+ }
153
+
154
+ async function enterAuthenticated(
155
+ tokens: { accessToken: string; refreshToken: string },
156
+ next: SaasSession,
157
+ opts?: { forceSelectOrg?: boolean },
158
+ ) {
159
+ attachClient(tokens, next)
160
+ const gate = gateForSession(next, opts)
161
+ sessionGate.value = gate
162
+ if (gate) return
163
+
164
+ if (authRedirect.value) {
165
+ const redirectPath = authRedirect.value
166
+ authRedirect.value = null
167
+ const route = parseAuthRoute(redirectPath, '')
168
+ if (route.screen === 'invitation' && route.token) {
169
+ navigateAuth('invitation', { token: route.token })
170
+ return
171
+ }
172
+ }
173
+
174
+ if (client.value) await loadRuntime(client.value)
175
+ navigateAuth('sign-in')
176
+ }
177
+
178
+ async function onSignedIn(result: AuthLoginResult) {
179
+ bootError.value = null
180
+ loading.value = true
181
+ try {
182
+ const next = sessionFromLogin(result)
183
+ await enterAuthenticated(
184
+ { accessToken: result.accessToken, refreshToken: result.refreshToken },
185
+ next,
186
+ { forceSelectOrg: next.orgs.length > 1 },
187
+ )
188
+ } catch (e) {
189
+ bootError.value = e instanceof Error ? e.message : 'Sign-in failed'
190
+ clearSession()
191
+ client.value = null
192
+ session.value = null
193
+ sessionGate.value = null
194
+ } finally {
195
+ loading.value = false
196
+ }
197
+ }
198
+
199
+ async function onOrgSelected(result: {
200
+ accessToken: string
201
+ refreshToken: string
202
+ orgId: string
203
+ role: string
204
+ }) {
205
+ if (!session.value) return
206
+ loading.value = true
207
+ try {
208
+ const next: SaasSession = {
209
+ ...session.value,
210
+ orgId: result.orgId,
211
+ role: result.role,
212
+ }
213
+ await enterAuthenticated(
214
+ { accessToken: result.accessToken, refreshToken: result.refreshToken },
215
+ next,
216
+ )
217
+ } finally {
218
+ loading.value = false
219
+ }
220
+ }
221
+
222
+ async function onOrgCreated(result: {
223
+ accessToken?: string
224
+ refreshToken?: string
225
+ orgId: string
226
+ orgName: string
227
+ }) {
228
+ if (!session.value) return
229
+ loading.value = true
230
+ try {
231
+ const persisted = readPersistedSession()
232
+ const accessToken = result.accessToken ?? persisted.accessToken
233
+ const refreshToken = result.refreshToken ?? persisted.refreshToken
234
+ if (!accessToken || !refreshToken) {
235
+ throw new Error('Missing session tokens after org creation')
236
+ }
237
+ const next: SaasSession = {
238
+ ...session.value,
239
+ orgId: result.orgId,
240
+ role: 'owner',
241
+ orgs: [
242
+ ...session.value.orgs.filter((o) => o.orgId !== result.orgId),
243
+ { orgId: result.orgId, orgName: result.orgName, role: 'owner' },
244
+ ],
245
+ }
246
+ await enterAuthenticated({ accessToken, refreshToken }, next)
247
+ } catch (e) {
248
+ bootError.value = e instanceof Error ? e.message : 'Could not finish setup'
249
+ } finally {
250
+ loading.value = false
251
+ }
252
+ }
253
+
254
+ async function onInvitationAccepted(orgId: string) {
255
+ if (!client.value || !session.value) return
256
+ loading.value = true
257
+ try {
258
+ const result = await client.value.auth.switchOrg(orgId)
259
+ const membership = result.orgs.find((o) => o.orgId === orgId)
260
+ const mapped = sessionFromLogin(result)
261
+ await enterAuthenticated(
262
+ { accessToken: result.accessToken, refreshToken: result.refreshToken },
263
+ {
264
+ ...mapped,
265
+ orgId,
266
+ role: membership?.role ?? mapped.role,
267
+ },
268
+ )
269
+ } catch (e) {
270
+ bootError.value = e instanceof Error ? e.message : 'Could not open organization'
271
+ } finally {
272
+ loading.value = false
273
+ }
274
+ }
275
+
276
+ async function signOut() {
277
+ const refresh = readPersistedSession().refreshToken
278
+ try {
279
+ if (refresh && client.value) await client.value.auth.logout(refresh)
280
+ } catch {
281
+ // best-effort
282
+ }
283
+ clearSession()
284
+ client.value = null
285
+ session.value = null
286
+ surfaces.value = []
287
+ activeSurfaceKey.value = null
288
+ activeViewKey.value = null
289
+ accountSection.value = null
290
+ sessionGate.value = null
291
+ navigateAuth('sign-in')
292
+ }
293
+
294
+ function openView(surfaceKey: string, viewKey: string) {
295
+ accountSection.value = null
296
+ activeSurfaceKey.value = surfaceKey
297
+ activeViewKey.value = viewKey
298
+ }
299
+
300
+ function openAccount(section: SaasAccountSection) {
301
+ accountSection.value = section
302
+ }
303
+
304
+ function onPopState() {
305
+ applyRouteFromLocation()
306
+ }
307
+
308
+ onMounted(async () => {
309
+ applyRouteFromLocation()
310
+ window.addEventListener('popstate', onPopState)
311
+
312
+ const persisted = readPersistedSession()
313
+ if (!persisted.session || !persisted.accessToken || !persisted.refreshToken) return
314
+
315
+ // Deep-link token screens take precedence over restoring into the app shell.
316
+ if (isTokenAuthScreen.value && authScreen.value !== 'invitation') return
317
+
318
+ loading.value = true
319
+ try {
320
+ const next = persisted.session
321
+ attachClient(
322
+ { accessToken: persisted.accessToken, refreshToken: persisted.refreshToken },
323
+ next,
324
+ )
325
+ if (authScreen.value === 'invitation' && authToken.value) {
326
+ sessionGate.value = null
327
+ return
328
+ }
329
+ const gate = gateForSession(next)
330
+ sessionGate.value = gate
331
+ if (!gate && client.value) {
332
+ await loadRuntime(client.value)
333
+ }
334
+ } catch (e) {
335
+ clearSession()
336
+ client.value = null
337
+ session.value = null
338
+ sessionGate.value = null
339
+ bootError.value = e instanceof Error ? e.message : 'Session expired'
340
+ } finally {
341
+ loading.value = false
342
+ }
343
+ })
344
+
345
+ onUnmounted(() => {
346
+ window.removeEventListener('popstate', onPopState)
347
+ })
348
+
349
+ // Members / billing data
350
+ const members = ref<
351
+ Array<{ userId: string; email: string; name: string; role: string; status: string }>
352
+ >([])
353
+ const plans = ref<Array<Record<string, unknown>>>([])
354
+ const usage = ref<Record<string, unknown> | null>(null)
355
+ const activity = ref<Array<Record<string, unknown>>>([])
356
+ const notifications = ref<
357
+ Array<{ id: string; title: string; body: string; readAt: string | null; createdAt: string }>
358
+ >([])
359
+ const panelError = ref<string | null>(null)
360
+ const panelLoading = ref(false)
361
+
362
+ async function loadAccountPanel() {
363
+ if (!client.value || !session.value?.orgId || !accountSection.value) return
364
+ panelError.value = null
365
+ panelLoading.value = true
366
+ const orgId = session.value.orgId
367
+ try {
368
+ if (accountSection.value === 'members') {
369
+ members.value = await client.value.orgs.listMembers(orgId)
370
+ } else if (accountSection.value === 'billing' || accountSection.value === 'plans') {
371
+ plans.value = await client.value.billing.listPlans()
372
+ } else if (accountSection.value === 'activity') {
373
+ const events = await client.value.events.list({ limit: 50 })
374
+ activity.value = events.data as unknown as Array<Record<string, unknown>>
375
+ usage.value = await client.value.billing.usage(orgId)
376
+ } else if (accountSection.value === 'notifications') {
377
+ const list = await client.value.notifications.list({ limit: 50 })
378
+ notifications.value = list.data
379
+ }
380
+ } catch (e) {
381
+ panelError.value = e instanceof Error ? e.message : 'Failed to load'
382
+ } finally {
383
+ panelLoading.value = false
384
+ }
385
+ }
386
+
387
+ watch(accountSection, () => {
388
+ void loadAccountPanel()
389
+ })
390
+
391
+ async function openPortal() {
392
+ if (!client.value || !session.value?.orgId) return
393
+ const { url } = await client.value.billing.portal(session.value.orgId)
394
+ window.location.href = url
395
+ }
396
+
397
+ async function startSubscribe(planKey: string) {
398
+ if (!client.value || !session.value?.orgId) return
399
+ const result = await client.value.billing.subscribe(session.value.orgId, planKey)
400
+ const checkoutUrl =
401
+ typeof result.checkoutUrl === 'string' ? result.checkoutUrl : null
402
+ if (checkoutUrl) window.location.href = checkoutUrl
403
+ }
404
+
405
+ async function markNotificationRead(id: string) {
406
+ if (!client.value) return
407
+ await client.value.notifications.markRead(id)
408
+ await loadAccountPanel()
409
+ }
410
+
411
+ const inviteEmail = ref('')
412
+ const inviteRole = ref('member')
413
+ const inviteMessage = ref<string | null>(null)
414
+
415
+ async function sendInvite() {
416
+ if (!client.value || !session.value?.orgId || !inviteEmail.value) return
417
+ inviteMessage.value = null
418
+ try {
419
+ await client.value.orgs.inviteMember(session.value.orgId, {
420
+ email: inviteEmail.value,
421
+ role: inviteRole.value,
422
+ })
423
+ inviteMessage.value = `Invite sent to ${inviteEmail.value}`
424
+ inviteEmail.value = ''
425
+ await loadAccountPanel()
426
+ } catch (e) {
427
+ inviteMessage.value = e instanceof Error ? e.message : 'Invite failed'
428
+ }
429
+ }
430
+
431
+ const authCardTitle = computed(() => {
432
+ switch (authScreen.value) {
433
+ case 'sign-up':
434
+ return title.value
435
+ case 'forgot-password':
436
+ return 'Forgot password'
437
+ case 'reset-password':
438
+ return 'Reset password'
439
+ case 'verify-email':
440
+ return 'Verify email'
441
+ case 'invitation':
442
+ return "You've been invited"
443
+ default:
444
+ return title.value
445
+ }
446
+ })
447
+
448
+ const authCardSubtitle = computed(() => {
449
+ switch (authScreen.value) {
450
+ case 'sign-in':
451
+ return 'Sign in to your workspace'
452
+ case 'sign-up':
453
+ return 'Create your account'
454
+ case 'forgot-password':
455
+ return "Enter your email and we'll send a reset link if an account exists."
456
+ case 'reset-password':
457
+ return 'Choose a new password for your account.'
458
+ case 'verify-email':
459
+ return undefined
460
+ case 'invitation':
461
+ return undefined
462
+ default:
463
+ return undefined
464
+ }
465
+ })
466
+ </script>
467
+
468
+ <template>
469
+ <!-- Post-login gates (create / select org) -->
470
+ <AuthCard
471
+ v-if="client && session && sessionGate === 'create-org'"
472
+ :title="title"
473
+ subtitle="One last step"
474
+ >
475
+ <CreateOrgPanel :client="client" @created="onOrgCreated" />
476
+ <p class="saas-auth-links">
477
+ <button type="button" class="saas-link" @click="signOut">Sign out</button>
478
+ </p>
479
+ <p v-if="bootError" class="saas-error">{{ bootError }}</p>
480
+ </AuthCard>
481
+
482
+ <AuthCard
483
+ v-else-if="client && session && sessionGate === 'select-org'"
484
+ :title="title"
485
+ subtitle="Select organization"
486
+ >
487
+ <SelectOrgPanel :client="client" :orgs="session.orgs" @selected="onOrgSelected" />
488
+ <p class="saas-auth-links">
489
+ <button type="button" class="saas-link" @click="signOut">Sign out</button>
490
+ </p>
491
+ <p v-if="bootError" class="saas-error">{{ bootError }}</p>
492
+ </AuthCard>
493
+
494
+ <!-- Unauthenticated / invitation (or authenticated accepting invite) -->
495
+ <AuthCard
496
+ v-else-if="!showAppShell"
497
+ :title="authCardTitle"
498
+ :subtitle="authCardSubtitle"
499
+ >
500
+ <p v-if="loading" class="saas-muted">Loading…</p>
501
+ <p v-else-if="bootError" class="saas-error">{{ bootError }}</p>
502
+
503
+ <SignInForm
504
+ v-else-if="authScreen === 'sign-in'"
505
+ :config="config"
506
+ @signed-in="onSignedIn"
507
+ @navigate="navigateAuth($event)"
508
+ />
509
+ <SignUpForm
510
+ v-else-if="authScreen === 'sign-up'"
511
+ :config="config"
512
+ @navigate="navigateAuth($event)"
513
+ />
514
+ <ForgotPasswordForm
515
+ v-else-if="authScreen === 'forgot-password'"
516
+ :config="config"
517
+ @navigate="navigateAuth($event)"
518
+ />
519
+ <ResetPasswordForm
520
+ v-else-if="authScreen === 'reset-password'"
521
+ :config="config"
522
+ :token="authToken"
523
+ @navigate="navigateAuth($event)"
524
+ />
525
+ <VerifyEmailPanel
526
+ v-else-if="authScreen === 'verify-email'"
527
+ :config="config"
528
+ :token="authToken"
529
+ @navigate="navigateAuth($event)"
530
+ />
531
+ <AcceptInvitationPanel
532
+ v-else-if="authScreen === 'invitation'"
533
+ :token="authToken"
534
+ :authenticated="Boolean(client && session)"
535
+ :client="client"
536
+ @navigate="(screen, redirect) => navigateAuth(screen, { redirect: redirect ?? null })"
537
+ @accepted="onInvitationAccepted"
538
+ />
539
+ </AuthCard>
540
+
541
+ <div v-else class="saas-app">
542
+ <aside class="saas-nav">
543
+ <div class="saas-brand">
544
+ <h2>{{ title }}</h2>
545
+ <p v-if="session" class="saas-muted">{{ session.user.name }}</p>
546
+ </div>
547
+
548
+ <template v-for="surface in surfaces" :key="surface.key">
549
+ <p class="saas-group">{{ surface.key }}</p>
550
+ <button
551
+ v-for="view in surface.views"
552
+ :key="view.key"
553
+ class="saas-nav-item"
554
+ :class="{
555
+ active: !accountSection && view.key === activeViewKey,
556
+ }"
557
+ @click="openView(surface.key, view.key)"
558
+ >
559
+ {{ view.title }}
560
+ </button>
561
+ </template>
562
+
563
+ <p class="saas-group">Account</p>
564
+ <button
565
+ class="saas-nav-item"
566
+ :class="{ active: accountSection === 'profile' }"
567
+ @click="openAccount('profile')"
568
+ >
569
+ Profile
570
+ </button>
571
+ <button
572
+ v-if="canManageMembers"
573
+ class="saas-nav-item"
574
+ :class="{ active: accountSection === 'members' }"
575
+ @click="openAccount('members')"
576
+ >
577
+ Members
578
+ </button>
579
+ <button
580
+ v-if="canManageBilling"
581
+ class="saas-nav-item"
582
+ :class="{ active: accountSection === 'plans' }"
583
+ @click="openAccount('plans')"
584
+ >
585
+ Plans
586
+ </button>
587
+ <button
588
+ v-if="canManageBilling"
589
+ class="saas-nav-item"
590
+ :class="{ active: accountSection === 'billing' }"
591
+ @click="openAccount('billing')"
592
+ >
593
+ Subscriptions
594
+ </button>
595
+ <button
596
+ class="saas-nav-item"
597
+ :class="{ active: accountSection === 'activity' }"
598
+ @click="openAccount('activity')"
599
+ >
600
+ Activity
601
+ </button>
602
+ <button
603
+ class="saas-nav-item"
604
+ :class="{ active: accountSection === 'notifications' }"
605
+ @click="openAccount('notifications')"
606
+ >
607
+ Notifications
608
+ </button>
609
+ <button
610
+ class="saas-nav-item"
611
+ :class="{ active: accountSection === 'messaging' }"
612
+ @click="openAccount('messaging')"
613
+ >
614
+ Messaging
615
+ </button>
616
+
617
+ <button class="saas-signout" type="button" @click="signOut">Sign out</button>
618
+ </aside>
619
+
620
+ <main class="saas-content">
621
+ <p v-if="loading" class="saas-muted">Loading…</p>
622
+
623
+ <template v-else-if="accountSection === 'profile' && session">
624
+ <h1>Profile</h1>
625
+ <dl class="saas-dl">
626
+ <dt>Name</dt>
627
+ <dd>{{ session.user.name }}</dd>
628
+ <dt>Email</dt>
629
+ <dd>{{ session.user.email }}</dd>
630
+ <dt>Organization</dt>
631
+ <dd>
632
+ {{ session.orgs.find((o) => o.orgId === session?.orgId)?.orgName ?? '—' }}
633
+ <span class="saas-muted">({{ session.role }})</span>
634
+ </dd>
635
+ </dl>
636
+ </template>
637
+
638
+ <template v-else-if="accountSection === 'members'">
639
+ <h1>Members</h1>
640
+ <p v-if="panelLoading" class="saas-muted">Loading…</p>
641
+ <p v-else-if="panelError" class="saas-error">{{ panelError }}</p>
642
+ <ul v-else class="saas-list">
643
+ <li v-for="m in members" :key="m.userId">
644
+ <strong>{{ m.name }}</strong> — {{ m.email }}
645
+ <span class="saas-muted">{{ m.role }} · {{ m.status }}</span>
646
+ </li>
647
+ </ul>
648
+ <form class="saas-invite" @submit.prevent="sendInvite">
649
+ <h2>Invite</h2>
650
+ <input v-model="inviteEmail" type="email" placeholder="colleague@example.com" required />
651
+ <select v-model="inviteRole">
652
+ <option value="member">member</option>
653
+ <option value="admin">admin</option>
654
+ <option value="viewer">viewer</option>
655
+ </select>
656
+ <button type="submit">Send invite</button>
657
+ <p v-if="inviteMessage" class="saas-muted">{{ inviteMessage }}</p>
658
+ </form>
659
+ </template>
660
+
661
+ <template v-else-if="accountSection === 'plans' || accountSection === 'billing'">
662
+ <h1>{{ accountSection === 'plans' ? 'Plans' : 'Subscriptions' }}</h1>
663
+ <p v-if="panelLoading" class="saas-muted">Loading…</p>
664
+ <p v-else-if="panelError" class="saas-error">{{ panelError }}</p>
665
+ <div v-else class="saas-plans">
666
+ <article v-for="plan in plans" :key="String(plan.key ?? plan.id)" class="saas-card">
667
+ <h3>{{ plan.name ?? plan.key }}</h3>
668
+ <p class="saas-muted">{{ plan.description ?? '' }}</p>
669
+ <button
670
+ v-if="accountSection === 'plans'"
671
+ type="button"
672
+ @click="startSubscribe(String(plan.key))"
673
+ >
674
+ Subscribe
675
+ </button>
676
+ </article>
677
+ </div>
678
+ <button
679
+ v-if="accountSection === 'billing'"
680
+ class="saas-portal"
681
+ type="button"
682
+ @click="openPortal"
683
+ >
684
+ Open billing portal
685
+ </button>
686
+ </template>
687
+
688
+ <template v-else-if="accountSection === 'activity'">
689
+ <h1>Activity</h1>
690
+ <p v-if="panelLoading" class="saas-muted">Loading…</p>
691
+ <p v-else-if="panelError" class="saas-error">{{ panelError }}</p>
692
+ <template v-else>
693
+ <h2>Usage</h2>
694
+ <pre class="saas-pre">{{ JSON.stringify(usage, null, 2) }}</pre>
695
+ <h2>Recent events</h2>
696
+ <ul class="saas-list">
697
+ <li v-for="(ev, i) in activity" :key="String(ev.id ?? i)">
698
+ <strong>{{ ev.action }}</strong>
699
+ <span class="saas-muted">{{ ev.createdAt }}</span>
700
+ </li>
701
+ </ul>
702
+ </template>
703
+ </template>
704
+
705
+ <template v-else-if="accountSection === 'notifications'">
706
+ <h1>Notifications</h1>
707
+ <p v-if="panelLoading" class="saas-muted">Loading…</p>
708
+ <p v-else-if="panelError" class="saas-error">{{ panelError }}</p>
709
+ <ul v-else class="saas-list">
710
+ <li v-for="n in notifications" :key="n.id">
711
+ <strong>{{ n.title }}</strong>
712
+ <p>{{ n.body }}</p>
713
+ <button
714
+ v-if="!n.readAt"
715
+ type="button"
716
+ @click="markNotificationRead(n.id)"
717
+ >
718
+ Mark read
719
+ </button>
720
+ </li>
721
+ <li v-if="notifications.length === 0" class="saas-muted">No notifications yet.</li>
722
+ </ul>
723
+ </template>
724
+
725
+ <template v-else-if="accountSection === 'messaging'">
726
+ <h1>Messaging</h1>
727
+ <p class="saas-muted">
728
+ Unified messaging (email / WhatsApp / SMS) ships with Core E5.5 (spec 14). This shell
729
+ reserves the product surface; use Notifications for in-app inbox today.
730
+ </p>
731
+ </template>
732
+
733
+ <ViewRenderer
734
+ v-else-if="activeView && client"
735
+ :view="activeView"
736
+ :client="client"
737
+ />
738
+ <p v-else class="saas-muted">Select a screen.</p>
739
+ </main>
740
+ </div>
741
+ </template>