@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.
- package/CHANGELOG.md +22 -0
- package/dist/core/control-panel.js +8 -8
- package/dist/core/control-panel.js.map +1 -1
- package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
- package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
- package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
- package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
- package/dist/plugins/api-keys/index.d.ts +14 -0
- package/dist/plugins/api-keys/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/index.js +17 -0
- package/dist/plugins/api-keys/index.js.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
- package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
- package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/index.js +7 -0
- package/dist/plugins/api-keys/middleware/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/index.d.ts +7 -0
- package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/index.js +7 -0
- package/dist/plugins/api-keys/stores/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
- package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
- package/dist/plugins/api-keys/types.d.ts +268 -0
- package/dist/plugins/api-keys/types.d.ts.map +1 -0
- package/dist/plugins/api-keys/types.js +56 -0
- package/dist/plugins/api-keys/types.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.js +1 -1
- package/dist/plugins/auth/auth-plugin.js.map +1 -1
- package/dist/plugins/auth/env-config.js +2 -2
- package/dist/plugins/auth/env-config.js.map +1 -1
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +5 -2
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
- package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
- package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
- package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
- package/dist/plugins/users/stores/postgres-store.js +59 -1
- package/dist/plugins/users/stores/postgres-store.js.map +1 -1
- package/dist/plugins/users/types.d.ts +22 -0
- package/dist/plugins/users/types.d.ts.map +1 -1
- package/dist-ui/assets/index-5nX8fM1a.js +469 -0
- package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +62 -0
- package/dist-ui-lib/components/index.d.ts +2 -1
- package/dist-ui-lib/index.js +2588 -2238
- package/dist-ui-lib/index.js.map +1 -1
- package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
- package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
- package/package.json +3 -2
- package/src/core/control-panel.ts +8 -8
- package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
- package/src/plugins/api-keys/index.ts +49 -0
- package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
- package/src/plugins/api-keys/middleware/index.ts +12 -0
- package/src/plugins/api-keys/stores/index.ts +7 -0
- package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
- package/src/plugins/api-keys/types.ts +243 -0
- package/src/plugins/auth/auth-plugin.ts +1 -1
- package/src/plugins/auth/env-config.ts +2 -2
- package/src/plugins/frontend-app-plugin.ts +7 -2
- package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
- package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
- package/src/plugins/users/stores/postgres-store.ts +69 -0
- package/src/plugins/users/types.ts +25 -0
- package/ui/src/App.tsx +6 -1
- package/ui/src/api/controlPanelApi.ts +157 -0
- package/ui/src/components/index.ts +6 -0
- package/ui/src/pages/APIKeysPage.tsx +661 -0
- package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
- package/ui/src/pages/UsersPage.tsx +225 -2
- package/dist-ui/assets/index-BfC7mG5L.js +0 -469
- 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: '/
|
|
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
|
|
92
|
-
if (
|
|
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
|
|
|
@@ -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();
|