@qwickapps/server 1.3.0 → 1.3.1
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/README.md +154 -0
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +30 -2
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +36 -0
- package/dist/core/plugin-registry.d.ts.map +1 -1
- package/dist/core/plugin-registry.js +26 -0
- package/dist/core/plugin-registry.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/auth/adapters/index.d.ts +1 -0
- package/dist/plugins/auth/adapters/index.d.ts.map +1 -1
- package/dist/plugins/auth/adapters/index.js +1 -0
- package/dist/plugins/auth/adapters/index.js.map +1 -1
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -1
- package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -1
- package/dist/plugins/auth/adapters/supertokens-adapter.d.ts +18 -0
- package/dist/plugins/auth/adapters/supertokens-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/supertokens-adapter.js +267 -0
- package/dist/plugins/auth/adapters/supertokens-adapter.js.map +1 -0
- package/dist/plugins/auth/env-config.d.ts +88 -0
- package/dist/plugins/auth/env-config.d.ts.map +1 -0
- package/dist/plugins/auth/env-config.js +489 -0
- package/dist/plugins/auth/env-config.js.map +1 -0
- package/dist/plugins/auth/index.d.ts +3 -1
- package/dist/plugins/auth/index.d.ts.map +1 -1
- package/dist/plugins/auth/index.js +3 -0
- package/dist/plugins/auth/index.js.map +1 -1
- package/dist/plugins/auth/supertokens-adapter.test.d.ts +10 -0
- package/dist/plugins/auth/supertokens-adapter.test.d.ts.map +1 -0
- package/dist/plugins/auth/supertokens-adapter.test.js +486 -0
- package/dist/plugins/auth/supertokens-adapter.test.js.map +1 -0
- package/dist/plugins/auth/types.d.ts +70 -0
- package/dist/plugins/auth/types.d.ts.map +1 -1
- package/dist/plugins/auth/types.js.map +1 -1
- package/dist/plugins/cache-plugin.test.js +3 -0
- package/dist/plugins/cache-plugin.test.js.map +1 -1
- package/dist/plugins/index.d.ts +4 -2
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +3 -1
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/postgres-plugin.test.js +3 -0
- package/dist/plugins/postgres-plugin.test.js.map +1 -1
- package/dist/plugins/preferences/__tests__/deep-merge.test.d.ts +7 -0
- package/dist/plugins/preferences/__tests__/deep-merge.test.d.ts.map +1 -0
- package/dist/plugins/preferences/__tests__/deep-merge.test.js +215 -0
- package/dist/plugins/preferences/__tests__/deep-merge.test.js.map +1 -0
- package/dist/plugins/preferences/__tests__/preferences-plugin.test.d.ts +7 -0
- package/dist/plugins/preferences/__tests__/preferences-plugin.test.d.ts.map +1 -0
- package/dist/plugins/preferences/__tests__/preferences-plugin.test.js +265 -0
- package/dist/plugins/preferences/__tests__/preferences-plugin.test.js.map +1 -0
- package/dist/plugins/preferences/index.d.ts +12 -0
- package/dist/plugins/preferences/index.d.ts.map +1 -0
- package/dist/plugins/preferences/index.js +13 -0
- package/dist/plugins/preferences/index.js.map +1 -0
- package/dist/plugins/preferences/preferences-plugin.d.ts +39 -0
- package/dist/plugins/preferences/preferences-plugin.d.ts.map +1 -0
- package/dist/plugins/preferences/preferences-plugin.js +226 -0
- package/dist/plugins/preferences/preferences-plugin.js.map +1 -0
- package/dist/plugins/preferences/stores/index.d.ts +9 -0
- package/dist/plugins/preferences/stores/index.d.ts.map +1 -0
- package/dist/plugins/preferences/stores/index.js +9 -0
- package/dist/plugins/preferences/stores/index.js.map +1 -0
- package/dist/plugins/preferences/stores/postgres-store.d.ts +41 -0
- package/dist/plugins/preferences/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/preferences/stores/postgres-store.js +181 -0
- package/dist/plugins/preferences/stores/postgres-store.js.map +1 -0
- package/dist/plugins/preferences/types.d.ts +91 -0
- package/dist/plugins/preferences/types.d.ts.map +1 -0
- package/dist/plugins/preferences/types.js +10 -0
- package/dist/plugins/preferences/types.js.map +1 -0
- package/dist/plugins/users/__tests__/users-plugin.test.d.ts +9 -0
- package/dist/plugins/users/__tests__/users-plugin.test.d.ts.map +1 -0
- package/dist/plugins/users/__tests__/users-plugin.test.js +546 -0
- package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -0
- package/dist/plugins/users/index.d.ts +2 -2
- package/dist/plugins/users/index.d.ts.map +1 -1
- package/dist/plugins/users/index.js +1 -1
- package/dist/plugins/users/index.js.map +1 -1
- package/dist/plugins/users/types.d.ts +36 -0
- package/dist/plugins/users/types.d.ts.map +1 -1
- package/dist/plugins/users/users-plugin.d.ts +8 -2
- package/dist/plugins/users/users-plugin.d.ts.map +1 -1
- package/dist/plugins/users/users-plugin.js +122 -5
- package/dist/plugins/users/users-plugin.js.map +1 -1
- package/dist-ui/assets/{index-Bsp2ntcw.js → index-BY8OxNgO.js} +112 -112
- package/dist-ui/assets/index-BY8OxNgO.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +53 -7
- package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +9 -5
- package/dist-ui-lib/dashboard/builtInWidgets.d.ts +7 -1
- package/dist-ui-lib/index.js +2382 -3651
- package/dist-ui-lib/index.js.map +1 -1
- package/dist-ui-lib/pages/AuthPage.d.ts +1 -0
- package/dist-ui-lib/pages/PluginsPage.d.ts +1 -0
- package/package.json +7 -2
- package/src/core/control-panel.ts +33 -2
- package/src/core/plugin-registry.ts +63 -0
- package/src/index.ts +7 -0
- package/src/plugins/auth/adapters/index.ts +1 -0
- package/src/plugins/auth/adapters/supabase-adapter.ts +22 -14
- package/src/plugins/auth/adapters/supertokens-adapter.ts +326 -0
- package/src/plugins/auth/env-config.ts +572 -0
- package/src/plugins/auth/index.ts +9 -0
- package/src/plugins/auth/supertokens-adapter.test.ts +621 -0
- package/src/plugins/auth/types.ts +80 -0
- package/src/plugins/cache-plugin.test.ts +3 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/postgres-plugin.test.ts +3 -0
- package/src/plugins/preferences/__tests__/deep-merge.test.ts +242 -0
- package/src/plugins/preferences/__tests__/preferences-plugin.test.ts +350 -0
- package/src/plugins/preferences/index.ts +30 -0
- package/src/plugins/preferences/preferences-plugin.ts +270 -0
- package/src/plugins/preferences/stores/index.ts +9 -0
- package/src/plugins/preferences/stores/postgres-store.ts +252 -0
- package/src/plugins/preferences/types.ts +100 -0
- package/src/plugins/users/__tests__/users-plugin.test.ts +690 -0
- package/src/plugins/users/index.ts +3 -0
- package/src/plugins/users/types.ts +38 -0
- package/src/plugins/users/users-plugin.ts +142 -5
- package/ui/src/App.tsx +4 -1
- package/ui/src/api/controlPanelApi.ts +100 -1
- package/ui/src/components/ControlPanelApp.tsx +3 -0
- package/ui/src/dashboard/PluginWidgetRenderer.tsx +13 -10
- package/ui/src/dashboard/WidgetComponentRegistry.tsx +13 -9
- package/ui/src/dashboard/builtInWidgets.tsx +8 -2
- package/ui/src/pages/AuthPage.tsx +259 -0
- package/ui/src/pages/PluginsPage.tsx +394 -0
- package/ui/vite.lib.config.ts +5 -0
- package/dist-ui/assets/index-Bsp2ntcw.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function AuthPage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function PluginsPage(): import("react/jsx-runtime").JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qwickapps/server",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Plugin-based application server framework for building websites, APIs, admin dashboards, and full-stack products",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"react": "^18.2.0",
|
|
77
77
|
"react-dom": "^18.2.0",
|
|
78
78
|
"react-router-dom": "^6.30.1",
|
|
79
|
+
"supertokens-node": "^20.1.7",
|
|
79
80
|
"tsx": "^4.20.6",
|
|
80
81
|
"typescript": "^5.3.3",
|
|
81
82
|
"vite": "^6.0.0",
|
|
@@ -85,7 +86,8 @@
|
|
|
85
86
|
"@qwickapps/react-framework": ">=1.0.0",
|
|
86
87
|
"express-openid-connect": ">=2.0.0",
|
|
87
88
|
"ioredis": ">=5.0.0",
|
|
88
|
-
"pg": ">=8.0.0"
|
|
89
|
+
"pg": ">=8.0.0",
|
|
90
|
+
"supertokens-node": ">=20.0.0"
|
|
89
91
|
},
|
|
90
92
|
"peerDependenciesMeta": {
|
|
91
93
|
"@qwickapps/react-framework": {
|
|
@@ -99,6 +101,9 @@
|
|
|
99
101
|
},
|
|
100
102
|
"pg": {
|
|
101
103
|
"optional": true
|
|
104
|
+
},
|
|
105
|
+
"supertokens-node": {
|
|
106
|
+
"optional": true
|
|
102
107
|
}
|
|
103
108
|
},
|
|
104
109
|
"keywords": [
|
|
@@ -197,11 +197,42 @@ export function createControlPanel(options: CreateControlPanelOptions): ControlP
|
|
|
197
197
|
});
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
|
-
* GET /api/plugins - List all registered plugins
|
|
200
|
+
* GET /api/plugins - List all registered plugins with contribution counts
|
|
201
201
|
*/
|
|
202
202
|
router.get('/plugins', (_req: Request, res: Response) => {
|
|
203
|
+
const plugins = pluginRegistry.listPlugins().map((plugin) => {
|
|
204
|
+
const contributions = pluginRegistry.getPluginContributions(plugin.id);
|
|
205
|
+
return {
|
|
206
|
+
...plugin,
|
|
207
|
+
contributionCounts: {
|
|
208
|
+
routes: contributions.routes.length,
|
|
209
|
+
menuItems: contributions.menuItems.length,
|
|
210
|
+
pages: contributions.pages.length,
|
|
211
|
+
widgets: contributions.widgets.length,
|
|
212
|
+
hasConfig: !!contributions.config,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
res.json({ plugins });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* GET /api/plugins/:id - Get detailed plugin info with contributions
|
|
221
|
+
*/
|
|
222
|
+
router.get('/plugins/:id', (req: Request, res: Response) => {
|
|
223
|
+
const { id } = req.params;
|
|
224
|
+
const plugins = pluginRegistry.listPlugins();
|
|
225
|
+
const plugin = plugins.find((p) => p.id === id);
|
|
226
|
+
|
|
227
|
+
if (!plugin) {
|
|
228
|
+
res.status(404).json({ error: `Plugin not found: ${id}` });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const contributions = pluginRegistry.getPluginContributions(id);
|
|
203
233
|
res.json({
|
|
204
|
-
|
|
234
|
+
...plugin,
|
|
235
|
+
contributions,
|
|
205
236
|
});
|
|
206
237
|
});
|
|
207
238
|
|
|
@@ -164,6 +164,31 @@ export interface RouteDefinition {
|
|
|
164
164
|
pluginId: string;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Configuration UI contribution for plugin settings
|
|
169
|
+
*/
|
|
170
|
+
export interface ConfigContribution {
|
|
171
|
+
/** Unique ID for this config contribution */
|
|
172
|
+
id: string;
|
|
173
|
+
/** React component name to render (matched by frontend registry) */
|
|
174
|
+
component: string;
|
|
175
|
+
/** Display title for the config section */
|
|
176
|
+
title?: string;
|
|
177
|
+
/** Plugin ID that contributed this */
|
|
178
|
+
pluginId: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Aggregated contributions for a specific plugin
|
|
183
|
+
*/
|
|
184
|
+
export interface PluginContributions {
|
|
185
|
+
routes: Array<{ method: string; path: string }>;
|
|
186
|
+
menuItems: MenuContribution[];
|
|
187
|
+
pages: PageContribution[];
|
|
188
|
+
widgets: WidgetContribution[];
|
|
189
|
+
config?: ConfigContribution;
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
// =============================================================================
|
|
168
193
|
// Plugin Registry Interface
|
|
169
194
|
// =============================================================================
|
|
@@ -204,6 +229,9 @@ export interface PluginRegistry {
|
|
|
204
229
|
/** Register a widget */
|
|
205
230
|
addWidget(widget: WidgetContribution): void;
|
|
206
231
|
|
|
232
|
+
/** Register a config component for plugin settings UI */
|
|
233
|
+
addConfigComponent(config: ConfigContribution): void;
|
|
234
|
+
|
|
207
235
|
// ---------------------------------------------------------------------------
|
|
208
236
|
// Contribution queries
|
|
209
237
|
// ---------------------------------------------------------------------------
|
|
@@ -220,6 +248,12 @@ export interface PluginRegistry {
|
|
|
220
248
|
/** Get all widgets */
|
|
221
249
|
getWidgets(): WidgetContribution[];
|
|
222
250
|
|
|
251
|
+
/** Get all config components */
|
|
252
|
+
getConfigComponents(): ConfigContribution[];
|
|
253
|
+
|
|
254
|
+
/** Get all contributions for a specific plugin */
|
|
255
|
+
getPluginContributions(pluginId: string): PluginContributions;
|
|
256
|
+
|
|
223
257
|
// ---------------------------------------------------------------------------
|
|
224
258
|
// Configuration
|
|
225
259
|
// ---------------------------------------------------------------------------
|
|
@@ -292,6 +326,7 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
292
326
|
private menuItems: MenuContribution[] = [];
|
|
293
327
|
private pages: PageContribution[] = [];
|
|
294
328
|
private widgets: WidgetContribution[] = [];
|
|
329
|
+
private configComponents: ConfigContribution[] = [];
|
|
295
330
|
|
|
296
331
|
private eventHandlers = new Set<PluginEventHandler>();
|
|
297
332
|
|
|
@@ -385,6 +420,17 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
385
420
|
this.logger.debug(`Widget registered: ${widget.title} by ${widget.pluginId}`);
|
|
386
421
|
}
|
|
387
422
|
|
|
423
|
+
addConfigComponent(config: ConfigContribution): void {
|
|
424
|
+
// Only one config component per plugin - warn if replacing
|
|
425
|
+
const existing = this.configComponents.find((c) => c.pluginId === config.pluginId);
|
|
426
|
+
if (existing) {
|
|
427
|
+
this.logger.warn(`Replacing config component for plugin ${config.pluginId}: ${existing.component} → ${config.component}`);
|
|
428
|
+
}
|
|
429
|
+
this.configComponents = this.configComponents.filter((c) => c.pluginId !== config.pluginId);
|
|
430
|
+
this.configComponents.push(config);
|
|
431
|
+
this.logger.debug(`Config component registered: ${config.component} by ${config.pluginId}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
388
434
|
// ---------------------------------------------------------------------------
|
|
389
435
|
// Contribution queries
|
|
390
436
|
// ---------------------------------------------------------------------------
|
|
@@ -405,6 +451,22 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
405
451
|
return [...this.widgets];
|
|
406
452
|
}
|
|
407
453
|
|
|
454
|
+
getConfigComponents(): ConfigContribution[] {
|
|
455
|
+
return [...this.configComponents];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
getPluginContributions(pluginId: string): PluginContributions {
|
|
459
|
+
return {
|
|
460
|
+
routes: this.routes
|
|
461
|
+
.filter((r) => r.pluginId === pluginId)
|
|
462
|
+
.map((r) => ({ method: r.method, path: r.path })),
|
|
463
|
+
menuItems: this.menuItems.filter((m) => m.pluginId === pluginId),
|
|
464
|
+
pages: this.pages.filter((p) => p.pluginId === pluginId),
|
|
465
|
+
widgets: this.widgets.filter((w) => w.pluginId === pluginId),
|
|
466
|
+
config: this.configComponents.find((c) => c.pluginId === pluginId),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
408
470
|
// ---------------------------------------------------------------------------
|
|
409
471
|
// Configuration
|
|
410
472
|
// ---------------------------------------------------------------------------
|
|
@@ -577,6 +639,7 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
577
639
|
this.menuItems = this.menuItems.filter((m) => m.pluginId !== pluginId);
|
|
578
640
|
this.pages = this.pages.filter((p) => p.pluginId !== pluginId);
|
|
579
641
|
this.widgets = this.widgets.filter((w) => w.pluginId !== pluginId);
|
|
642
|
+
this.configComponents = this.configComponents.filter((c) => c.pluginId !== pluginId);
|
|
580
643
|
|
|
581
644
|
this.emit({
|
|
582
645
|
type: 'plugin:stopped',
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,8 @@ export {
|
|
|
89
89
|
hasCache,
|
|
90
90
|
// Auth plugin
|
|
91
91
|
createAuthPlugin,
|
|
92
|
+
createAuthPluginFromEnv,
|
|
93
|
+
getAuthStatus,
|
|
92
94
|
isAuthenticated,
|
|
93
95
|
getAuthenticatedUser,
|
|
94
96
|
getAccessToken,
|
|
@@ -98,6 +100,7 @@ export {
|
|
|
98
100
|
auth0Adapter,
|
|
99
101
|
basicAdapter,
|
|
100
102
|
supabaseAdapter,
|
|
103
|
+
supertokensAdapter,
|
|
101
104
|
isAuthenticatedRequest,
|
|
102
105
|
// Users plugin
|
|
103
106
|
createUsersPlugin,
|
|
@@ -159,6 +162,10 @@ export type {
|
|
|
159
162
|
Auth0AdapterConfig,
|
|
160
163
|
SupabaseAdapterConfig,
|
|
161
164
|
BasicAdapterConfig,
|
|
165
|
+
SupertokensAdapterConfig,
|
|
166
|
+
AuthPluginState,
|
|
167
|
+
AuthEnvPluginOptions,
|
|
168
|
+
AuthConfigStatus,
|
|
162
169
|
// Users plugin types
|
|
163
170
|
UsersPluginConfig,
|
|
164
171
|
UserStore,
|
|
@@ -9,6 +9,26 @@
|
|
|
9
9
|
import type { Request, Response, RequestHandler } from 'express';
|
|
10
10
|
import type { AuthAdapter, AuthenticatedUser, SupabaseAdapterConfig } from '../types.js';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Supabase user response from /auth/v1/user endpoint
|
|
14
|
+
* @see https://supabase.com/docs/reference/javascript/auth-getuser
|
|
15
|
+
*/
|
|
16
|
+
interface SupabaseUserResponse {
|
|
17
|
+
id: string;
|
|
18
|
+
email: string;
|
|
19
|
+
email_confirmed_at?: string;
|
|
20
|
+
user_metadata?: {
|
|
21
|
+
full_name?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
avatar_url?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
app_metadata?: {
|
|
27
|
+
roles?: string[];
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
/**
|
|
13
33
|
* Create a Supabase authentication adapter
|
|
14
34
|
*/
|
|
@@ -68,19 +88,7 @@ export function supabaseAdapter(config: SupabaseAdapterConfig): AuthAdapter {
|
|
|
68
88
|
return null;
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
const supabaseUser = (await response.json()) as
|
|
72
|
-
id: string;
|
|
73
|
-
email: string;
|
|
74
|
-
email_confirmed_at?: string;
|
|
75
|
-
user_metadata?: {
|
|
76
|
-
full_name?: string;
|
|
77
|
-
name?: string;
|
|
78
|
-
avatar_url?: string;
|
|
79
|
-
};
|
|
80
|
-
app_metadata?: {
|
|
81
|
-
roles?: string[];
|
|
82
|
-
};
|
|
83
|
-
};
|
|
91
|
+
const supabaseUser = (await response.json()) as SupabaseUserResponse;
|
|
84
92
|
|
|
85
93
|
const user: AuthenticatedUser = {
|
|
86
94
|
id: supabaseUser.id,
|
|
@@ -89,7 +97,7 @@ export function supabaseAdapter(config: SupabaseAdapterConfig): AuthAdapter {
|
|
|
89
97
|
picture: supabaseUser.user_metadata?.avatar_url,
|
|
90
98
|
emailVerified: !!supabaseUser.email_confirmed_at,
|
|
91
99
|
roles: supabaseUser.app_metadata?.roles || [],
|
|
92
|
-
raw: supabaseUser,
|
|
100
|
+
raw: supabaseUser as unknown as Record<string, unknown>,
|
|
93
101
|
};
|
|
94
102
|
|
|
95
103
|
// Cache the validated user
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supertokens Auth Adapter
|
|
3
|
+
*
|
|
4
|
+
* Provides Supertokens authentication using EmailPassword and ThirdParty recipes.
|
|
5
|
+
* Supports email/password and social logins (Google, Apple, GitHub).
|
|
6
|
+
*
|
|
7
|
+
* Note: Requires supertokens-node v20+
|
|
8
|
+
*
|
|
9
|
+
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Request, Response, RequestHandler } from 'express';
|
|
13
|
+
import type { AuthAdapter, AuthenticatedUser, SupertokensAdapterConfig } from '../types.js';
|
|
14
|
+
|
|
15
|
+
// Keys for storing data on the request object
|
|
16
|
+
const REQUEST_USER_KEY = '_supertokensUser';
|
|
17
|
+
const REQUEST_RES_KEY = '_supertokensRes';
|
|
18
|
+
const REQUEST_SESSION_KEY = '_supertokensSession';
|
|
19
|
+
|
|
20
|
+
// Type for extended request with our custom properties
|
|
21
|
+
interface SupertokensExtendedRequest extends Request {
|
|
22
|
+
[REQUEST_USER_KEY]?: AuthenticatedUser;
|
|
23
|
+
[REQUEST_RES_KEY]?: Response;
|
|
24
|
+
[REQUEST_SESSION_KEY]?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a Supertokens authentication adapter
|
|
29
|
+
*
|
|
30
|
+
* Uses EmailPassword and ThirdParty recipes (Supertokens v20+)
|
|
31
|
+
*/
|
|
32
|
+
export function supertokensAdapter(config: SupertokensAdapterConfig): AuthAdapter {
|
|
33
|
+
// Track initialization state
|
|
34
|
+
let initialized = false;
|
|
35
|
+
let initializationError: Error | null = null;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: 'supertokens',
|
|
39
|
+
|
|
40
|
+
initialize(): RequestHandler[] {
|
|
41
|
+
// Return middleware that lazily initializes Supertokens
|
|
42
|
+
const initMiddleware: RequestHandler = async (
|
|
43
|
+
req: Request,
|
|
44
|
+
res: Response,
|
|
45
|
+
next: (err?: unknown) => void
|
|
46
|
+
) => {
|
|
47
|
+
// Store response on request for later use in getUser()
|
|
48
|
+
(req as SupertokensExtendedRequest)[REQUEST_RES_KEY] = res;
|
|
49
|
+
|
|
50
|
+
// Skip if already initialized with error
|
|
51
|
+
if (initializationError) {
|
|
52
|
+
return res.status(500).json({
|
|
53
|
+
error: 'Auth Configuration Error',
|
|
54
|
+
message:
|
|
55
|
+
'Supertokens is not properly configured. Install supertokens-node package: npm install supertokens-node',
|
|
56
|
+
details: initializationError.message,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Lazy initialize Supertokens
|
|
61
|
+
if (!initialized) {
|
|
62
|
+
try {
|
|
63
|
+
const supertokens = await import('supertokens-node');
|
|
64
|
+
const Session = await import('supertokens-node/recipe/session');
|
|
65
|
+
const EmailPassword = await import('supertokens-node/recipe/emailpassword');
|
|
66
|
+
const ThirdParty = await import('supertokens-node/recipe/thirdparty');
|
|
67
|
+
|
|
68
|
+
// Build recipe list - using any[] for Supertokens internal types
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
const recipeList: any[] = [];
|
|
71
|
+
|
|
72
|
+
// Add EmailPassword recipe if enabled (default: true)
|
|
73
|
+
if (config.enableEmailPassword !== false) {
|
|
74
|
+
recipeList.push(EmailPassword.default.init());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add ThirdParty recipe if any social providers configured
|
|
78
|
+
if (config.socialProviders) {
|
|
79
|
+
// Build provider configurations using Supertokens ProviderInput type
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
|
+
const providers: any[] = [];
|
|
82
|
+
|
|
83
|
+
if (config.socialProviders.google) {
|
|
84
|
+
providers.push({
|
|
85
|
+
config: {
|
|
86
|
+
thirdPartyId: 'google',
|
|
87
|
+
clients: [
|
|
88
|
+
{
|
|
89
|
+
clientId: config.socialProviders.google.clientId,
|
|
90
|
+
clientSecret: config.socialProviders.google.clientSecret,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (config.socialProviders.apple) {
|
|
98
|
+
// Apple requires keyId, teamId, and privateKey in additionalConfig
|
|
99
|
+
providers.push({
|
|
100
|
+
config: {
|
|
101
|
+
thirdPartyId: 'apple',
|
|
102
|
+
clients: [
|
|
103
|
+
{
|
|
104
|
+
clientId: config.socialProviders.apple.clientId,
|
|
105
|
+
clientSecret: config.socialProviders.apple.clientSecret,
|
|
106
|
+
additionalConfig: {
|
|
107
|
+
keyId: config.socialProviders.apple.keyId,
|
|
108
|
+
teamId: config.socialProviders.apple.teamId,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (config.socialProviders.github) {
|
|
117
|
+
providers.push({
|
|
118
|
+
config: {
|
|
119
|
+
thirdPartyId: 'github',
|
|
120
|
+
clients: [
|
|
121
|
+
{
|
|
122
|
+
clientId: config.socialProviders.github.clientId,
|
|
123
|
+
clientSecret: config.socialProviders.github.clientSecret,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (providers.length > 0) {
|
|
131
|
+
recipeList.push(
|
|
132
|
+
ThirdParty.default.init({
|
|
133
|
+
signInAndUpFeature: {
|
|
134
|
+
providers,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Always add Session recipe
|
|
142
|
+
recipeList.push(Session.default.init());
|
|
143
|
+
|
|
144
|
+
// Initialize Supertokens
|
|
145
|
+
supertokens.default.init({
|
|
146
|
+
framework: 'express',
|
|
147
|
+
supertokens: {
|
|
148
|
+
connectionURI: config.connectionUri,
|
|
149
|
+
apiKey: config.apiKey,
|
|
150
|
+
},
|
|
151
|
+
appInfo: {
|
|
152
|
+
appName: config.appName,
|
|
153
|
+
apiDomain: config.apiDomain,
|
|
154
|
+
websiteDomain: config.websiteDomain,
|
|
155
|
+
apiBasePath: config.apiBasePath ?? '/auth',
|
|
156
|
+
websiteBasePath: config.websiteBasePath ?? '/auth',
|
|
157
|
+
},
|
|
158
|
+
recipeList,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
initialized = true;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
initializationError =
|
|
164
|
+
error instanceof Error ? error : new Error('Failed to initialize Supertokens');
|
|
165
|
+
console.error('[SupertokensAdapter] Initialization error:', error);
|
|
166
|
+
return res.status(500).json({
|
|
167
|
+
error: 'Auth Configuration Error',
|
|
168
|
+
message:
|
|
169
|
+
'Supertokens is not properly configured. Install supertokens-node package: npm install supertokens-node',
|
|
170
|
+
details: initializationError.message,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
next();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Supertokens middleware for handling auth routes
|
|
179
|
+
const supertokensMiddleware: RequestHandler = async (req, res, next) => {
|
|
180
|
+
if (!initialized) {
|
|
181
|
+
return next();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const { middleware } = await import('supertokens-node/framework/express');
|
|
186
|
+
middleware()(req, res, next);
|
|
187
|
+
} catch {
|
|
188
|
+
next();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return [initMiddleware, supertokensMiddleware];
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
isAuthenticated(req: Request): boolean {
|
|
196
|
+
const extReq = req as SupertokensExtendedRequest;
|
|
197
|
+
|
|
198
|
+
// Check if we already validated this request
|
|
199
|
+
if (extReq[REQUEST_USER_KEY]) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Check if session was already retrieved
|
|
204
|
+
if (extReq[REQUEST_SESSION_KEY]) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// For synchronous check, we can only check if session cookies exist
|
|
209
|
+
// Full validation happens in getUser()
|
|
210
|
+
// Supertokens uses cookies, so we check for session tokens
|
|
211
|
+
const cookies = req.cookies || {};
|
|
212
|
+
const accessToken = cookies.sAccessToken;
|
|
213
|
+
const refreshToken = cookies.sRefreshToken;
|
|
214
|
+
|
|
215
|
+
// Also check for Authorization header (for API clients)
|
|
216
|
+
const authHeader = req.headers.authorization;
|
|
217
|
+
const hasBearerToken = authHeader?.startsWith('Bearer ');
|
|
218
|
+
|
|
219
|
+
return !!(accessToken || refreshToken || hasBearerToken);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async getUser(req: Request): Promise<AuthenticatedUser | null> {
|
|
223
|
+
const extReq = req as SupertokensExtendedRequest;
|
|
224
|
+
|
|
225
|
+
// Return cached user if available
|
|
226
|
+
const cachedUser = extReq[REQUEST_USER_KEY];
|
|
227
|
+
if (cachedUser) {
|
|
228
|
+
return cachedUser;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!initialized) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Get response object stored during middleware
|
|
236
|
+
const res = extReq[REQUEST_RES_KEY];
|
|
237
|
+
if (!res) {
|
|
238
|
+
console.error('[SupertokensAdapter] Response object not found on request');
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const Session = await import('supertokens-node/recipe/session');
|
|
244
|
+
const supertokens = await import('supertokens-node');
|
|
245
|
+
|
|
246
|
+
// Get session - sessionRequired: false means it won't throw if no session
|
|
247
|
+
const session = await Session.default.getSession(req, res, {
|
|
248
|
+
sessionRequired: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (!session) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Cache session for isAuthenticated check
|
|
256
|
+
extReq[REQUEST_SESSION_KEY] = session;
|
|
257
|
+
|
|
258
|
+
const userId = session.getUserId();
|
|
259
|
+
|
|
260
|
+
// Get user info from Supertokens
|
|
261
|
+
const userInfo = await supertokens.default.getUser(userId);
|
|
262
|
+
|
|
263
|
+
if (!userInfo) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Get roles from session access token payload if available
|
|
268
|
+
const accessTokenPayload = session.getAccessTokenPayload();
|
|
269
|
+
const roles: string[] = accessTokenPayload?.roles || [];
|
|
270
|
+
|
|
271
|
+
// Map Supertokens user to AuthenticatedUser
|
|
272
|
+
const user: AuthenticatedUser = {
|
|
273
|
+
id: userId,
|
|
274
|
+
email: userInfo.emails?.[0] ?? '',
|
|
275
|
+
name:
|
|
276
|
+
accessTokenPayload?.name ||
|
|
277
|
+
userInfo.thirdParty?.[0]?.userId ||
|
|
278
|
+
userInfo.emails?.[0]?.split('@')[0],
|
|
279
|
+
picture: accessTokenPayload?.picture,
|
|
280
|
+
emailVerified: userInfo.emails?.[0] ? true : false,
|
|
281
|
+
roles,
|
|
282
|
+
raw: {
|
|
283
|
+
...userInfo,
|
|
284
|
+
sessionHandle: session.getHandle(),
|
|
285
|
+
accessTokenPayload,
|
|
286
|
+
} as Record<string, unknown>,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Cache on request object
|
|
290
|
+
extReq[REQUEST_USER_KEY] = user;
|
|
291
|
+
|
|
292
|
+
return user;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error('[SupertokensAdapter] Error getting user:', error);
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
hasRoles(req: Request, roles: string[]): boolean {
|
|
300
|
+
const extReq = req as SupertokensExtendedRequest;
|
|
301
|
+
const user = extReq[REQUEST_USER_KEY];
|
|
302
|
+
if (!user?.roles) return false;
|
|
303
|
+
return roles.every((role) => user.roles?.includes(role));
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
getAccessToken(_req: Request): string | null {
|
|
307
|
+
// Supertokens uses session cookies, not access tokens
|
|
308
|
+
// Return null as per the design decision
|
|
309
|
+
return null;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
onUnauthorized(_req: Request, res: Response): void {
|
|
313
|
+
res.status(401).json({
|
|
314
|
+
error: 'Unauthorized',
|
|
315
|
+
message: 'Authentication required. Please sign in.',
|
|
316
|
+
hint: 'Use the /auth endpoints to authenticate',
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
async shutdown(): Promise<void> {
|
|
321
|
+
// Supertokens doesn't require explicit cleanup
|
|
322
|
+
initialized = false;
|
|
323
|
+
initializationError = null;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|