@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.
- package/LICENSE +193 -0
- package/README.md +43 -0
- package/package.json +43 -0
- package/src/SaasShell.vue +741 -0
- package/src/auth/AcceptInvitationPanel.vue +76 -0
- package/src/auth/AuthCard.vue +14 -0
- package/src/auth/CreateOrgPanel.vue +70 -0
- package/src/auth/ForgotPasswordForm.vue +55 -0
- package/src/auth/ResetPasswordForm.vue +93 -0
- package/src/auth/SelectOrgPanel.vue +65 -0
- package/src/auth/SignInForm.vue +88 -0
- package/src/auth/SignUpForm.vue +102 -0
- package/src/auth/VerifyEmailPanel.vue +48 -0
- package/src/auth/auth-route.ts +113 -0
- package/src/auth/use-anon-client.ts +32 -0
- package/src/cookies.ts +28 -0
- package/src/index.ts +19 -0
- package/src/session.ts +104 -0
- package/src/styles.css +187 -0
|
@@ -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>
|