@shellui/core 0.2.0-beta.5 → 0.3.0-beta.0

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.
Files changed (111) hide show
  1. package/package.json +9 -4
  2. package/src/app.tsx +12 -9
  3. package/src/components/ui/badge.tsx +35 -0
  4. package/src/components/ui/dropdown-menu.tsx +94 -0
  5. package/src/components/ui/sidebar.tsx +1 -1
  6. package/src/constants/urls.ts +8 -0
  7. package/src/features/admin/AdminView.tsx +154 -0
  8. package/src/features/admin/components/AdminForbiddenAccess.tsx +10 -0
  9. package/src/features/auth/AuthProvider.tsx +464 -0
  10. package/src/features/auth/backends/index.ts +41 -0
  11. package/src/features/auth/backends/shellui.ts +278 -0
  12. package/src/features/auth/backends/supabase.ts +300 -0
  13. package/src/features/auth/backends/types.ts +30 -0
  14. package/src/features/auth/components/LoginButton.tsx +360 -0
  15. package/src/features/auth/components/LoginButtonIcons.tsx +48 -0
  16. package/src/features/auth/components/LoginView.tsx +721 -0
  17. package/src/features/auth/components/OAuthCallbackView.tsx +119 -0
  18. package/src/features/auth/hooks/useAuth.tsx +37 -0
  19. package/src/features/auth/types.ts +51 -0
  20. package/src/features/auth/utils/buildSessionFromParams.spec.ts +61 -0
  21. package/src/features/auth/utils/buildSessionFromParams.ts +79 -0
  22. package/src/features/auth/utils/clearStoredAuthSession.spec.ts +23 -0
  23. package/src/features/auth/utils/clearStoredAuthSession.ts +10 -0
  24. package/src/features/auth/utils/clientLoginContext.spec.ts +83 -0
  25. package/src/features/auth/utils/clientLoginContext.ts +89 -0
  26. package/src/features/auth/utils/decodeJwtPayload.spec.ts +17 -0
  27. package/src/features/auth/utils/decodeJwtPayload.ts +24 -0
  28. package/src/features/auth/utils/formatProviderLabel.spec.ts +19 -0
  29. package/src/features/auth/utils/formatProviderLabel.ts +11 -0
  30. package/src/features/auth/utils/getOAuthProviderCandidates.spec.ts +16 -0
  31. package/src/features/auth/utils/getOAuthProviderCandidates.ts +15 -0
  32. package/src/features/auth/utils/getPreferredBackendProvider.spec.ts +15 -0
  33. package/src/features/auth/utils/getPreferredBackendProvider.ts +10 -0
  34. package/src/features/auth/utils/getProviderVisual.spec.ts +30 -0
  35. package/src/features/auth/utils/getProviderVisual.ts +83 -0
  36. package/src/features/auth/utils/getUserFromSdkSettings.spec.ts +32 -0
  37. package/src/features/auth/utils/getUserFromSdkSettings.ts +13 -0
  38. package/src/features/auth/utils/index.ts +21 -0
  39. package/src/features/auth/utils/isLoginMethod.spec.ts +18 -0
  40. package/src/features/auth/utils/isLoginMethod.ts +5 -0
  41. package/src/features/auth/utils/isSessionExpired.spec.ts +23 -0
  42. package/src/features/auth/utils/isSessionExpired.ts +5 -0
  43. package/src/features/auth/utils/normalizeAuthSettings.spec.ts +60 -0
  44. package/src/features/auth/utils/normalizeAuthSettings.ts +71 -0
  45. package/src/features/auth/utils/normalizeNextPath.spec.ts +21 -0
  46. package/src/features/auth/utils/normalizeNextPath.ts +12 -0
  47. package/src/features/auth/utils/normalizeRedirectPath.spec.ts +12 -0
  48. package/src/features/auth/utils/normalizeRedirectPath.ts +3 -0
  49. package/src/features/auth/utils/persistAuthSession.spec.ts +35 -0
  50. package/src/features/auth/utils/persistAuthSession.ts +12 -0
  51. package/src/features/auth/utils/readStoredAuthSession.spec.ts +34 -0
  52. package/src/features/auth/utils/readStoredAuthSession.ts +14 -0
  53. package/src/features/auth/utils/toAuthSessionFromSettingsUser.spec.ts +76 -0
  54. package/src/features/auth/utils/toAuthSessionFromSettingsUser.ts +36 -0
  55. package/src/features/config/types.ts +55 -0
  56. package/src/features/layouts/AppLayout.tsx +8 -6
  57. package/src/features/layouts/appbar/AppBarLayout.tsx +42 -23
  58. package/src/features/layouts/fullscreen/FullscreenLayout.tsx +3 -2
  59. package/src/features/layouts/sidebar/SidebarInner.tsx +7 -5
  60. package/src/features/layouts/sidebar/SidebarLayout.tsx +16 -3
  61. package/src/features/layouts/utils.ts +54 -0
  62. package/src/features/layouts/windows/WindowsLayout.tsx +22 -4
  63. package/src/features/legal/LegalDocumentContent.tsx +102 -0
  64. package/src/features/legal/LegalDocumentView.tsx +42 -0
  65. package/src/features/legal/LegalDocumentsIndexView.tsx +51 -0
  66. package/src/features/legal/LegalDocumentsLinks.tsx +29 -0
  67. package/src/features/legal/legalDocuments.ts +62 -0
  68. package/src/features/settings/SettingsIcons.tsx +20 -0
  69. package/src/features/settings/SettingsProvider.tsx +347 -245
  70. package/src/features/settings/SettingsRoutes.tsx +8 -0
  71. package/src/features/settings/SettingsView.tsx +43 -8
  72. package/src/features/settings/components/Develop.tsx +2 -2
  73. package/src/features/settings/components/LegalDocumentsPanel.tsx +46 -0
  74. package/src/features/settings/components/UserIcon.tsx +20 -0
  75. package/src/features/settings/components/UserSettingsPanel.tsx +438 -0
  76. package/src/features/settings/components/createUserSettingsRoute.tsx +43 -0
  77. package/src/features/settings/utils/buildSettingsForPropagation.spec.ts +143 -0
  78. package/src/features/settings/utils/buildSettingsForPropagation.ts +55 -0
  79. package/src/features/settings/utils/flattenNavigationItems.spec.ts +17 -0
  80. package/src/features/settings/utils/flattenNavigationItems.ts +12 -0
  81. package/src/features/settings/utils/getAvailableThemesForSettings.spec.ts +15 -0
  82. package/src/features/settings/utils/getAvailableThemesForSettings.ts +16 -0
  83. package/src/features/settings/utils/getBrowserTimezone.spec.ts +11 -0
  84. package/src/features/settings/utils/getBrowserTimezone.ts +7 -0
  85. package/src/features/settings/utils/getPreferenceSnapshot.spec.ts +35 -0
  86. package/src/features/settings/utils/getPreferenceSnapshot.ts +10 -0
  87. package/src/features/settings/utils/getResolvedAppearanceForSettings.spec.ts +97 -0
  88. package/src/features/settings/utils/getResolvedAppearanceForSettings.ts +48 -0
  89. package/src/features/settings/utils/index.ts +12 -0
  90. package/src/features/settings/utils/isSameUser.spec.ts +35 -0
  91. package/src/features/settings/utils/isSameUser.ts +17 -0
  92. package/src/features/settings/utils/mergePreferencesIntoSettings.spec.ts +108 -0
  93. package/src/features/settings/utils/mergePreferencesIntoSettings.ts +47 -0
  94. package/src/features/settings/utils/resolveColorMode.spec.ts +29 -0
  95. package/src/features/settings/utils/resolveColorMode.ts +6 -0
  96. package/src/features/settings/utils/resolveLabel.spec.ts +17 -0
  97. package/src/features/settings/utils/resolveLabel.ts +7 -0
  98. package/src/features/settings/utils/toAbsoluteFontUrls.spec.ts +26 -0
  99. package/src/features/settings/utils/toAbsoluteFontUrls.ts +15 -0
  100. package/src/features/settings/utils/toSettingsUser.spec.ts +49 -0
  101. package/src/features/settings/utils/toSettingsUser.ts +15 -0
  102. package/src/i18n/translations/en/common.json +14 -0
  103. package/src/i18n/translations/en/settings.json +45 -0
  104. package/src/i18n/translations/fr/common.json +14 -0
  105. package/src/i18n/translations/fr/settings.json +45 -0
  106. package/src/index.css +37 -0
  107. package/src/index.ts +6 -0
  108. package/src/routes/components/NavigationItemRoute.tsx +32 -1
  109. package/src/routes/components/NotFoundView.tsx +13 -3
  110. package/src/routes/hooks/useNavigationItems.ts +19 -4
  111. package/src/routes/routes.tsx +87 -0
@@ -0,0 +1,278 @@
1
+ import {
2
+ buildSessionFromParams,
3
+ getShellUILoginClientTimezone,
4
+ getShellUILoginDeviceId,
5
+ isSessionExpired,
6
+ normalizeAuthSettings,
7
+ normalizeRedirectPath,
8
+ } from '../utils';
9
+ import { getShellUILoginCompanyId } from '../utils/clientLoginContext';
10
+ import type { AuthSession, UserPreferences } from '../types';
11
+ import type { AuthBackend } from './types';
12
+
13
+ const USER_PREFERENCES_ENDPOINT = '/api/v1/preferences';
14
+ const OAUTH_EXCHANGE_ENDPOINT = '/api/v1/oauth/exchange';
15
+
16
+ export const createShellUIAuthBackend = ({
17
+ backendUrl,
18
+ companyId,
19
+ }: {
20
+ backendUrl: string | null;
21
+ companyId?: string | number;
22
+ }): AuthBackend => {
23
+ const refreshWithStoredToken = async (
24
+ storedSession: AuthSession,
25
+ nowSeconds: number,
26
+ ): Promise<AuthSession | null> => {
27
+ if (!backendUrl || !storedSession.refreshToken) return null;
28
+
29
+ const refreshUrl = new URL(`${backendUrl}/api/v1/token`);
30
+ refreshUrl.searchParams.set('grant_type', 'refresh_token');
31
+ const clientTz = getShellUILoginClientTimezone();
32
+ const response = await fetch(refreshUrl.toString(), {
33
+ method: 'POST',
34
+ headers: {
35
+ Accept: 'application/json',
36
+ 'Content-Type': 'application/json',
37
+ },
38
+ body: JSON.stringify({
39
+ refresh_token: storedSession.refreshToken,
40
+ ...(clientTz ? { client_timezone: clientTz } : {}),
41
+ }),
42
+ });
43
+ if (!response.ok) {
44
+ throw new Error(`HTTP ${response.status}`);
45
+ }
46
+
47
+ const payload = (await response.json()) as Record<string, unknown>;
48
+ const refreshParams = new URLSearchParams();
49
+ if (typeof payload.access_token === 'string')
50
+ refreshParams.set('access_token', payload.access_token);
51
+ if (typeof payload.refresh_token === 'string')
52
+ refreshParams.set('refresh_token', payload.refresh_token);
53
+ if (typeof payload.expires_at === 'number' || typeof payload.expires_at === 'string') {
54
+ refreshParams.set('expires_at', String(payload.expires_at));
55
+ }
56
+ if (typeof payload.expires_in === 'number' || typeof payload.expires_in === 'string') {
57
+ refreshParams.set('expires_in', String(payload.expires_in));
58
+ }
59
+ if (typeof payload.token_type === 'string') refreshParams.set('token_type', payload.token_type);
60
+ return buildSessionFromParams(refreshParams, nowSeconds);
61
+ };
62
+
63
+ return {
64
+ type: 'shellui',
65
+ readSessionFromCallback: (locationHash, nowSeconds) => {
66
+ const hashParams = new URLSearchParams(locationHash.replace(/^#/, ''));
67
+ return buildSessionFromParams(hashParams, nowSeconds);
68
+ },
69
+ exchangeOAuthCode: async ({ provider, code, redirectUri, oauthClientId, nowSeconds }) => {
70
+ if (!backendUrl) {
71
+ throw new Error('Missing ShellUI backend URL.');
72
+ }
73
+ const selectedCompanyId = getShellUILoginCompanyId(companyId);
74
+ if (!selectedCompanyId) {
75
+ throw new Error('Missing company_id for OAuth exchange.');
76
+ }
77
+ const endpoint = new URL(`${backendUrl}${OAUTH_EXCHANGE_ENDPOINT}`);
78
+ endpoint.searchParams.set('company_id', selectedCompanyId);
79
+ const clientTz = getShellUILoginClientTimezone();
80
+ const clientDeviceId = getShellUILoginDeviceId();
81
+ const response = await fetch(endpoint.toString(), {
82
+ method: 'POST',
83
+ headers: {
84
+ Accept: 'application/json',
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ body: JSON.stringify({
88
+ provider,
89
+ code,
90
+ redirect_uri: redirectUri,
91
+ ...(typeof oauthClientId === 'number' &&
92
+ Number.isFinite(oauthClientId) &&
93
+ oauthClientId > 0
94
+ ? { company_oauth_client_id: Math.trunc(oauthClientId) }
95
+ : {}),
96
+ ...(clientTz ? { client_timezone: clientTz } : {}),
97
+ ...(clientDeviceId ? { client_device_id: clientDeviceId } : {}),
98
+ }),
99
+ });
100
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
101
+ if (!response.ok) {
102
+ const err = payload?.error ?? payload?.detail;
103
+ throw new Error(typeof err === 'string' && err.trim() ? err : `HTTP ${response.status}`);
104
+ }
105
+ const refreshParams = new URLSearchParams();
106
+ if (typeof payload?.access_token === 'string')
107
+ refreshParams.set('access_token', payload.access_token);
108
+ if (typeof payload?.refresh_token === 'string')
109
+ refreshParams.set('refresh_token', payload.refresh_token);
110
+ if (typeof payload?.expires_at === 'number' || typeof payload?.expires_at === 'string') {
111
+ refreshParams.set('expires_at', String(payload.expires_at));
112
+ }
113
+ if (typeof payload?.expires_in === 'number' || typeof payload?.expires_in === 'string') {
114
+ refreshParams.set('expires_in', String(payload.expires_in));
115
+ }
116
+ if (typeof payload?.token_type === 'string')
117
+ refreshParams.set('token_type', payload.token_type);
118
+ return buildSessionFromParams(refreshParams, nowSeconds);
119
+ },
120
+ restoreSession: async (storedSession, nowSeconds) => {
121
+ if (!storedSession) return null;
122
+ if (!isSessionExpired(storedSession)) return storedSession;
123
+ return refreshWithStoredToken(storedSession, nowSeconds);
124
+ },
125
+ refreshAuthSession: async (currentSession, nowSeconds) => {
126
+ if (!currentSession?.refreshToken) return null;
127
+ return refreshWithStoredToken(currentSession, nowSeconds);
128
+ },
129
+ startOAuth: (provider, redirectPath, oauthClientId) => {
130
+ if (!backendUrl) {
131
+ throw new Error('Missing ShellUI backend URL.');
132
+ }
133
+ const redirectToUrl = new URL(
134
+ `${window.location.origin}${normalizeRedirectPath(redirectPath)}`,
135
+ );
136
+ redirectToUrl.searchParams.set('provider', provider);
137
+ if (
138
+ typeof oauthClientId === 'number' &&
139
+ Number.isFinite(oauthClientId) &&
140
+ oauthClientId > 0
141
+ ) {
142
+ redirectToUrl.searchParams.set(
143
+ 'company_oauth_client_id',
144
+ String(Math.trunc(oauthClientId)),
145
+ );
146
+ }
147
+ const redirectTo = redirectToUrl.toString();
148
+ const authorizeUrl = new URL(`${backendUrl}/api/v1/authorize`);
149
+ authorizeUrl.searchParams.set('provider', provider);
150
+ authorizeUrl.searchParams.set('redirect_to', redirectTo);
151
+ if (
152
+ typeof oauthClientId === 'number' &&
153
+ Number.isFinite(oauthClientId) &&
154
+ oauthClientId > 0
155
+ ) {
156
+ authorizeUrl.searchParams.set('company_oauth_client_id', String(Math.trunc(oauthClientId)));
157
+ }
158
+ const selectedCompanyId = getShellUILoginCompanyId(companyId);
159
+ if (selectedCompanyId) {
160
+ authorizeUrl.searchParams.set('company_id', selectedCompanyId);
161
+ }
162
+ const clientTz = getShellUILoginClientTimezone();
163
+ if (clientTz) {
164
+ authorizeUrl.searchParams.set('client_timezone', clientTz);
165
+ }
166
+ const clientDeviceId = getShellUILoginDeviceId();
167
+ if (clientDeviceId) {
168
+ authorizeUrl.searchParams.set('client_device_id', clientDeviceId);
169
+ }
170
+ window.location.assign(authorizeUrl.toString());
171
+ },
172
+ startWeb3Ethereum: async () => {
173
+ throw new Error('Ethereum wallet login is not supported by the shellui backend.');
174
+ },
175
+ logout: async (session) => {
176
+ if (!backendUrl || !session?.accessToken) {
177
+ return;
178
+ }
179
+ const endpoint = new URL(`${backendUrl}/api/v1/logout`);
180
+ await fetch(endpoint.toString(), {
181
+ method: 'POST',
182
+ headers: {
183
+ Accept: 'application/json',
184
+ Authorization: `Bearer ${session.accessToken}`,
185
+ },
186
+ });
187
+ },
188
+ getAuthSettings: async () => {
189
+ if (!backendUrl) {
190
+ return { methods: [], oauthProviders: [], oauthClients: [] };
191
+ }
192
+ const endpoint = new URL(`${backendUrl}/api/v1/settings`);
193
+ const selectedCompanyId = getShellUILoginCompanyId(companyId);
194
+ if (selectedCompanyId) {
195
+ endpoint.searchParams.set('company_id', selectedCompanyId);
196
+ }
197
+ const response = await fetch(endpoint.toString(), {
198
+ method: 'GET',
199
+ headers: { Accept: 'application/json' },
200
+ });
201
+ if (!response.ok) {
202
+ throw new Error(`HTTP ${response.status}`);
203
+ }
204
+ const payload = (await response.json()) as unknown;
205
+ return normalizeAuthSettings(payload);
206
+ },
207
+ sendMagicLink: async (email, redirectPath) => {
208
+ if (!backendUrl) {
209
+ throw new Error('Missing ShellUI backend URL.');
210
+ }
211
+ const emailRedirectTo = `${window.location.origin}${normalizeRedirectPath(redirectPath)}`;
212
+ const response = await fetch(`${backendUrl}/api/v1/otp`, {
213
+ method: 'POST',
214
+ headers: {
215
+ Accept: 'application/json',
216
+ 'Content-Type': 'application/json',
217
+ },
218
+ body: JSON.stringify({
219
+ email,
220
+ create_user: true,
221
+ email_redirect_to: emailRedirectTo,
222
+ }),
223
+ });
224
+ if (!response.ok) {
225
+ const payload = (await response.json().catch(() => null)) as {
226
+ msg?: string;
227
+ message?: string;
228
+ error?: string;
229
+ } | null;
230
+ throw new Error(
231
+ payload?.msg ??
232
+ payload?.message ??
233
+ payload?.error ??
234
+ `Could not send magic link (HTTP ${response.status}).`,
235
+ );
236
+ }
237
+ },
238
+ syncUserPreferences: async (session, preferences: UserPreferences) => {
239
+ if (!backendUrl || !session?.accessToken) {
240
+ return;
241
+ }
242
+ const endpoint = new URL(`${backendUrl}${USER_PREFERENCES_ENDPOINT}`);
243
+ const response = await fetch(endpoint.toString(), {
244
+ method: 'PUT',
245
+ headers: {
246
+ Accept: 'application/json',
247
+ 'Content-Type': 'application/json',
248
+ Authorization: `Bearer ${session.accessToken}`,
249
+ },
250
+ body: JSON.stringify(preferences),
251
+ });
252
+ if (!response.ok) {
253
+ throw new Error(`HTTP ${response.status}`);
254
+ }
255
+ },
256
+ loadUserPreferences: async (session) => {
257
+ if (!backendUrl || !session?.accessToken) {
258
+ return null;
259
+ }
260
+ const endpoint = new URL(`${backendUrl}${USER_PREFERENCES_ENDPOINT}`);
261
+ const response = await fetch(endpoint.toString(), {
262
+ method: 'GET',
263
+ headers: {
264
+ Accept: 'application/json',
265
+ Authorization: `Bearer ${session.accessToken}`,
266
+ },
267
+ });
268
+ if (!response.ok) {
269
+ throw new Error(`HTTP ${response.status}`);
270
+ }
271
+ const preferences = (await response.json()) as Record<string, unknown>;
272
+ if (!preferences || typeof preferences !== 'object') {
273
+ return null;
274
+ }
275
+ return preferences as UserPreferences;
276
+ },
277
+ };
278
+ };
@@ -0,0 +1,300 @@
1
+ import {
2
+ buildSessionFromParams,
3
+ isSessionExpired,
4
+ normalizeAuthSettings,
5
+ normalizeRedirectPath,
6
+ } from '../utils';
7
+ import { normalizeJwtUserGroups } from '../utils/decodeJwtPayload';
8
+ import type { AuthSession, UserPreferences } from '../types';
9
+ import type { AuthBackend } from './types';
10
+ import { createClient } from '@supabase/supabase-js';
11
+
12
+ const USER_METADATA_ENDPOINT = '/auth/v1/user';
13
+ const APP_PREFERENCES_METADATA_KEY = 'shelluiPreferences';
14
+
15
+ const buildSessionFromSupabaseSession = (session: {
16
+ access_token: string;
17
+ refresh_token: string;
18
+ token_type?: string;
19
+ expires_at?: number;
20
+ user?: {
21
+ id?: string;
22
+ email?: string;
23
+ app_metadata?: Record<string, unknown>;
24
+ user_metadata?: Record<string, unknown>;
25
+ };
26
+ }): AuthSession => {
27
+ const userMetadata = session.user?.user_metadata ?? {};
28
+ const appMetadata = session.user?.app_metadata ?? {};
29
+ return {
30
+ accessToken: session.access_token,
31
+ refreshToken: session.refresh_token,
32
+ tokenType: session.token_type ?? 'bearer',
33
+ expiresAt: session.expires_at ?? Math.floor(Date.now() / 1000) + 3600,
34
+ provider: typeof appMetadata.provider === 'string' ? appMetadata.provider : 'ethereum',
35
+ userId: session.user?.id ?? null,
36
+ userEmail: session.user?.email ?? null,
37
+ userName:
38
+ typeof userMetadata.full_name === 'string'
39
+ ? userMetadata.full_name
40
+ : typeof userMetadata.name === 'string'
41
+ ? userMetadata.name
42
+ : null,
43
+ userAvatarUrl: typeof userMetadata.avatar_url === 'string' ? userMetadata.avatar_url : null,
44
+ userIsStaff: userMetadata.is_staff === true,
45
+ userIsCompanyOwner: userMetadata.is_company_owner === true,
46
+ userGroups: normalizeJwtUserGroups(userMetadata.groups),
47
+ userPreferences:
48
+ userMetadata.shelluiPreferences && typeof userMetadata.shelluiPreferences === 'object'
49
+ ? (userMetadata.shelluiPreferences as UserPreferences)
50
+ : null,
51
+ };
52
+ };
53
+
54
+ export const createSupabaseAuthBackend = ({
55
+ backendUrl,
56
+ publishableKey,
57
+ }: {
58
+ backendUrl: string | null;
59
+ publishableKey: string | undefined;
60
+ }): AuthBackend => {
61
+ const refreshWithStoredToken = async (
62
+ storedSession: AuthSession,
63
+ nowSeconds: number,
64
+ ): Promise<AuthSession | null> => {
65
+ if (!backendUrl || !publishableKey || !storedSession.refreshToken) return null;
66
+
67
+ const refreshUrl = new URL(`${backendUrl}/auth/v1/token`);
68
+ refreshUrl.searchParams.set('grant_type', 'refresh_token');
69
+ refreshUrl.searchParams.set('apikey', publishableKey);
70
+
71
+ const response = await fetch(refreshUrl.toString(), {
72
+ method: 'POST',
73
+ headers: {
74
+ Accept: 'application/json',
75
+ 'Content-Type': 'application/json',
76
+ apikey: publishableKey,
77
+ Authorization: `Bearer ${publishableKey}`,
78
+ },
79
+ body: JSON.stringify({ refresh_token: storedSession.refreshToken }),
80
+ });
81
+
82
+ if (!response.ok) {
83
+ throw new Error(`HTTP ${response.status}`);
84
+ }
85
+
86
+ const payload = (await response.json()) as Record<string, unknown>;
87
+ const refreshParams = new URLSearchParams();
88
+ if (typeof payload.access_token === 'string')
89
+ refreshParams.set('access_token', payload.access_token);
90
+ if (typeof payload.refresh_token === 'string') {
91
+ refreshParams.set('refresh_token', payload.refresh_token);
92
+ }
93
+ if (typeof payload.expires_at === 'number' || typeof payload.expires_at === 'string') {
94
+ refreshParams.set('expires_at', String(payload.expires_at));
95
+ }
96
+ if (typeof payload.expires_in === 'number' || typeof payload.expires_in === 'string') {
97
+ refreshParams.set('expires_in', String(payload.expires_in));
98
+ }
99
+ if (typeof payload.token_type === 'string') refreshParams.set('token_type', payload.token_type);
100
+
101
+ return buildSessionFromParams(refreshParams, nowSeconds);
102
+ };
103
+
104
+ return {
105
+ type: 'supabase',
106
+ readSessionFromCallback: (locationHash, nowSeconds) => {
107
+ const hashParams = new URLSearchParams(locationHash.replace(/^#/, ''));
108
+ return buildSessionFromParams(hashParams, nowSeconds);
109
+ },
110
+ exchangeOAuthCode: async () => null,
111
+ restoreSession: async (storedSession, nowSeconds) => {
112
+ if (!storedSession) return null;
113
+ if (!isSessionExpired(storedSession)) return storedSession;
114
+ return refreshWithStoredToken(storedSession, nowSeconds);
115
+ },
116
+ refreshAuthSession: async (currentSession, nowSeconds) => {
117
+ if (!currentSession?.refreshToken) return null;
118
+ return refreshWithStoredToken(currentSession, nowSeconds);
119
+ },
120
+ startOAuth: (provider, redirectPath) => {
121
+ if (!backendUrl || !publishableKey) {
122
+ throw new Error('Missing Supabase backend URL or publishableKey.');
123
+ }
124
+ const redirectTo = `${window.location.origin}${normalizeRedirectPath(redirectPath)}`;
125
+ const authorizeUrl = new URL(`${backendUrl}/auth/v1/authorize`);
126
+ authorizeUrl.searchParams.set('provider', provider);
127
+ authorizeUrl.searchParams.set('redirect_to', redirectTo);
128
+ authorizeUrl.searchParams.set('apikey', publishableKey);
129
+ window.location.assign(authorizeUrl.toString());
130
+ },
131
+ startWeb3Ethereum: async () => {
132
+ if (!backendUrl || !publishableKey) {
133
+ throw new Error('Missing Supabase backend URL or publishableKey.');
134
+ }
135
+ if (
136
+ typeof window === 'undefined' ||
137
+ typeof (window as Window & { ethereum?: unknown }).ethereum === 'undefined'
138
+ ) {
139
+ throw new Error('No Ethereum wallet found. Install a wallet like MetaMask.');
140
+ }
141
+
142
+ const supabase = createClient(backendUrl, publishableKey, {
143
+ auth: {
144
+ persistSession: false,
145
+ autoRefreshToken: false,
146
+ detectSessionInUrl: false,
147
+ },
148
+ });
149
+
150
+ const { data, error } = await supabase.auth.signInWithWeb3({
151
+ chain: 'ethereum',
152
+ statement: `Sign in to ${window.location.host}`,
153
+ });
154
+ if (error) {
155
+ throw new Error(error.message);
156
+ }
157
+
158
+ const session = data.session;
159
+ if (!session?.access_token || !session.refresh_token) {
160
+ throw new Error('Ethereum sign-in completed but no session was returned.');
161
+ }
162
+ return buildSessionFromSupabaseSession(session);
163
+ },
164
+ logout: async (session) => {
165
+ if (!backendUrl || !publishableKey || !session?.accessToken) {
166
+ return;
167
+ }
168
+
169
+ const signOutUrl = new URL(`${backendUrl}/auth/v1/logout`);
170
+ signOutUrl.searchParams.set('apikey', publishableKey);
171
+ await fetch(signOutUrl.toString(), {
172
+ method: 'POST',
173
+ headers: {
174
+ Accept: 'application/json',
175
+ apikey: publishableKey,
176
+ Authorization: `Bearer ${session.accessToken}`,
177
+ },
178
+ });
179
+ },
180
+ getAuthSettings: async () => {
181
+ if (!backendUrl) {
182
+ return { methods: [], oauthProviders: [], oauthClients: [] };
183
+ }
184
+
185
+ const endpoint = new URL(`${backendUrl}/auth/v1/settings`);
186
+ if (publishableKey) {
187
+ endpoint.searchParams.set('apikey', publishableKey);
188
+ }
189
+
190
+ const headers: HeadersInit = { Accept: 'application/json' };
191
+ if (publishableKey) {
192
+ headers.apikey = publishableKey;
193
+ headers.Authorization = `Bearer ${publishableKey}`;
194
+ }
195
+
196
+ const response = await fetch(endpoint.toString(), { method: 'GET', headers });
197
+ if (!response.ok) {
198
+ throw new Error(`HTTP ${response.status}`);
199
+ }
200
+
201
+ const payload = (await response.json()) as unknown;
202
+ return normalizeAuthSettings(payload);
203
+ },
204
+ sendMagicLink: async (email, redirectPath) => {
205
+ if (!backendUrl || !publishableKey) {
206
+ throw new Error('Missing Supabase backend URL or publishableKey.');
207
+ }
208
+
209
+ const otpUrl = new URL(`${backendUrl}/auth/v1/otp`);
210
+ otpUrl.searchParams.set('apikey', publishableKey);
211
+ const emailRedirectTo = `${window.location.origin}${normalizeRedirectPath(redirectPath)}`;
212
+
213
+ const response = await fetch(otpUrl.toString(), {
214
+ method: 'POST',
215
+ headers: {
216
+ Accept: 'application/json',
217
+ 'Content-Type': 'application/json',
218
+ apikey: publishableKey,
219
+ Authorization: `Bearer ${publishableKey}`,
220
+ },
221
+ body: JSON.stringify({
222
+ email,
223
+ create_user: true,
224
+ email_redirect_to: emailRedirectTo,
225
+ }),
226
+ });
227
+
228
+ if (!response.ok) {
229
+ const payload = (await response.json().catch(() => null)) as {
230
+ msg?: string;
231
+ message?: string;
232
+ error_description?: string;
233
+ } | null;
234
+ throw new Error(
235
+ payload?.msg ??
236
+ payload?.message ??
237
+ payload?.error_description ??
238
+ `Could not send magic link (HTTP ${response.status}).`,
239
+ );
240
+ }
241
+ },
242
+ syncUserPreferences: async (session, preferences: UserPreferences) => {
243
+ if (!backendUrl || !publishableKey || !session?.accessToken) {
244
+ return;
245
+ }
246
+
247
+ const userUrl = new URL(`${backendUrl}${USER_METADATA_ENDPOINT}`);
248
+ userUrl.searchParams.set('apikey', publishableKey);
249
+
250
+ const response = await fetch(userUrl.toString(), {
251
+ method: 'PUT',
252
+ headers: {
253
+ Accept: 'application/json',
254
+ 'Content-Type': 'application/json',
255
+ apikey: publishableKey,
256
+ Authorization: `Bearer ${session.accessToken}`,
257
+ },
258
+ body: JSON.stringify({
259
+ data: {
260
+ [APP_PREFERENCES_METADATA_KEY]: preferences,
261
+ },
262
+ }),
263
+ });
264
+
265
+ if (!response.ok) {
266
+ throw new Error(`HTTP ${response.status}`);
267
+ }
268
+ },
269
+ loadUserPreferences: async (session) => {
270
+ if (!backendUrl || !publishableKey || !session?.accessToken) {
271
+ return null;
272
+ }
273
+
274
+ const userUrl = new URL(`${backendUrl}${USER_METADATA_ENDPOINT}`);
275
+ userUrl.searchParams.set('apikey', publishableKey);
276
+
277
+ const response = await fetch(userUrl.toString(), {
278
+ method: 'GET',
279
+ headers: {
280
+ Accept: 'application/json',
281
+ apikey: publishableKey,
282
+ Authorization: `Bearer ${session.accessToken}`,
283
+ },
284
+ });
285
+
286
+ if (!response.ok) {
287
+ throw new Error(`HTTP ${response.status}`);
288
+ }
289
+
290
+ const payload = (await response.json()) as {
291
+ user_metadata?: Record<string, unknown>;
292
+ };
293
+ const preferences = payload.user_metadata?.[APP_PREFERENCES_METADATA_KEY];
294
+ if (!preferences || typeof preferences !== 'object') {
295
+ return null;
296
+ }
297
+ return preferences as UserPreferences;
298
+ },
299
+ };
300
+ };
@@ -0,0 +1,30 @@
1
+ import type { BackendConfig } from '../../config/types';
2
+ import type { AuthSession, AuthSettings, UserPreferences } from '../types';
3
+
4
+ export interface AuthBackend {
5
+ type: BackendConfig['type'] | 'none';
6
+ readSessionFromCallback: (locationHash: string, nowSeconds: number) => AuthSession | null;
7
+ exchangeOAuthCode: (params: {
8
+ provider: string;
9
+ code: string;
10
+ redirectUri: string;
11
+ oauthClientId?: number;
12
+ nowSeconds: number;
13
+ }) => Promise<AuthSession | null>;
14
+ restoreSession: (
15
+ storedSession: AuthSession | null,
16
+ nowSeconds: number,
17
+ ) => Promise<AuthSession | null>;
18
+ /** Re-fetch tokens so the access JWT reflects latest user claims (e.g. after preference sync). */
19
+ refreshAuthSession: (
20
+ session: AuthSession | null,
21
+ nowSeconds: number,
22
+ ) => Promise<AuthSession | null>;
23
+ startOAuth: (provider: string, redirectPath: string, oauthClientId?: number) => void;
24
+ startWeb3Ethereum: () => Promise<AuthSession | null>;
25
+ logout: (session: AuthSession | null) => Promise<void>;
26
+ getAuthSettings: () => Promise<AuthSettings>;
27
+ sendMagicLink: (email: string, redirectPath: string) => Promise<void>;
28
+ syncUserPreferences: (session: AuthSession | null, preferences: UserPreferences) => Promise<void>;
29
+ loadUserPreferences: (session: AuthSession | null) => Promise<UserPreferences | null>;
30
+ }