@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,113 @@
1
+ /** Auth screens addressed by path (product shell mini-router). Spec 04 A1–A7. */
2
+
3
+ export type AuthScreen =
4
+ 'sign-in' | 'sign-up' | 'forgot-password' | 'reset-password' | 'verify-email' | 'invitation';
5
+
6
+ export interface AuthRoute {
7
+ screen: AuthScreen;
8
+ /** Token from query (`?token=`) or invitation path segment. */
9
+ token: string | null;
10
+ /** Optional return path after sign-in/sign-up (e.g. invitation). */
11
+ redirect: string | null;
12
+ }
13
+
14
+ const DEFAULT_ROUTE: AuthRoute = {
15
+ screen: 'sign-in',
16
+ token: null,
17
+ redirect: null,
18
+ };
19
+
20
+ function firstQuery(search: string, key: string): string | null {
21
+ const value = new URLSearchParams(search).get(key);
22
+ return value && value.length > 0 ? value : null;
23
+ }
24
+
25
+ /** Strip trailing slash except for root. */
26
+ function normalizePath(pathname: string): string {
27
+ if (pathname.length > 1 && pathname.endsWith('/')) {
28
+ return pathname.slice(0, -1);
29
+ }
30
+ return pathname || '/';
31
+ }
32
+
33
+ /**
34
+ * Parse location pathname + search into an auth route.
35
+ * Accepts admin-style paths and Core email links (`/auth/verify`, `/auth/reset`,
36
+ * `/invitations/:token/accept`).
37
+ */
38
+ export function parseAuthRoute(pathname: string, search = ''): AuthRoute {
39
+ const path = normalizePath(pathname);
40
+ const token = firstQuery(search, 'token');
41
+ const redirect = firstQuery(search, 'redirect');
42
+
43
+ if (path === '/' || path === '/sign-in' || path === '/login') {
44
+ return { screen: 'sign-in', token: null, redirect };
45
+ }
46
+ if (path === '/sign-up' || path === '/register') {
47
+ return { screen: 'sign-up', token: null, redirect };
48
+ }
49
+ if (path === '/forgot-password') {
50
+ return { screen: 'forgot-password', token: null, redirect };
51
+ }
52
+ if (path === '/reset-password' || path === '/auth/reset') {
53
+ return { screen: 'reset-password', token, redirect };
54
+ }
55
+ if (path === '/verify-email' || path === '/auth/verify') {
56
+ return { screen: 'verify-email', token, redirect };
57
+ }
58
+
59
+ const inviteMatch = path.match(/^\/invitations\/([^/]+)(?:\/accept)?$/);
60
+ if (inviteMatch?.[1]) {
61
+ return {
62
+ screen: 'invitation',
63
+ token: decodeURIComponent(inviteMatch[1]),
64
+ redirect,
65
+ };
66
+ }
67
+
68
+ return { ...DEFAULT_ROUTE, redirect };
69
+ }
70
+
71
+ /** Canonical path for a screen (for history.pushState). */
72
+ export function authScreenPath(
73
+ screen: AuthScreen,
74
+ opts?: { token?: string | null; redirect?: string | null },
75
+ ): string {
76
+ const params = new URLSearchParams();
77
+ // Invitation carries the token in the path; other token screens use ?token=.
78
+ if (opts?.token && screen !== 'invitation') params.set('token', opts.token);
79
+ if (opts?.redirect) params.set('redirect', opts.redirect);
80
+ const qs = params.toString();
81
+ const suffix = qs ? `?${qs}` : '';
82
+
83
+ switch (screen) {
84
+ case 'sign-in':
85
+ return `/sign-in${suffix}`;
86
+ case 'sign-up':
87
+ return `/sign-up${suffix}`;
88
+ case 'forgot-password':
89
+ return `/forgot-password${suffix}`;
90
+ case 'reset-password':
91
+ return `/reset-password${suffix}`;
92
+ case 'verify-email':
93
+ return `/verify-email${suffix}`;
94
+ case 'invitation':
95
+ return opts?.token
96
+ ? `/invitations/${encodeURIComponent(opts.token)}${suffix}`
97
+ : `/sign-in${suffix}`;
98
+ }
99
+ }
100
+
101
+ export function readBrowserAuthRoute(): AuthRoute {
102
+ if (typeof window === 'undefined') return { ...DEFAULT_ROUTE };
103
+ return parseAuthRoute(window.location.pathname, window.location.search);
104
+ }
105
+
106
+ export function pushAuthRoute(
107
+ screen: AuthScreen,
108
+ opts?: { token?: string | null; redirect?: string | null },
109
+ ): void {
110
+ if (typeof window === 'undefined') return;
111
+ const path = authScreenPath(screen, opts);
112
+ window.history.pushState({ saasAuth: screen }, '', path);
113
+ }
@@ -0,0 +1,32 @@
1
+ import { createMaestroClient, type MaestroClient } from '@bemaestro/sdk/runtime';
2
+
3
+ export type SaasShellConfig = { baseUrl: string; app: string; appName?: string };
4
+
5
+ export function createAnonClient(config: SaasShellConfig): MaestroClient {
6
+ return createMaestroClient({
7
+ baseUrl: config.baseUrl,
8
+ app: config.app,
9
+ });
10
+ }
11
+
12
+ export function errorMessage(error: unknown, fallback: string): string {
13
+ return error instanceof Error ? error.message : fallback;
14
+ }
15
+
16
+ export function errorCode(error: unknown): string | null {
17
+ if (
18
+ error &&
19
+ typeof error === 'object' &&
20
+ 'body' in error &&
21
+ error.body &&
22
+ typeof error.body === 'object' &&
23
+ 'error' in error.body &&
24
+ error.body.error &&
25
+ typeof error.body.error === 'object' &&
26
+ 'code' in error.body.error &&
27
+ typeof error.body.error.code === 'string'
28
+ ) {
29
+ return error.body.error.code;
30
+ }
31
+ return null;
32
+ }
package/src/cookies.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cookie helpers shared by product shells (apps/client and future apps).
3
+ * Secure only over HTTPS so localhost login keeps working.
4
+ */
5
+
6
+ const DEFAULT_MAX_AGE = 60 * 60 * 24 * 7;
7
+
8
+ export function getCookie(name: string): string | undefined {
9
+ if (typeof document === 'undefined') return undefined;
10
+ const value = `; ${document.cookie}`;
11
+ const parts = value.split(`; ${name}=`);
12
+ if (parts.length === 2) {
13
+ return parts.pop()?.split(';').shift();
14
+ }
15
+ return undefined;
16
+ }
17
+
18
+ export function setCookie(name: string, value: string, maxAge = DEFAULT_MAX_AGE): void {
19
+ if (typeof document === 'undefined') return;
20
+ const secure =
21
+ typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
22
+ document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
23
+ }
24
+
25
+ export function removeCookie(name: string): void {
26
+ if (typeof document === 'undefined') return;
27
+ document.cookie = `${name}=; path=/; max-age=0`;
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { default as SaasShell } from './SaasShell.vue';
2
+ export type { SaasAccountSection } from './SaasShell.vue';
3
+ export {
4
+ clearSession,
5
+ createAuthenticatedClient,
6
+ persistSession,
7
+ readPersistedSession,
8
+ sessionFromLogin,
9
+ type SaasOrgMembership,
10
+ type SaasSession,
11
+ type SaasSessionUser,
12
+ } from './session.js';
13
+ export { getCookie, removeCookie, setCookie } from './cookies.js';
14
+ export {
15
+ authScreenPath,
16
+ parseAuthRoute,
17
+ type AuthRoute,
18
+ type AuthScreen,
19
+ } from './auth/auth-route.js';
package/src/session.ts ADDED
@@ -0,0 +1,104 @@
1
+ import {
2
+ createMaestroClient,
3
+ type AuthLoginResult,
4
+ type MaestroClient,
5
+ } from '@bemaestro/sdk/runtime';
6
+ import { getCookie, removeCookie, setCookie } from './cookies.js';
7
+
8
+ const ACCESS_COOKIE = 'maestro_client_access_token';
9
+ const REFRESH_COOKIE = 'maestro_client_refresh_token';
10
+ const SESSION_COOKIE = 'maestro_client_session';
11
+
12
+ export interface SaasSessionUser {
13
+ id: string;
14
+ email: string;
15
+ name: string;
16
+ }
17
+
18
+ export interface SaasOrgMembership {
19
+ orgId: string;
20
+ orgName: string;
21
+ role: string;
22
+ }
23
+
24
+ export interface SaasSession {
25
+ user: SaasSessionUser;
26
+ orgId: string | null;
27
+ role: string | null;
28
+ orgs: SaasOrgMembership[];
29
+ }
30
+
31
+ function mapOrgs(orgs: AuthLoginResult['orgs']): SaasOrgMembership[] {
32
+ return orgs
33
+ .filter((org) => org.membershipStatus === 'active')
34
+ .map((org) => ({
35
+ orgId: org.orgId,
36
+ orgName: org.orgName,
37
+ role: org.role,
38
+ }));
39
+ }
40
+
41
+ export function readPersistedSession(): {
42
+ accessToken: string;
43
+ refreshToken: string;
44
+ session: SaasSession | null;
45
+ } {
46
+ const accessToken = getCookie(ACCESS_COOKIE) ?? '';
47
+ const refreshToken = getCookie(REFRESH_COOKIE) ?? '';
48
+ const raw = getCookie(SESSION_COOKIE);
49
+ if (!accessToken || !refreshToken || !raw) {
50
+ return { accessToken: '', refreshToken: '', session: null };
51
+ }
52
+ try {
53
+ return { accessToken, refreshToken, session: JSON.parse(raw) as SaasSession };
54
+ } catch {
55
+ return { accessToken: '', refreshToken: '', session: null };
56
+ }
57
+ }
58
+
59
+ export function persistSession(
60
+ tokens: { accessToken: string; refreshToken: string },
61
+ session: SaasSession,
62
+ ): void {
63
+ setCookie(ACCESS_COOKIE, tokens.accessToken);
64
+ setCookie(REFRESH_COOKIE, tokens.refreshToken);
65
+ setCookie(SESSION_COOKIE, JSON.stringify(session));
66
+ }
67
+
68
+ export function clearSession(): void {
69
+ removeCookie(ACCESS_COOKIE);
70
+ removeCookie(REFRESH_COOKIE);
71
+ removeCookie(SESSION_COOKIE);
72
+ }
73
+
74
+ export function sessionFromLogin(result: AuthLoginResult): SaasSession {
75
+ const orgs = mapOrgs(result.orgs);
76
+ const primary = orgs[0];
77
+ return {
78
+ user: result.user,
79
+ orgs,
80
+ orgId: primary?.orgId ?? null,
81
+ role: primary?.role ?? null,
82
+ };
83
+ }
84
+
85
+ export function createAuthenticatedClient(input: {
86
+ baseUrl: string;
87
+ app: string;
88
+ accessToken: string;
89
+ refreshToken: string;
90
+ orgId?: string | null;
91
+ onTokens: (tokens: { accessToken: string; refreshToken: string }) => void;
92
+ }): MaestroClient {
93
+ return createMaestroClient({
94
+ baseUrl: input.baseUrl,
95
+ app: input.app,
96
+ orgId: input.orgId ?? undefined,
97
+ auth: {
98
+ mode: 'user',
99
+ accessToken: input.accessToken,
100
+ refreshToken: input.refreshToken,
101
+ onTokens: input.onTokens,
102
+ },
103
+ });
104
+ }
package/src/styles.css ADDED
@@ -0,0 +1,187 @@
1
+ /* Shared product-shell styles for @bemaestro/saas-ui */
2
+
3
+ .saas-login {
4
+ max-width: 22rem;
5
+ margin: 12vh auto;
6
+ display: grid;
7
+ gap: 1rem;
8
+ font-family: system-ui, sans-serif;
9
+ }
10
+
11
+ .saas-auth-form,
12
+ .saas-login form {
13
+ display: grid;
14
+ gap: 0.75rem;
15
+ }
16
+
17
+ .saas-login input,
18
+ .saas-login button,
19
+ .saas-auth-form input,
20
+ .saas-auth-form button,
21
+ .saas-invite input,
22
+ .saas-invite select,
23
+ .saas-invite button,
24
+ .saas-portal,
25
+ .saas-plans button,
26
+ .saas-list button {
27
+ padding: 0.6rem 0.75rem;
28
+ font-size: 1rem;
29
+ }
30
+
31
+ .saas-btn-secondary {
32
+ background: #fff;
33
+ border: 1px solid #e5e7eb;
34
+ cursor: pointer;
35
+ }
36
+
37
+ .saas-auth-links {
38
+ display: flex;
39
+ flex-wrap: wrap;
40
+ align-items: center;
41
+ gap: 0.35rem 0.5rem;
42
+ margin: 0.25rem 0 0;
43
+ font-size: 0.875rem;
44
+ }
45
+
46
+ .saas-link {
47
+ padding: 0 !important;
48
+ border: 0;
49
+ background: none;
50
+ color: #1d4ed8;
51
+ cursor: pointer;
52
+ font-size: inherit;
53
+ text-decoration: underline;
54
+ text-underline-offset: 2px;
55
+ }
56
+
57
+ .saas-org-pick {
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: flex-start;
61
+ gap: 0.15rem;
62
+ width: 100%;
63
+ text-align: left;
64
+ border: 1px solid #e5e7eb;
65
+ border-radius: 0.375rem;
66
+ background: #fff;
67
+ cursor: pointer;
68
+ }
69
+
70
+ .saas-org-pick:hover:not(:disabled) {
71
+ border-color: #c7d2fe;
72
+ background: #eef2ff;
73
+ }
74
+
75
+ .saas-error {
76
+ color: #c0392b;
77
+ }
78
+
79
+ .saas-muted {
80
+ color: #6b7280;
81
+ font-size: 0.875rem;
82
+ }
83
+
84
+ .saas-app {
85
+ display: grid;
86
+ grid-template-columns: 15rem 1fr;
87
+ min-height: 100vh;
88
+ font-family: system-ui, sans-serif;
89
+ }
90
+
91
+ .saas-nav {
92
+ border-right: 1px solid #e5e7eb;
93
+ padding: 1rem;
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 0.15rem;
97
+ }
98
+
99
+ .saas-brand h2 {
100
+ margin: 0;
101
+ font-size: 1.1rem;
102
+ }
103
+
104
+ .saas-group {
105
+ margin: 1rem 0 0.25rem;
106
+ font-size: 0.7rem;
107
+ text-transform: uppercase;
108
+ letter-spacing: 0.04em;
109
+ color: #6b7280;
110
+ }
111
+
112
+ .saas-nav-item {
113
+ display: block;
114
+ width: 100%;
115
+ text-align: left;
116
+ padding: 0.4rem 0.5rem;
117
+ border: 0;
118
+ background: none;
119
+ border-radius: 0.375rem;
120
+ cursor: pointer;
121
+ }
122
+
123
+ .saas-nav-item.active {
124
+ background: #eef2ff;
125
+ font-weight: 600;
126
+ }
127
+
128
+ .saas-signout {
129
+ margin-top: 1.5rem;
130
+ padding: 0.4rem 0.5rem;
131
+ border: 1px solid #e5e7eb;
132
+ border-radius: 0.375rem;
133
+ background: #fff;
134
+ cursor: pointer;
135
+ }
136
+
137
+ .saas-content {
138
+ padding: 1.5rem;
139
+ }
140
+
141
+ .saas-dl {
142
+ display: grid;
143
+ grid-template-columns: 8rem 1fr;
144
+ gap: 0.5rem 1rem;
145
+ }
146
+
147
+ .saas-dl dt {
148
+ color: #6b7280;
149
+ }
150
+
151
+ .saas-list {
152
+ list-style: none;
153
+ padding: 0;
154
+ display: grid;
155
+ gap: 0.75rem;
156
+ }
157
+
158
+ .saas-plans {
159
+ display: grid;
160
+ gap: 1rem;
161
+ grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
162
+ }
163
+
164
+ .saas-card {
165
+ border: 1px solid #e5e7eb;
166
+ border-radius: 0.5rem;
167
+ padding: 1rem;
168
+ }
169
+
170
+ .saas-invite {
171
+ margin-top: 2rem;
172
+ display: grid;
173
+ gap: 0.5rem;
174
+ max-width: 24rem;
175
+ }
176
+
177
+ .saas-pre {
178
+ background: #f9fafb;
179
+ padding: 0.75rem;
180
+ border-radius: 0.375rem;
181
+ overflow: auto;
182
+ font-size: 0.8rem;
183
+ }
184
+
185
+ .saas-portal {
186
+ margin-top: 1rem;
187
+ }