@qwickapps/server 1.5.2 → 1.6.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 (80) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/core/control-panel.js +8 -8
  3. package/dist/core/control-panel.js.map +1 -1
  4. package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
  5. package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
  6. package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
  7. package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
  8. package/dist/plugins/api-keys/index.d.ts +14 -0
  9. package/dist/plugins/api-keys/index.d.ts.map +1 -0
  10. package/dist/plugins/api-keys/index.js +17 -0
  11. package/dist/plugins/api-keys/index.js.map +1 -0
  12. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
  13. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
  14. package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
  15. package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
  16. package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
  17. package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
  18. package/dist/plugins/api-keys/middleware/index.js +7 -0
  19. package/dist/plugins/api-keys/middleware/index.js.map +1 -0
  20. package/dist/plugins/api-keys/stores/index.d.ts +7 -0
  21. package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
  22. package/dist/plugins/api-keys/stores/index.js +7 -0
  23. package/dist/plugins/api-keys/stores/index.js.map +1 -0
  24. package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
  25. package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
  26. package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
  27. package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
  28. package/dist/plugins/api-keys/types.d.ts +268 -0
  29. package/dist/plugins/api-keys/types.d.ts.map +1 -0
  30. package/dist/plugins/api-keys/types.js +56 -0
  31. package/dist/plugins/api-keys/types.js.map +1 -0
  32. package/dist/plugins/auth/auth-plugin.js +1 -1
  33. package/dist/plugins/auth/auth-plugin.js.map +1 -1
  34. package/dist/plugins/auth/env-config.js +2 -2
  35. package/dist/plugins/auth/env-config.js.map +1 -1
  36. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  37. package/dist/plugins/frontend-app-plugin.js +5 -2
  38. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  39. package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
  40. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
  41. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  42. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  43. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  44. package/dist/plugins/users/stores/postgres-store.js +59 -1
  45. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  46. package/dist/plugins/users/types.d.ts +22 -0
  47. package/dist/plugins/users/types.d.ts.map +1 -1
  48. package/dist-ui/assets/index-5nX8fM1a.js +469 -0
  49. package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
  50. package/dist-ui/index.html +1 -1
  51. package/dist-ui-lib/api/controlPanelApi.d.ts +62 -0
  52. package/dist-ui-lib/components/index.d.ts +2 -1
  53. package/dist-ui-lib/index.js +2588 -2238
  54. package/dist-ui-lib/index.js.map +1 -1
  55. package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
  56. package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
  57. package/package.json +3 -2
  58. package/src/core/control-panel.ts +8 -8
  59. package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
  60. package/src/plugins/api-keys/index.ts +49 -0
  61. package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
  62. package/src/plugins/api-keys/middleware/index.ts +12 -0
  63. package/src/plugins/api-keys/stores/index.ts +7 -0
  64. package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
  65. package/src/plugins/api-keys/types.ts +243 -0
  66. package/src/plugins/auth/auth-plugin.ts +1 -1
  67. package/src/plugins/auth/env-config.ts +2 -2
  68. package/src/plugins/frontend-app-plugin.ts +7 -2
  69. package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
  70. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  71. package/src/plugins/users/stores/postgres-store.ts +69 -0
  72. package/src/plugins/users/types.ts +25 -0
  73. package/ui/src/App.tsx +6 -1
  74. package/ui/src/api/controlPanelApi.ts +157 -0
  75. package/ui/src/components/index.ts +6 -0
  76. package/ui/src/pages/APIKeysPage.tsx +661 -0
  77. package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
  78. package/ui/src/pages/UsersPage.tsx +225 -2
  79. package/dist-ui/assets/index-BfC7mG5L.js +0 -469
  80. package/dist-ui/assets/index-BfC7mG5L.js.map +0 -1
@@ -0,0 +1,243 @@
1
+ /**
2
+ * API Keys Plugin Types
3
+ *
4
+ * Type definitions for API key authentication and management.
5
+ * Supports PostgreSQL with Row-Level Security (RLS) for data isolation.
6
+ *
7
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
8
+ */
9
+
10
+ import { z } from 'zod';
11
+
12
+ /**
13
+ * API key scope type
14
+ */
15
+ export type ApiKeyScope = 'read' | 'write' | 'admin';
16
+
17
+ /**
18
+ * API key type (M2M = machine-to-machine, PAT = personal access token)
19
+ */
20
+ export type ApiKeyType = 'm2m' | 'pat';
21
+
22
+ /**
23
+ * API key record in the database
24
+ */
25
+ export interface ApiKey {
26
+ /** Primary key - UUID */
27
+ id: string;
28
+ /** User ID (foreign key to users table) */
29
+ user_id: string;
30
+ /** Human-readable name for the key */
31
+ name: string;
32
+ /** Hashed API key (SHA-256) */
33
+ key_hash: string;
34
+ /** Key prefix for identification (e.g., 'qk_live_') - stored in plaintext */
35
+ key_prefix: string;
36
+ /** Key type: m2m (machine-to-machine) or pat (personal access token) */
37
+ key_type: ApiKeyType;
38
+ /** Scopes granted to this key */
39
+ scopes: ApiKeyScope[];
40
+ /** Last time this key was used */
41
+ last_used_at: Date | null;
42
+ /** Expiration date (null = never expires) */
43
+ expires_at: Date | null;
44
+ /** Whether the key is active */
45
+ is_active: boolean;
46
+ /** When the key was created */
47
+ created_at: Date;
48
+ /** When the key was last updated */
49
+ updated_at: Date;
50
+ }
51
+
52
+ /**
53
+ * API key creation parameters
54
+ */
55
+ export interface CreateApiKeyParams {
56
+ /** User ID who owns this key */
57
+ user_id: string;
58
+ /** Human-readable name for the key */
59
+ name: string;
60
+ /** Key type: m2m or pat */
61
+ key_type: ApiKeyType;
62
+ /** Scopes to grant */
63
+ scopes: ApiKeyScope[];
64
+ /** Optional expiration date */
65
+ expires_at?: Date;
66
+ }
67
+
68
+ /**
69
+ * API key update parameters
70
+ */
71
+ export interface UpdateApiKeyParams {
72
+ /** New name (optional) */
73
+ name?: string;
74
+ /** New scopes (optional) */
75
+ scopes?: ApiKeyScope[];
76
+ /** New expiration date (optional) */
77
+ expires_at?: Date;
78
+ /** Activate/deactivate key (optional) */
79
+ is_active?: boolean;
80
+ }
81
+
82
+ /**
83
+ * API key with plaintext key (only returned on creation)
84
+ */
85
+ export interface ApiKeyWithPlaintext extends ApiKey {
86
+ /** Plaintext API key - only available on creation */
87
+ plaintext_key: string;
88
+ }
89
+
90
+ /**
91
+ * API key store interface - all storage backends must implement this
92
+ */
93
+ export interface ApiKeyStore {
94
+ /** Store name (e.g., 'postgres', 'memory') */
95
+ name: string;
96
+
97
+ /**
98
+ * Initialize the store (create tables, RLS policies, etc.)
99
+ */
100
+ initialize(): Promise<void>;
101
+
102
+ /**
103
+ * Create a new API key
104
+ * Returns the key with plaintext value (only time plaintext is accessible)
105
+ */
106
+ create(params: CreateApiKeyParams): Promise<ApiKeyWithPlaintext>;
107
+
108
+ /**
109
+ * Get all API keys for a user
110
+ */
111
+ list(userId: string): Promise<ApiKey[]>;
112
+
113
+ /**
114
+ * Get a specific API key by ID
115
+ * Returns null if key doesn't exist or doesn't belong to user
116
+ */
117
+ get(userId: string, keyId: string): Promise<ApiKey | null>;
118
+
119
+ /**
120
+ * Verify an API key and return the associated key record
121
+ * Returns null if key is invalid, expired, or inactive
122
+ */
123
+ verify(plaintextKey: string): Promise<ApiKey | null>;
124
+
125
+ /**
126
+ * Update an API key
127
+ * Returns the updated key or null if key doesn't exist
128
+ */
129
+ update(userId: string, keyId: string, params: UpdateApiKeyParams): Promise<ApiKey | null>;
130
+
131
+ /**
132
+ * Delete an API key
133
+ * Returns true if key was deleted, false if it didn't exist
134
+ */
135
+ delete(userId: string, keyId: string): Promise<boolean>;
136
+
137
+ /**
138
+ * Record key usage (updates last_used_at timestamp)
139
+ */
140
+ recordUsage(keyId: string): Promise<void>;
141
+
142
+ /**
143
+ * Shutdown the store
144
+ */
145
+ shutdown(): Promise<void>;
146
+ }
147
+
148
+ /**
149
+ * PostgreSQL API key store configuration
150
+ */
151
+ export interface PostgresApiKeyStoreConfig {
152
+ /** PostgreSQL pool instance or a function that returns one (for lazy initialization) */
153
+ pool: unknown | (() => unknown);
154
+ /** Table name (default: 'api_keys') */
155
+ tableName?: string;
156
+ /** Schema name (default: 'public') */
157
+ schema?: string;
158
+ /** Auto-create tables on init (default: true) */
159
+ autoCreateTables?: boolean;
160
+ /** Enable RLS (default: true) */
161
+ enableRLS?: boolean;
162
+ /** Key expiration in days (default: 90, null = never expires) */
163
+ defaultExpirationDays?: number | null;
164
+ /** Environment for key prefix (default: from NODE_ENV, 'test' in non-production, 'live' in production) */
165
+ environment?: 'test' | 'live';
166
+ }
167
+
168
+ /**
169
+ * API keys API configuration
170
+ */
171
+ export interface ApiKeysApiConfig {
172
+ /** API route prefix (default: '/api-keys') */
173
+ prefix?: string;
174
+ /** Enable API endpoints (default: true) */
175
+ enabled?: boolean;
176
+ }
177
+
178
+ /**
179
+ * API keys plugin configuration
180
+ */
181
+ export interface ApiKeysPluginConfig {
182
+ /** API key storage backend */
183
+ store: ApiKeyStore;
184
+ /** API configuration */
185
+ api?: ApiKeysApiConfig;
186
+ /** Enable debug logging */
187
+ debug?: boolean;
188
+ }
189
+
190
+ // ============================================================================
191
+ // Zod Validation Schemas
192
+ // ============================================================================
193
+
194
+ /**
195
+ * Zod schema for API key scope
196
+ */
197
+ export const ApiKeyScopeSchema = z.enum(['read', 'write', 'admin']);
198
+
199
+ /**
200
+ * Zod schema for API key type
201
+ */
202
+ export const ApiKeyTypeSchema = z.enum(['m2m', 'pat']);
203
+
204
+ /**
205
+ * Zod schema for creating an API key
206
+ */
207
+ export const CreateApiKeySchema = z.object({
208
+ name: z.string().min(1).max(255),
209
+ key_type: ApiKeyTypeSchema,
210
+ scopes: z.array(ApiKeyScopeSchema).min(1),
211
+ expires_at: z.coerce.date().optional(),
212
+ });
213
+
214
+ /**
215
+ * Zod schema for updating an API key
216
+ */
217
+ export const UpdateApiKeySchema = z.object({
218
+ name: z.string().min(1).max(255).optional(),
219
+ scopes: z.array(ApiKeyScopeSchema).min(1).optional(),
220
+ expires_at: z.coerce.date().optional(),
221
+ is_active: z.boolean().optional(),
222
+ }).refine(
223
+ (data) => Object.keys(data).length > 0,
224
+ { message: 'At least one field must be provided for update' }
225
+ );
226
+
227
+ /**
228
+ * Zod schema for API key record
229
+ */
230
+ export const ApiKeySchema = z.object({
231
+ id: z.string().uuid(),
232
+ user_id: z.string().uuid(),
233
+ name: z.string(),
234
+ key_hash: z.string(),
235
+ key_prefix: z.string(),
236
+ key_type: ApiKeyTypeSchema,
237
+ scopes: z.array(ApiKeyScopeSchema),
238
+ last_used_at: z.coerce.date().nullable(),
239
+ expires_at: z.coerce.date().nullable(),
240
+ is_active: z.boolean(),
241
+ created_at: z.coerce.date(),
242
+ updated_at: z.coerce.date(),
243
+ });
@@ -78,7 +78,7 @@ export function createAuthPlugin(config: AuthPluginConfig): Plugin {
78
78
  // Register auth status route
79
79
  registry.addRoute({
80
80
  method: 'get',
81
- path: '/api/auth/status',
81
+ path: '/auth/status',
82
82
  handler: (_req: Request, res: Response) => {
83
83
  const authReq = _req as AuthenticatedRequest;
84
84
  res.json({
@@ -323,8 +323,8 @@ type AdapterName = (typeof VALID_ADAPTERS)[number];
323
323
  export function createAuthPluginFromEnv(options?: AuthEnvPluginOptions): Plugin {
324
324
  const adapterName = getEnv('AUTH_ADAPTER')?.toLowerCase();
325
325
 
326
- // No adapter specified - return disabled plugin
327
- if (!adapterName) {
326
+ // No adapter specified OR explicitly disabled - return disabled plugin
327
+ if (!adapterName || adapterName === 'none') {
328
328
  currentStatus = {
329
329
  state: 'disabled',
330
330
  adapter: null,
@@ -88,8 +88,13 @@ export function createFrontendAppPlugin(config: FrontendAppPluginConfig): Plugin
88
88
  // SPA fallback for all non-API routes
89
89
  // This must be registered after static files but handles routes that don't match files
90
90
  app.get('*', (req, res, next) => {
91
- // Skip API routes and control panel
92
- if (req.path.startsWith('/api') || req.path.startsWith(config.mountPath || '/cpanel')) {
91
+ // Skip API routes, control panel, auth, and MCP endpoints
92
+ if (
93
+ req.path.startsWith('/api') ||
94
+ req.path.startsWith(config.mountPath || '/cpanel') ||
95
+ req.path.startsWith('/auth') ||
96
+ req.path.startsWith('/mcp')
97
+ ) {
93
98
  return next();
94
99
  }
95
100
 
@@ -19,6 +19,7 @@ const mockUser: User = {
19
19
  external_id: 'auth0|abc123',
20
20
  provider: 'auth0',
21
21
  picture: 'https://example.com/avatar.jpg',
22
+ status: 'active',
22
23
  metadata: {
23
24
  identifiers: {
24
25
  auth0_user_id: 'auth0|abc123',
@@ -44,6 +44,7 @@ describe('Users Plugin', () => {
44
44
  external_id: 'auth0|abc123',
45
45
  provider: 'auth0',
46
46
  picture: 'https://example.com/avatar.jpg',
47
+ status: 'active',
47
48
  created_at: new Date('2025-01-01'),
48
49
  updated_at: new Date('2025-01-01'),
49
50
  last_login_at: new Date('2025-12-13'),
@@ -64,6 +65,8 @@ describe('Users Plugin', () => {
64
65
  delete: vi.fn().mockResolvedValue(true),
65
66
  search: vi.fn().mockResolvedValue({ users: [mockUser], total: 1, page: 1, limit: 20, totalPages: 1 }),
66
67
  updateLastLogin: vi.fn().mockResolvedValue(undefined),
68
+ getByInvitationToken: vi.fn().mockResolvedValue(mockUser),
69
+ acceptInvitation: vi.fn().mockResolvedValue(mockUser),
67
70
  shutdown: vi.fn().mockResolvedValue(undefined),
68
71
  });
69
72
 
@@ -72,6 +72,9 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
72
72
  external_id VARCHAR(255),
73
73
  provider VARCHAR(50),
74
74
  picture TEXT,
75
+ status VARCHAR(20) DEFAULT 'active',
76
+ invitation_token VARCHAR(255),
77
+ invitation_expires_at TIMESTAMPTZ,
75
78
  metadata JSONB DEFAULT '{}',
76
79
  created_at TIMESTAMPTZ DEFAULT NOW(),
77
80
  updated_at TIMESTAMPTZ DEFAULT NOW(),
@@ -80,6 +83,41 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
80
83
 
81
84
  CREATE INDEX IF NOT EXISTS idx_${usersTable}_email ON ${usersTableFull}(email);
82
85
  CREATE INDEX IF NOT EXISTS idx_${usersTable}_external_id ON ${usersTableFull}(external_id, provider);
86
+ CREATE INDEX IF NOT EXISTS idx_${usersTable}_invitation_token ON ${usersTableFull}(invitation_token);
87
+ CREATE INDEX IF NOT EXISTS idx_${usersTable}_status ON ${usersTableFull}(status);
88
+ `);
89
+
90
+ // Add new columns to existing tables (migration)
91
+ await getPool().query(`
92
+ DO $$
93
+ BEGIN
94
+ IF NOT EXISTS (
95
+ SELECT 1 FROM information_schema.columns
96
+ WHERE table_schema = '${schema}'
97
+ AND table_name = '${usersTable}'
98
+ AND column_name = 'status'
99
+ ) THEN
100
+ ALTER TABLE ${usersTableFull} ADD COLUMN status VARCHAR(20) DEFAULT 'active';
101
+ END IF;
102
+
103
+ IF NOT EXISTS (
104
+ SELECT 1 FROM information_schema.columns
105
+ WHERE table_schema = '${schema}'
106
+ AND table_name = '${usersTable}'
107
+ AND column_name = 'invitation_token'
108
+ ) THEN
109
+ ALTER TABLE ${usersTableFull} ADD COLUMN invitation_token VARCHAR(255);
110
+ END IF;
111
+
112
+ IF NOT EXISTS (
113
+ SELECT 1 FROM information_schema.columns
114
+ WHERE table_schema = '${schema}'
115
+ AND table_name = '${usersTable}'
116
+ AND column_name = 'invitation_expires_at'
117
+ ) THEN
118
+ ALTER TABLE ${usersTableFull} ADD COLUMN invitation_expires_at TIMESTAMPTZ;
119
+ END IF;
120
+ END $$;
83
121
  `);
84
122
  },
85
123
 
@@ -263,6 +301,7 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
263
301
  const {
264
302
  query,
265
303
  provider,
304
+ status,
266
305
  page = 1,
267
306
  limit = 20,
268
307
  sortBy = 'created_at',
@@ -285,6 +324,12 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
285
324
  paramIndex++;
286
325
  }
287
326
 
327
+ if (status) {
328
+ conditions.push(`status = $${paramIndex}`);
329
+ values.push(status);
330
+ paramIndex++;
331
+ }
332
+
288
333
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
289
334
 
290
335
  // Validate sort column to prevent SQL injection
@@ -322,6 +367,30 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
322
367
  await getPool().query(`UPDATE ${usersTableFull} SET last_login_at = NOW() WHERE id = $1`, [id]);
323
368
  },
324
369
 
370
+ async getByInvitationToken(token: string): Promise<User | null> {
371
+ const result = await getPool().query(
372
+ `SELECT * FROM ${usersTableFull} WHERE invitation_token = $1 AND invitation_expires_at > NOW()`,
373
+ [token]
374
+ );
375
+ return (result.rows[0] as User) || null;
376
+ },
377
+
378
+ async acceptInvitation(token: string): Promise<User | null> {
379
+ const result = await getPool().query(
380
+ `UPDATE ${usersTableFull}
381
+ SET status = 'active',
382
+ invitation_token = NULL,
383
+ invitation_expires_at = NULL,
384
+ updated_at = NOW()
385
+ WHERE invitation_token = $1
386
+ AND invitation_expires_at > NOW()
387
+ AND status = 'invited'
388
+ RETURNING *`,
389
+ [token]
390
+ );
391
+ return (result.rows[0] as User) || null;
392
+ },
393
+
325
394
  async shutdown(): Promise<void> {
326
395
  // Pool is managed externally, nothing to do here
327
396
  },
@@ -9,6 +9,11 @@
9
9
  * Copyright (c) 2025 QwickApps.com. All rights reserved.
10
10
  */
11
11
 
12
+ /**
13
+ * User status in the system
14
+ */
15
+ export type UserStatus = 'invited' | 'active' | 'suspended';
16
+
12
17
  /**
13
18
  * User record in the database
14
19
  */
@@ -25,6 +30,12 @@ export interface User {
25
30
  provider?: string;
26
31
  /** Profile picture URL */
27
32
  picture?: string;
33
+ /** User status */
34
+ status: UserStatus;
35
+ /** Invitation token (set when user is invited) */
36
+ invitation_token?: string;
37
+ /** Invitation expiration timestamp */
38
+ invitation_expires_at?: Date;
28
39
  /** Additional metadata (JSON) */
29
40
  metadata?: Record<string, unknown>;
30
41
  /** When the user was created */
@@ -90,6 +101,8 @@ export interface UserSearchParams {
90
101
  query?: string;
91
102
  /** Filter by provider */
92
103
  provider?: string;
104
+ /** Filter by status */
105
+ status?: UserStatus;
93
106
  /** Page number (1-indexed) */
94
107
  page?: number;
95
108
  /** Items per page */
@@ -182,6 +195,18 @@ export interface UserStore {
182
195
  */
183
196
  updateLastLogin(id: string): Promise<void>;
184
197
 
198
+ /**
199
+ * Get a user by invitation token (only if invitation is valid and not expired)
200
+ */
201
+ getByInvitationToken(token: string): Promise<User | null>;
202
+
203
+ /**
204
+ * Accept an invitation by token.
205
+ * Sets status to 'active' and clears invitation token fields.
206
+ * Returns the updated user or null if token is invalid/expired.
207
+ */
208
+ acceptInvitation(token: string): Promise<User | null>;
209
+
185
210
  /**
186
211
  * Shutdown the store
187
212
  */
package/ui/src/App.tsx CHANGED
@@ -18,6 +18,7 @@ import { AuthPage } from './pages/AuthPage';
18
18
  import { RateLimitPage } from './pages/RateLimitPage';
19
19
  import { NotificationsPage } from './pages/NotificationsPage';
20
20
  import { IntegrationsPage } from './pages/IntegrationsPage';
21
+ import { APIKeysPage } from './pages/APIKeysPage';
21
22
  import { PluginPage } from './pages/PluginPage';
22
23
  import { NotFoundPage } from './pages/NotFoundPage';
23
24
  import { api, type MenuContribution } from './api/controlPanelApi';
@@ -41,10 +42,11 @@ const coreNavigationItems: NavigationItem[] = [
41
42
  // Built-in optional navigation items - shown if corresponding plugin is registered
42
43
  const builtInPluginNavItems: Record<string, NavigationItem> = {
43
44
  users: { id: 'users', label: 'Users', route: '/users', icon: 'people' },
45
+ 'api-keys': { id: 'api-keys', label: 'API Keys', route: '/api-keys', icon: 'key' },
44
46
  };
45
47
 
46
48
  // Routes that have dedicated page components
47
- const dedicatedRoutes = new Set(['/', '/plugins', '/logs', '/system', '/users', '/entitlements', '/auth', '/rate-limits', '/notifications', '/integrations']);
49
+ const dedicatedRoutes = new Set(['/', '/plugins', '/logs', '/system', '/users', '/entitlements', '/auth', '/rate-limits', '/notifications', '/integrations', '/api-keys']);
48
50
 
49
51
  // Package version - injected at build time or fallback
50
52
  const SERVER_VERSION = '1.0.0';
@@ -222,6 +224,9 @@ export function App() {
222
224
  {registeredPlugins.has('ai-proxy') && (
223
225
  <Route path="/integrations" element={<IntegrationsPage />} />
224
226
  )}
227
+ {registeredPlugins.has('api-keys') && (
228
+ <Route path="/api-keys" element={<APIKeysPage />} />
229
+ )}
225
230
 
226
231
  {/* Dynamic plugin routes - render generic PluginPage for non-dedicated routes */}
227
232
  {pluginMenuItems
@@ -81,10 +81,15 @@ export interface LogSource {
81
81
  // ==================
82
82
  // Users API Types
83
83
  // ==================
84
+ export type UserStatus = 'invited' | 'active' | 'suspended';
85
+
84
86
  export interface User {
85
87
  id: string;
86
88
  email: string;
87
89
  name?: string;
90
+ status: UserStatus;
91
+ invitation_token?: string;
92
+ invitation_expires_at?: string;
88
93
  created_at?: string;
89
94
  updated_at?: string;
90
95
  last_login?: string;
@@ -98,6 +103,30 @@ export interface UsersResponse {
98
103
  limit: number;
99
104
  }
100
105
 
106
+ export interface InviteUserRequest {
107
+ email: string;
108
+ name?: string;
109
+ role?: string;
110
+ metadata?: Record<string, unknown>;
111
+ expiresInDays?: number;
112
+ }
113
+
114
+ export interface InvitationResponse {
115
+ user: User;
116
+ token: string;
117
+ inviteLink: string;
118
+ expiresAt: string;
119
+ }
120
+
121
+ export interface AcceptInvitationRequest {
122
+ token: string;
123
+ }
124
+
125
+ export interface AcceptInvitationResponse {
126
+ success: boolean;
127
+ user: User;
128
+ }
129
+
101
130
  // ==================
102
131
  // Bans API Types
103
132
  // ==================
@@ -116,6 +145,44 @@ export interface BansResponse {
116
145
  total: number;
117
146
  }
118
147
 
148
+ // ==================
149
+ // API Keys Types
150
+ // ==================
151
+ export interface ApiKey {
152
+ id: string;
153
+ name: string;
154
+ key_prefix: string;
155
+ key_type: 'm2m' | 'pat';
156
+ scopes: Array<'read' | 'write' | 'admin'>;
157
+ last_used_at: string | null;
158
+ expires_at: string | null;
159
+ is_active: boolean;
160
+ created_at: string;
161
+ updated_at: string;
162
+ }
163
+
164
+ export interface ApiKeyWithPlaintext extends ApiKey {
165
+ key: string; // Only available on creation
166
+ }
167
+
168
+ export interface ApiKeysResponse {
169
+ keys: ApiKey[];
170
+ }
171
+
172
+ export interface CreateApiKeyRequest {
173
+ name: string;
174
+ key_type: 'm2m' | 'pat';
175
+ scopes: Array<'read' | 'write' | 'admin'>;
176
+ expires_at?: string;
177
+ }
178
+
179
+ export interface UpdateApiKeyRequest {
180
+ name?: string;
181
+ scopes?: Array<'read' | 'write' | 'admin'>;
182
+ expires_at?: string;
183
+ is_active?: boolean;
184
+ }
185
+
119
186
  // ==================
120
187
  // Entitlements API Types
121
188
  // ==================
@@ -544,6 +611,40 @@ class ControlPanelApi {
544
611
  return response.json();
545
612
  }
546
613
 
614
+ async inviteUser(request: InviteUserRequest): Promise<InvitationResponse> {
615
+ const response = await this._fetch(`${this.baseUrl}/api/users/invite`, {
616
+ method: 'POST',
617
+ headers: { 'Content-Type': 'application/json' },
618
+ body: JSON.stringify(request),
619
+ });
620
+ if (!response.ok) {
621
+ const error = await response.json().catch(() => ({}));
622
+ throw new Error(error.error || `Invite user failed: ${response.statusText}`);
623
+ }
624
+ return response.json();
625
+ }
626
+
627
+ async acceptInvitation(token: string): Promise<AcceptInvitationResponse> {
628
+ const response = await this._fetch(`${this.baseUrl}/api/users/accept-invitation/${encodeURIComponent(token)}`);
629
+ if (!response.ok) {
630
+ const error = await response.json().catch(() => ({}));
631
+ throw new Error(error.error || `Accept invitation failed: ${response.statusText}`);
632
+ }
633
+ return response.json();
634
+ }
635
+
636
+ async getInvitations(): Promise<UsersResponse> {
637
+ const params = new URLSearchParams();
638
+ params.set('status', 'invited');
639
+ params.set('limit', '100');
640
+
641
+ const response = await this._fetch(`${this.baseUrl}/api/users?${params}`);
642
+ if (!response.ok) {
643
+ throw new Error(`Invitations request failed: ${response.statusText}`);
644
+ }
645
+ return response.json();
646
+ }
647
+
547
648
  // ==================
548
649
  // Bans API
549
650
  // ==================
@@ -930,6 +1031,62 @@ class ControlPanelApi {
930
1031
  }
931
1032
  return response.json();
932
1033
  }
1034
+
1035
+ // ==================
1036
+ // API Keys API
1037
+ // ==================
1038
+
1039
+ async getApiKeys(): Promise<ApiKeysResponse> {
1040
+ const response = await this._fetch(`${this.baseUrl}/api/api-keys`);
1041
+ if (!response.ok) {
1042
+ throw new Error(`API keys request failed: ${response.statusText}`);
1043
+ }
1044
+ return response.json();
1045
+ }
1046
+
1047
+ async createApiKey(request: CreateApiKeyRequest): Promise<ApiKeyWithPlaintext> {
1048
+ const response = await this._fetch(`${this.baseUrl}/api/api-keys`, {
1049
+ method: 'POST',
1050
+ headers: { 'Content-Type': 'application/json' },
1051
+ body: JSON.stringify(request),
1052
+ });
1053
+ if (!response.ok) {
1054
+ const error = await response.json().catch(() => ({}));
1055
+ throw new Error(error.error || `API key creation failed: ${response.statusText}`);
1056
+ }
1057
+ return response.json();
1058
+ }
1059
+
1060
+ async getApiKey(keyId: string): Promise<ApiKey> {
1061
+ const response = await this._fetch(`${this.baseUrl}/api/api-keys/${encodeURIComponent(keyId)}`);
1062
+ if (!response.ok) {
1063
+ throw new Error(`API key request failed: ${response.statusText}`);
1064
+ }
1065
+ return response.json();
1066
+ }
1067
+
1068
+ async updateApiKey(keyId: string, updates: UpdateApiKeyRequest): Promise<ApiKey> {
1069
+ const response = await this._fetch(`${this.baseUrl}/api/api-keys/${encodeURIComponent(keyId)}`, {
1070
+ method: 'PUT',
1071
+ headers: { 'Content-Type': 'application/json' },
1072
+ body: JSON.stringify(updates),
1073
+ });
1074
+ if (!response.ok) {
1075
+ const error = await response.json().catch(() => ({}));
1076
+ throw new Error(error.error || `API key update failed: ${response.statusText}`);
1077
+ }
1078
+ return response.json();
1079
+ }
1080
+
1081
+ async deleteApiKey(keyId: string): Promise<void> {
1082
+ const response = await this._fetch(`${this.baseUrl}/api/api-keys/${encodeURIComponent(keyId)}`, {
1083
+ method: 'DELETE',
1084
+ });
1085
+ if (!response.ok) {
1086
+ const error = await response.json().catch(() => ({}));
1087
+ throw new Error(error.error || `API key deletion failed: ${response.statusText}`);
1088
+ }
1089
+ }
933
1090
  }
934
1091
 
935
1092
  export const api = new ControlPanelApi();