docpouch-client 0.9.1 → 1.0.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 +630 -449
- package/chat-09e440bd-c52c-44b9-bba4-01cebc84bd7b.txt +223 -0
- package/dist/index.d.ts +146 -5
- package/dist/index.js +308 -10
- package/{jest.config.js → jest.config.cjs} +1 -1
- package/package.json +41 -38
- package/src/index.ts +1003 -622
- package/tests/docPouchClient.test.ts +654 -0
- package/tests/mock-server.ts +613 -0
- package/tsconfig.build.json +7 -0
- package/tsconfig.json +9 -7
package/src/index.ts
CHANGED
|
@@ -1,623 +1,1004 @@
|
|
|
1
|
-
import {io, Socket} from "socket.io-client";
|
|
2
|
-
import packetJson from '../package.json'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Client for interacting with docPouch API.
|
|
6
|
-
*/
|
|
7
|
-
export default class docPouchClient {
|
|
8
|
-
/**
|
|
9
|
-
* The base URL of the server.
|
|
10
|
-
*
|
|
11
|
-
* @type {string}
|
|
1
|
+
import {io, Socket} from "socket.io-client";
|
|
2
|
+
import packetJson from '../package.json'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client for interacting with docPouch API.
|
|
6
|
+
*/
|
|
7
|
+
export default class docPouchClient {
|
|
8
|
+
/**
|
|
9
|
+
* The base URL of the server.
|
|
10
|
+
*
|
|
11
|
+
* @type {string}
|
|
12
|
+
*/
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
/**
|
|
15
|
+
* Socket.IO socket instance for real-time communication with the server.
|
|
16
|
+
*
|
|
17
|
+
* @type {Socket}
|
|
18
|
+
*/
|
|
19
|
+
socket: Socket;
|
|
20
|
+
/**
|
|
21
|
+
* Callback function to handle socket events.
|
|
22
|
+
*
|
|
23
|
+
* @type {(event: I_EventString, data: I_WsMessage) => void}
|
|
24
|
+
*/
|
|
25
|
+
callbackFunction;
|
|
26
|
+
/**
|
|
27
|
+
* Flag indicating whether real-time synchronization is enabled.
|
|
28
|
+
*
|
|
29
|
+
* @type {boolean}
|
|
30
|
+
*/
|
|
31
|
+
realTimeSync: boolean = false;
|
|
32
|
+
/**
|
|
33
|
+
* Authentication token used to authorize requests.
|
|
34
|
+
*
|
|
35
|
+
* @private
|
|
36
|
+
* @type {string | null}
|
|
37
|
+
*/
|
|
38
|
+
private authToken: string | null = null;
|
|
39
|
+
private oidcConfig: I_OidcConfig | null = null;
|
|
40
|
+
private oidcAccessToken: string | null = null;
|
|
41
|
+
private oidcRefreshToken: string | null = null;
|
|
42
|
+
private oidcIdToken: string | null = null;
|
|
43
|
+
private oidcTokenExpiry: number = 0;
|
|
44
|
+
private codeVerifier: string | null = null;
|
|
45
|
+
private oidcState: string | null = null;
|
|
46
|
+
private authMethod: 'jwt' | 'oidc' | 'none' = 'none';
|
|
47
|
+
/**
|
|
48
|
+
* Flag indicating whether a connection attempt is in progress.
|
|
49
|
+
*
|
|
50
|
+
* @private
|
|
51
|
+
* @type {boolean}
|
|
52
|
+
*/
|
|
53
|
+
private connectionInProgress = false;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates an instance of docPouchClient.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} host - The base URL for the server.
|
|
59
|
+
* @param {number} [port=80] - The port number to connect to (default is 80).
|
|
60
|
+
* @param {(event: I_EventString, data: I_WsMessage) => void} [callback] - Optional callback function for socket events.
|
|
61
|
+
*/
|
|
62
|
+
constructor(host: string, port: number = 80, callback?: (event: I_EventString, data: I_WsMessage) => void) {
|
|
63
|
+
this.baseUrl = host;
|
|
64
|
+
const socketUrl = host.includes('://') ? host : `https://${host}`;
|
|
65
|
+
const socketUrlWithPort = socketUrl.includes(':') && !socketUrl.endsWith(':')
|
|
66
|
+
? socketUrl
|
|
67
|
+
: `${socketUrl}:${port}`;
|
|
68
|
+
|
|
69
|
+
console.log(`Initializing Socket.IO with URL: ${socketUrlWithPort}, path: /socket.io`);
|
|
70
|
+
|
|
71
|
+
this.socket = io(`${socketUrlWithPort}`, {
|
|
72
|
+
autoConnect: false,
|
|
73
|
+
transports: ['websocket'], // Try websocket only first
|
|
74
|
+
reconnection: true,
|
|
75
|
+
reconnectionAttempts: 5,
|
|
76
|
+
reconnectionDelay: 1000,
|
|
77
|
+
forceNew: true, // Force a new connection
|
|
78
|
+
auth: {
|
|
79
|
+
token: null // Will be set later
|
|
80
|
+
},
|
|
81
|
+
path: '/socket.io'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.callbackFunction = callback;
|
|
85
|
+
|
|
86
|
+
this.setupPermanentSocketListeners();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Sets the real-time synchronization status.
|
|
91
|
+
*
|
|
92
|
+
* @param {boolean} newRealTimeSync - The new real-time sync setting (true/false).
|
|
93
|
+
*/
|
|
94
|
+
setRealTimeSync(newRealTimeSync: boolean) {
|
|
95
|
+
console.log(`Setting realtime sync to: ${newRealTimeSync}. Current setting: ${this.realTimeSync}`);
|
|
96
|
+
|
|
97
|
+
// Skip if the setting isn't changing
|
|
98
|
+
if (newRealTimeSync === this.realTimeSync) {
|
|
99
|
+
console.log("Realtime sync setting unchanged, skipping");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.realTimeSync = newRealTimeSync;
|
|
104
|
+
|
|
105
|
+
if (newRealTimeSync && (this.authToken || this.oidcAccessToken)) {
|
|
106
|
+
console.log("Activating realtime updates");
|
|
107
|
+
|
|
108
|
+
// Ensure we're not in the middle of another connection attempt
|
|
109
|
+
if (this.connectionInProgress) {
|
|
110
|
+
console.log("Connection already in progress, waiting before initializing");
|
|
111
|
+
setTimeout(() => this.initWebSocket(), 500);
|
|
112
|
+
} else {
|
|
113
|
+
this.initWebSocket();
|
|
114
|
+
}
|
|
115
|
+
} else if (!newRealTimeSync) {
|
|
116
|
+
console.log("Deactivating realtime updates");
|
|
117
|
+
if (this.socket.connected) {
|
|
118
|
+
console.log("Disconnecting socket");
|
|
119
|
+
this.socket.disconnect();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// User Administration Endpoints
|
|
125
|
+
/**
|
|
126
|
+
* Authenticates a user and stores the returned token for subsequent requests.
|
|
127
|
+
*
|
|
128
|
+
* @param {I_UserLogin} credentials - Username and password credentials.
|
|
129
|
+
* @returns {Promise<I_LoginResponse | null>} Login payload when successful, otherwise null.
|
|
130
|
+
*/
|
|
131
|
+
async login(credentials: I_UserLogin): Promise<I_LoginResponse | null> {
|
|
132
|
+
const response = await this.request<I_LoginResponse>('/users/login', 'POST', credentials, false);
|
|
133
|
+
if (response.token) {
|
|
134
|
+
this.authToken = response.token;
|
|
135
|
+
this.authMethod = 'jwt';
|
|
136
|
+
|
|
137
|
+
// Reconnect websocket with new token if realtime sync is enabled
|
|
138
|
+
if (this.realTimeSync) {
|
|
139
|
+
this.initWebSocket();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
_id: response._id,
|
|
144
|
+
token: response.token,
|
|
145
|
+
isAdmin: response.isAdmin,
|
|
146
|
+
userName: response.userName,
|
|
147
|
+
expiresIn: response.expiresIn
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Retrieves all users visible to the authenticated user.
|
|
155
|
+
*
|
|
156
|
+
* @returns {Promise<I_UserEntry[]>} A list of user entries.
|
|
157
|
+
*/
|
|
158
|
+
async listUsers(): Promise<I_UserEntry[]> {
|
|
159
|
+
return await this.request<I_UserEntry[]>('/users/list', 'GET');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Updates a user by ID.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} userID - The ID of the user to update.
|
|
166
|
+
* @param {I_UserUpdate} userData - Partial user fields to update.
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
async updateUser(userID: string, userData: I_UserUpdate): Promise<void> {
|
|
170
|
+
await this.request<void>(`/users/update/${userID}`, 'PATCH', userData);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Creates a new user.
|
|
175
|
+
*
|
|
176
|
+
* @param {I_UserCreation} userData - Data used to create the user.
|
|
177
|
+
* @returns {Promise<I_UserDisplay>} The created user payload returned by the API.
|
|
178
|
+
*/
|
|
179
|
+
async createUser(userData: I_UserCreation): Promise<I_UserDisplay> {
|
|
180
|
+
return await this.request<I_UserDisplay>('/users/create', 'POST', userData);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Removes a user by ID.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} userID - The ID of the user to remove.
|
|
187
|
+
* @returns {Promise<void>}
|
|
188
|
+
*/
|
|
189
|
+
async removeUser(userID: string): Promise<void> {
|
|
190
|
+
await this.request<void>(`/users/remove/${userID}`, 'DELETE');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Document Management Endpoints
|
|
194
|
+
/**
|
|
195
|
+
* Creates a new document.
|
|
196
|
+
*
|
|
197
|
+
* @param {I_DocumentEntry} document - The document payload to create.
|
|
198
|
+
* @returns {Promise<I_DocumentEntry>} The created document.
|
|
199
|
+
*/
|
|
200
|
+
async createDocument(document: I_DocumentEntry): Promise<I_DocumentEntry> {
|
|
201
|
+
return await this.request<I_DocumentEntry>('/docs/create', 'POST', document);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Retrieves all documents visible to the authenticated user.
|
|
206
|
+
*
|
|
207
|
+
* @returns {Promise<I_DocumentEntry[]>} A list of document entries.
|
|
208
|
+
*/
|
|
209
|
+
async listDocuments(): Promise<I_DocumentEntry[]> {
|
|
210
|
+
return await this.request<I_DocumentEntry[]>('/docs/list', 'GET');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetches documents matching a query object.
|
|
215
|
+
*
|
|
216
|
+
* @param {I_DocumentQuery} queryObject - Query fields used for filtering.
|
|
217
|
+
* @returns {Promise<I_DocumentEntry[]>} Matching documents.
|
|
218
|
+
*/
|
|
219
|
+
async fetchDocuments(queryObject: I_DocumentQuery): Promise<I_DocumentEntry[]> {
|
|
220
|
+
return await this.request<I_DocumentEntry[]>(`/docs/fetch/`, 'POST', queryObject);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Updates a document by ID.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} documentID - The ID of the document to update.
|
|
227
|
+
* @param {I_DocumentEntry} documentData - Updated document payload.
|
|
228
|
+
* @returns {Promise<void>}
|
|
229
|
+
*/
|
|
230
|
+
async updateDocument(documentID: string, documentData: I_DocumentEntry): Promise<void> {
|
|
231
|
+
await this.request<void>(`/docs/update/${documentID}`, 'PATCH', documentData);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Removes a document by ID.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} documentID - The ID of the document to remove.
|
|
238
|
+
* @returns {Promise<void>}
|
|
239
|
+
*/
|
|
240
|
+
async removeDocument(documentID: string): Promise<void> {
|
|
241
|
+
await this.request<void>(`/docs/remove/${documentID}`, 'DELETE');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Data Structure Endpoints
|
|
245
|
+
/**
|
|
246
|
+
* Creates a new data structure.
|
|
247
|
+
*
|
|
248
|
+
* @param {I_StructureCreation} structure - Data structure payload to create.
|
|
249
|
+
* @returns {Promise<I_DataStructure>} The created data structure.
|
|
250
|
+
*/
|
|
251
|
+
async createStructure(structure: I_StructureCreation): Promise<I_DataStructure> {
|
|
252
|
+
return await this.request<I_DataStructure>('/structures/create', 'POST', structure);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Retrieves all data structures.
|
|
257
|
+
*
|
|
258
|
+
* @returns {Promise<I_DataStructure[]>} A list of data structures.
|
|
259
|
+
*/
|
|
260
|
+
async getStructures(): Promise<I_DataStructure[]> {
|
|
261
|
+
return await this.request<I_DataStructure[]>('/structures/list', 'GET');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Updates a data structure by ID.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} structureID - The ID of the structure to update.
|
|
268
|
+
* @param {I_DataStructure} structureData - Updated structure payload.
|
|
269
|
+
* @returns {Promise<void>}
|
|
270
|
+
*/
|
|
271
|
+
async updateStructure(structureID: string, structureData: I_DataStructure): Promise<void> {
|
|
272
|
+
await this.request<void>(`/structures/update/${structureID}`, 'PATCH', structureData);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Removes a data structure by ID.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} structureID - The ID of the structure to remove.
|
|
279
|
+
* @returns {Promise<void>}
|
|
280
|
+
*/
|
|
281
|
+
async removeStructure(structureID: string): Promise<void> {
|
|
282
|
+
await this.request<void>(`/structures/remove/${structureID}`, 'DELETE');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Sets or clears the authentication token used for API and WebSocket auth.
|
|
287
|
+
*
|
|
288
|
+
* @param {string | null} token - Bearer token to use, or null to clear it.
|
|
289
|
+
*/
|
|
290
|
+
setToken(token: string | null): void {
|
|
291
|
+
console.log("Setting token to:", token ? "***token***" : "null");
|
|
292
|
+
|
|
293
|
+
this.authMethod = token ? 'jwt' : 'none';
|
|
294
|
+
const tokenChanged = this.authToken !== token;
|
|
295
|
+
this.authToken = token;
|
|
296
|
+
if (!token) this.clearOidcTokens();
|
|
297
|
+
|
|
298
|
+
if (!tokenChanged) {
|
|
299
|
+
console.log("Token unchanged, no need to reconnect");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// If we have a new token and realtime sync is enabled
|
|
304
|
+
if (token && this.realTimeSync) {
|
|
305
|
+
console.log("New token set, will initialize WebSocket");
|
|
306
|
+
|
|
307
|
+
// Ensure any existing connection is closed first
|
|
308
|
+
if (this.socket.connected) {
|
|
309
|
+
console.log("Disconnecting existing socket before reconnecting with new token");
|
|
310
|
+
this.socket.disconnect();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Wait a moment for the disconnect to complete
|
|
314
|
+
setTimeout(() => {
|
|
315
|
+
console.log("Initializing WebSocket with new token");
|
|
316
|
+
this.initWebSocket();
|
|
317
|
+
}, 300);
|
|
318
|
+
}
|
|
319
|
+
// If token was cleared or realtime sync is disabled
|
|
320
|
+
else if (this.socket.connected) {
|
|
321
|
+
console.log("Token cleared or realtime sync disabled, disconnecting");
|
|
322
|
+
this.socket.disconnect();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Returns the package version of this client.
|
|
328
|
+
*
|
|
329
|
+
* @returns {string} The semantic version string.
|
|
330
|
+
*/
|
|
331
|
+
getVersion() {
|
|
332
|
+
return packetJson.version;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Logs socket diagnostics and attempts a reconnect when possible.
|
|
337
|
+
*
|
|
338
|
+
* @returns {void}
|
|
339
|
+
*/
|
|
340
|
+
debugSocketConnection(): void {
|
|
341
|
+
console.log("Socket connection debug info:");
|
|
342
|
+
console.log("- Connected:", this.socket.connected);
|
|
343
|
+
console.log("- Socket ID:", this.socket.id);
|
|
344
|
+
console.log("- Auth token present:", !!this.authToken, "(method:", this.authMethod + ")");
|
|
345
|
+
console.log("- OIDC access token present:", !!this.oidcAccessToken);
|
|
346
|
+
console.log("- Connection in progress:", this.connectionInProgress);
|
|
347
|
+
console.log("- Realtime sync enabled:", this.realTimeSync);
|
|
348
|
+
console.log("- Socket options:", this.socket.io.opts);
|
|
349
|
+
|
|
350
|
+
const activeToken = this.authMethod === 'oidc' ? this.oidcAccessToken : this.authToken;
|
|
351
|
+
// Try to force reconnection
|
|
352
|
+
if (!this.socket.connected && activeToken && this.realTimeSync) {
|
|
353
|
+
console.log("Attempting to force reconnection...");
|
|
354
|
+
this.socket.auth = {token: activeToken};
|
|
355
|
+
this.socket.connect();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// OIDC Authentication Methods
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Initiates the OIDC authentication flow by redirecting to the authorization endpoint.
|
|
363
|
+
*
|
|
364
|
+
* @param {I_OidcConfig} config - OIDC provider configuration.
|
|
365
|
+
* @returns {Promise<void>}
|
|
366
|
+
*/
|
|
367
|
+
async loginWithOidc(config: I_OidcConfig): Promise<void> {
|
|
368
|
+
this.oidcConfig = config;
|
|
369
|
+
const response = await fetch(`${config.issuer}/.well-known/openid-configuration`);
|
|
370
|
+
const discovery = await response.json();
|
|
371
|
+
|
|
372
|
+
this.codeVerifier = this.generateCodeVerifier();
|
|
373
|
+
this.oidcState = this.generateState();
|
|
374
|
+
const codeChallenge = await this.generateCodeChallenge(this.codeVerifier!);
|
|
375
|
+
|
|
376
|
+
const params = new URLSearchParams({
|
|
377
|
+
response_type: 'code',
|
|
378
|
+
client_id: config.clientId,
|
|
379
|
+
redirect_uri: config.redirectUri,
|
|
380
|
+
scope: config.scopes?.join(' ') || 'openid profile',
|
|
381
|
+
state: this.oidcState!,
|
|
382
|
+
code_challenge: codeChallenge,
|
|
383
|
+
code_challenge_method: 'S256'
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
window.location.href = `${discovery.authorization_endpoint}?${params.toString()}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Handles the OIDC callback by exchanging the authorization code for tokens.
|
|
391
|
+
*
|
|
392
|
+
* @returns {Promise<boolean>} True if the callback was handled successfully.
|
|
393
|
+
*/
|
|
394
|
+
async handleOidcCallback(): Promise<boolean> {
|
|
395
|
+
const params = new URLSearchParams(window.location.search);
|
|
396
|
+
const code = params.get('code');
|
|
397
|
+
const state = params.get('state');
|
|
398
|
+
const error = params.get('error');
|
|
399
|
+
|
|
400
|
+
if (error) throw new Error(`OAuth error: ${error}`);
|
|
401
|
+
if (!code || !state) return false;
|
|
402
|
+
if (state !== this.oidcState) throw new Error('State mismatch');
|
|
403
|
+
|
|
404
|
+
const discovery = await this.discoverOidc();
|
|
405
|
+
const body = new URLSearchParams({
|
|
406
|
+
grant_type: 'authorization_code',
|
|
407
|
+
code,
|
|
408
|
+
redirect_uri: this.oidcConfig!.redirectUri,
|
|
409
|
+
client_id: this.oidcConfig!.clientId,
|
|
410
|
+
code_verifier: this.codeVerifier || ''
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
|
416
|
+
body: body.toString()
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
if (!response.ok) throw new Error('Token exchange failed');
|
|
420
|
+
const raw = await response.json();
|
|
421
|
+
const tokens: I_OidcTokenResponse = {
|
|
422
|
+
accessToken: raw.access_token,
|
|
423
|
+
refreshToken: raw.refresh_token,
|
|
424
|
+
idToken: raw.id_token,
|
|
425
|
+
expiresIn: raw.expires_in,
|
|
426
|
+
tokenType: raw.token_type,
|
|
427
|
+
scope: raw.scope
|
|
428
|
+
};
|
|
429
|
+
this.setOidcTokens(tokens);
|
|
430
|
+
|
|
431
|
+
if (this.realTimeSync) this.initWebSocket();
|
|
432
|
+
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
restoreOidcSession(): boolean {
|
|
437
|
+
if (typeof window === 'undefined' || !window.localStorage) return false;
|
|
438
|
+
const stored = localStorage.getItem('docpouch_oidc_session');
|
|
439
|
+
if (!stored) return false;
|
|
440
|
+
try {
|
|
441
|
+
const session = JSON.parse(stored);
|
|
442
|
+
if (!session.accessToken || Date.now() >= session.expiry) {
|
|
443
|
+
this.clearPersistedOidcSession();
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
this.authMethod = 'oidc';
|
|
447
|
+
this.oidcAccessToken = session.accessToken;
|
|
448
|
+
this.oidcRefreshToken = session.refreshToken || null;
|
|
449
|
+
this.oidcIdToken = session.idToken || null;
|
|
450
|
+
this.oidcTokenExpiry = session.expiry;
|
|
451
|
+
return true;
|
|
452
|
+
} catch {
|
|
453
|
+
this.clearPersistedOidcSession();
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Ensures the OIDC access token is valid, refreshing it if necessary.
|
|
12
460
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* @
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
this.socket
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* @
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
*
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
this.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
export interface
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
description?: string;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
export interface I_DocumentQuery {
|
|
561
|
-
_id?: string;
|
|
562
|
-
owner?: string;
|
|
563
|
-
title?: string;
|
|
564
|
-
type?: number;
|
|
565
|
-
subType?: number;
|
|
566
|
-
shareWithGroup?: boolean;
|
|
567
|
-
shareWithDepartment?: boolean;
|
|
568
|
-
public?: boolean;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
// Structure related types
|
|
573
|
-
export interface I_DataStructure {
|
|
574
|
-
_id?: string | undefined;
|
|
575
|
-
name: string;
|
|
576
|
-
description: string;
|
|
577
|
-
type: number
|
|
578
|
-
subType: number
|
|
579
|
-
fields: I_StructureField[];
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
export interface I_StructureField {
|
|
583
|
-
name: string;
|
|
584
|
-
displayName: string; //should not contain spaces or non-ASCII characters
|
|
585
|
-
type: string;
|
|
586
|
-
items?: string;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
export interface I_StructureCreation {
|
|
590
|
-
name: string;
|
|
591
|
-
description?: string;
|
|
592
|
-
type: number
|
|
593
|
-
subType: number
|
|
594
|
-
fields: I_StructureField[];
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
export interface I_StructureUpdate {
|
|
598
|
-
_id?: string
|
|
599
|
-
name?: string;
|
|
600
|
-
description?: string;
|
|
601
|
-
type?: number
|
|
602
|
-
subType?: number
|
|
603
|
-
fields?: I_StructureField[];
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// WebSocket-related types
|
|
607
|
-
export type I_EventString = 'heartbeatPong' | "heartbeatPing" | "newDocument" | "newStructure" |
|
|
608
|
-
"newUser" | "newType" | "removedID" | "changedDocument" | "changedStructure" | "changedUser" | "changedType" |
|
|
609
|
-
"removedUser" | "removedStructure" | "removedDocument" | "removedType";
|
|
610
|
-
|
|
611
|
-
export interface I_WsMessage {
|
|
612
|
-
newDocument?: I_DocumentEntry;
|
|
613
|
-
newStructure?: I_DataStructure;
|
|
614
|
-
newUser?: I_UserEntry;
|
|
615
|
-
removedID?: string;
|
|
616
|
-
changedDocument?: I_DocumentUpdate;
|
|
617
|
-
changedStructure?: I_StructureUpdate;
|
|
618
|
-
changedUser?: I_UserUpdate;
|
|
619
|
-
confirmSubscription?: boolean;
|
|
620
|
-
confirmUnsubscription?: boolean;
|
|
621
|
-
heartbeatPing?: number;
|
|
622
|
-
heartbeatPong?: number;
|
|
623
|
-
}
|
|
461
|
+
async ensureValidOidcToken(): Promise<string> {
|
|
462
|
+
if (this.authMethod !== 'oidc' || !this.oidcAccessToken)
|
|
463
|
+
throw new Error('Not authenticated via OIDC');
|
|
464
|
+
|
|
465
|
+
if (Date.now() >= this.oidcTokenExpiry - 60000) {
|
|
466
|
+
await this.refreshOidcToken();
|
|
467
|
+
}
|
|
468
|
+
return this.oidcAccessToken!;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Returns the current authentication method.
|
|
473
|
+
*
|
|
474
|
+
* @returns {'jwt' | 'oidc' | 'none'} The active auth method.
|
|
475
|
+
*/
|
|
476
|
+
getAuthMethod(): 'jwt' | 'oidc' | 'none' {
|
|
477
|
+
return this.authMethod;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Checks whether the client is currently authenticated.
|
|
482
|
+
*
|
|
483
|
+
* @returns {boolean} True if authenticated.
|
|
484
|
+
*/
|
|
485
|
+
isAuthenticated(): boolean {
|
|
486
|
+
return this.authMethod !== 'none' && (
|
|
487
|
+
this.authMethod === 'jwt'
|
|
488
|
+
? !!this.authToken
|
|
489
|
+
: (this.oidcAccessToken !== null && Date.now() < this.oidcTokenExpiry)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Returns the current active token, regardless of auth method.
|
|
495
|
+
*
|
|
496
|
+
* @returns {string | null} The active token or null.
|
|
497
|
+
*/
|
|
498
|
+
getToken(): string | null {
|
|
499
|
+
return this.authMethod === 'oidc' ? this.oidcAccessToken : this.authToken;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Logs out the client, clearing all tokens and disconnecting WebSocket.
|
|
504
|
+
*
|
|
505
|
+
* @returns {Promise<void>}
|
|
506
|
+
*/
|
|
507
|
+
async logout(): Promise<void> {
|
|
508
|
+
this.authToken = null;
|
|
509
|
+
this.clearOidcTokens();
|
|
510
|
+
this.authMethod = 'none';
|
|
511
|
+
if (this.socket.connected) this.socket.disconnect();
|
|
512
|
+
if (typeof window !== 'undefined' && window.location &&
|
|
513
|
+
(window.location.search.includes('code=') || window.location.search.includes('state='))) {
|
|
514
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// OIDC Dynamic Client Registration Methods
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Registers a new OIDC client with the server (dynamic client registration).
|
|
522
|
+
*
|
|
523
|
+
* @param {I_OidcClientRegistration} registration - Client metadata for registration.
|
|
524
|
+
* @param {string} [registrationToken] - Initial registration access token. If not provided, the current auth token is used.
|
|
525
|
+
* @returns {Promise<I_OidcClientResponse>} The registered client details.
|
|
526
|
+
*/
|
|
527
|
+
async registerOidcClient(registration: I_OidcClientRegistration, registrationToken?: string): Promise<I_OidcClientResponse> {
|
|
528
|
+
return await this.request<I_OidcClientResponse>('/oidc/reg', 'POST', registration, true, registrationToken);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Retrieves the registration state of an existing OIDC client.
|
|
533
|
+
*
|
|
534
|
+
* @param {string} clientId - The client identifier.
|
|
535
|
+
* @param {string} [registrationToken] - The registration access token. If not provided, the current auth token is used.
|
|
536
|
+
* @returns {Promise<I_OidcClientResponse>} The client details.
|
|
537
|
+
*/
|
|
538
|
+
async getOidcClient(clientId: string, registrationToken?: string): Promise<I_OidcClientResponse> {
|
|
539
|
+
return await this.request<I_OidcClientResponse>(`/oidc/reg/${clientId}`, 'GET', undefined, true, registrationToken);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Updates the registration of an existing OIDC client.
|
|
544
|
+
*
|
|
545
|
+
* @param {string} clientId - The client identifier.
|
|
546
|
+
* @param {I_OidcClientRegistration} registration - Updated client metadata.
|
|
547
|
+
* @param {string} [registrationToken] - The registration access token. If not provided, the current auth token is used.
|
|
548
|
+
* @returns {Promise<I_OidcClientResponse>} The updated client details.
|
|
549
|
+
*/
|
|
550
|
+
async updateOidcClient(clientId: string, registration: I_OidcClientRegistration, registrationToken?: string): Promise<I_OidcClientResponse> {
|
|
551
|
+
return await this.request<I_OidcClientResponse>(`/oidc/reg/${clientId}`, 'PUT', registration, true, registrationToken);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Deletes an OIDC client registration.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} clientId - The client identifier.
|
|
558
|
+
* @param {string} [registrationToken] - The registration access token. If not provided, the current auth token is used.
|
|
559
|
+
* @returns {Promise<void>}
|
|
560
|
+
*/
|
|
561
|
+
async deleteOidcClient(clientId: string, registrationToken?: string): Promise<void> {
|
|
562
|
+
await this.request<void>(`/oidc/reg/${clientId}`, 'DELETE', undefined, true, registrationToken);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Sets up permanent socket listeners for the client.
|
|
567
|
+
*
|
|
568
|
+
* @private
|
|
569
|
+
*/
|
|
570
|
+
private setupPermanentSocketListeners() {
|
|
571
|
+
// These are permanent listeners that won't be removed
|
|
572
|
+
this.socket.on('connect_error', (error) => {
|
|
573
|
+
console.error('Socket connection error:', error.message);
|
|
574
|
+
this.connectionInProgress = false;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
this.socket.on('connect', () => {
|
|
578
|
+
console.log('Socket connected successfully with ID:', this.socket.id);
|
|
579
|
+
this.connectionInProgress = false;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
this.socket.on('disconnect', (reason) => {
|
|
583
|
+
console.log('Socket disconnected. Reason:', reason);
|
|
584
|
+
this.connectionInProgress = false;
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
this.socket.on('error', (error) => {
|
|
588
|
+
console.error('Socket error:', error);
|
|
589
|
+
this.connectionInProgress = false;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Initializes the WebSocket connection with the server.
|
|
595
|
+
*
|
|
596
|
+
* @private
|
|
597
|
+
*/
|
|
598
|
+
private initWebSocket() {
|
|
599
|
+
const token = this.authMethod === 'oidc' ? this.oidcAccessToken : this.authToken;
|
|
600
|
+
console.log("initWebSocket called. Auth token present:", !!this.authToken,
|
|
601
|
+
"Connection in progress:", this.connectionInProgress,
|
|
602
|
+
"Socket connected:", this.socket.connected,
|
|
603
|
+
"Auth method:", this.authMethod);
|
|
604
|
+
|
|
605
|
+
if (!token) {
|
|
606
|
+
console.log("Skipping WebSocket initialization: No auth token");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (this.connectionInProgress) {
|
|
611
|
+
console.log("Connection already in progress, skipping initialization");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (this.socket.connected) {
|
|
616
|
+
console.log("Socket already connected with ID:", this.socket.id);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
this.connectionInProgress = true;
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
console.log("Setting up WebSocket connection with token");
|
|
624
|
+
|
|
625
|
+
// Update the auth token
|
|
626
|
+
this.socket.auth = {token};
|
|
627
|
+
|
|
628
|
+
// Remove any dynamic event listeners that might have been added
|
|
629
|
+
this.socket.offAny();
|
|
630
|
+
|
|
631
|
+
// Set up event handler for application events
|
|
632
|
+
this.socket.onAny((event: I_EventString, data: I_WsMessage) => {
|
|
633
|
+
if (event === "heartbeatPing") {
|
|
634
|
+
console.log("Ping event received:", data);
|
|
635
|
+
this.socket.emit("heartbeatPong", Date.now());
|
|
636
|
+
} else if (this.callbackFunction) {
|
|
637
|
+
this.callbackFunction(event, data);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Connect to the server
|
|
642
|
+
console.log("Connecting socket with auth token");
|
|
643
|
+
this.socket.connect();
|
|
644
|
+
|
|
645
|
+
// Add a timeout to detect if connection is taking too long
|
|
646
|
+
setTimeout(() => {
|
|
647
|
+
if (this.connectionInProgress) {
|
|
648
|
+
console.warn("Socket connection attempt timed out after 5 seconds");
|
|
649
|
+
this.connectionInProgress = false;
|
|
650
|
+
|
|
651
|
+
// If we're still not connected after the timeout, try again with polling
|
|
652
|
+
if (!this.socket.connected) {
|
|
653
|
+
console.log("Retrying connection with polling transport");
|
|
654
|
+
this.socket.io.opts.transports = ['polling', 'websocket'];
|
|
655
|
+
this.socket.connect();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}, 5000);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
console.error('Error in initWebSocket:', error);
|
|
661
|
+
this.connectionInProgress = false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Sends an HTTP request to the configured docPouch backend.
|
|
667
|
+
*
|
|
668
|
+
* @template T
|
|
669
|
+
* @param {string} endpoint - Relative API endpoint (with or without leading slash).
|
|
670
|
+
* @param {string} method - HTTP method.
|
|
671
|
+
* @param {any} [body] - Optional JSON body.
|
|
672
|
+
* @param {boolean} [requiresAuth=true] - Whether the Authorization header should be attached.
|
|
673
|
+
* @returns {Promise<T>} Parsed JSON response body.
|
|
674
|
+
* @private
|
|
675
|
+
*/
|
|
676
|
+
private async request<T>(endpoint: string, method: string, body?: any, requiresAuth: boolean = true, authTokenOverride?: string): Promise<T> {
|
|
677
|
+
const headers: HeadersInit = {
|
|
678
|
+
'Content-Type': 'application/json',
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const authToken = authTokenOverride ?? (this.authMethod === 'oidc'
|
|
682
|
+
? await this.ensureValidOidcToken().catch(() => null)
|
|
683
|
+
: this.authToken);
|
|
684
|
+
|
|
685
|
+
if (requiresAuth && authToken)
|
|
686
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
687
|
+
if (this.socket.id)
|
|
688
|
+
headers['X-Socket-ID'] = this.socket.id;
|
|
689
|
+
|
|
690
|
+
const options: RequestInit = {
|
|
691
|
+
method,
|
|
692
|
+
headers,
|
|
693
|
+
body: body ? JSON.stringify(body) : undefined
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
697
|
+
const normalizedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
|
698
|
+
const url = `${normalizedBaseUrl}${normalizedEndpoint}`;
|
|
699
|
+
const response = await fetch(url, options);
|
|
700
|
+
|
|
701
|
+
if (!response.ok) {
|
|
702
|
+
if (response.status === 401 || response.status === 403) {
|
|
703
|
+
this.authToken = null;
|
|
704
|
+
}
|
|
705
|
+
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (response.status === 204) return undefined as T;
|
|
709
|
+
|
|
710
|
+
return await response.json() as T;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// OIDC Private Helpers
|
|
714
|
+
|
|
715
|
+
private async discoverOidc(): Promise<any> {
|
|
716
|
+
const response = await fetch(`${this.oidcConfig!.issuer}/.well-known/openid-configuration`);
|
|
717
|
+
return response.json();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private setOidcTokens(tokens: I_OidcTokenResponse): void {
|
|
721
|
+
this.authMethod = 'oidc';
|
|
722
|
+
this.oidcAccessToken = tokens.accessToken;
|
|
723
|
+
this.oidcRefreshToken = tokens.refreshToken || this.oidcRefreshToken;
|
|
724
|
+
this.oidcIdToken = tokens.idToken || null;
|
|
725
|
+
this.oidcTokenExpiry = Date.now() + (tokens.expiresIn * 1000);
|
|
726
|
+
this.persistOidcSession();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private clearOidcTokens(): void {
|
|
730
|
+
this.oidcAccessToken = null;
|
|
731
|
+
this.oidcRefreshToken = null;
|
|
732
|
+
this.oidcIdToken = null;
|
|
733
|
+
this.oidcTokenExpiry = 0;
|
|
734
|
+
this.codeVerifier = null;
|
|
735
|
+
this.oidcState = null;
|
|
736
|
+
if (this.authMethod === 'oidc') this.authMethod = 'none';
|
|
737
|
+
this.clearPersistedOidcSession();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private persistOidcSession(): void {
|
|
741
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
742
|
+
const session = {
|
|
743
|
+
accessToken: this.oidcAccessToken,
|
|
744
|
+
refreshToken: this.oidcRefreshToken,
|
|
745
|
+
idToken: this.oidcIdToken,
|
|
746
|
+
expiry: this.oidcTokenExpiry
|
|
747
|
+
};
|
|
748
|
+
localStorage.setItem('docpouch_oidc_session', JSON.stringify(session));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private clearPersistedOidcSession(): void {
|
|
753
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
754
|
+
localStorage.removeItem('docpouch_oidc_session');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private async refreshOidcToken(): Promise<void> {
|
|
759
|
+
if (!this.oidcRefreshToken) throw new Error('No refresh token available');
|
|
760
|
+
const discovery = await this.discoverOidc();
|
|
761
|
+
const body = new URLSearchParams({
|
|
762
|
+
grant_type: 'refresh_token',
|
|
763
|
+
refresh_token: this.oidcRefreshToken,
|
|
764
|
+
client_id: this.oidcConfig!.clientId
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
768
|
+
method: 'POST',
|
|
769
|
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
|
770
|
+
body: body.toString()
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
if (!response.ok) {
|
|
774
|
+
this.clearOidcTokens();
|
|
775
|
+
throw new Error('Token refresh failed');
|
|
776
|
+
}
|
|
777
|
+
const raw = await response.json();
|
|
778
|
+
const tokens: I_OidcTokenResponse = {
|
|
779
|
+
accessToken: raw.access_token,
|
|
780
|
+
refreshToken: raw.refresh_token,
|
|
781
|
+
idToken: raw.id_token,
|
|
782
|
+
expiresIn: raw.expires_in,
|
|
783
|
+
tokenType: raw.token_type,
|
|
784
|
+
scope: raw.scope
|
|
785
|
+
};
|
|
786
|
+
this.setOidcTokens(tokens);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private generateCodeVerifier(): string {
|
|
790
|
+
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
791
|
+
const array = new Uint8Array(64);
|
|
792
|
+
crypto.getRandomValues(array);
|
|
793
|
+
return Array.from(array, byte => charset[byte % charset.length]).join('');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private async generateCodeChallenge(verifier: string): Promise<string> {
|
|
797
|
+
const encoder = new TextEncoder();
|
|
798
|
+
const data = encoder.encode(verifier);
|
|
799
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
800
|
+
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
801
|
+
.replace(/\+/g, '-')
|
|
802
|
+
.replace(/\//g, '_')
|
|
803
|
+
.replace(/=+$/, '');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private generateState(): string {
|
|
807
|
+
const array = new Uint8Array(32);
|
|
808
|
+
crypto.getRandomValues(array);
|
|
809
|
+
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Common type definitions for both frontend and backend
|
|
814
|
+
|
|
815
|
+
// User related types
|
|
816
|
+
export interface I_UserEntry extends I_UserCreation {
|
|
817
|
+
_id: string;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export interface I_UserLogin {
|
|
821
|
+
name: string;
|
|
822
|
+
password: string;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export interface I_UserCreation {
|
|
826
|
+
name: string;
|
|
827
|
+
password: string;
|
|
828
|
+
email?: string;
|
|
829
|
+
department: string;
|
|
830
|
+
group: string;
|
|
831
|
+
isAdmin: boolean;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export interface I_UserUpdate {
|
|
835
|
+
_id?: string;
|
|
836
|
+
name?: string;
|
|
837
|
+
password?: string;
|
|
838
|
+
email?: string;
|
|
839
|
+
department?: string;
|
|
840
|
+
group?: string;
|
|
841
|
+
isAdmin?: boolean;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export interface I_UserDisplay {
|
|
845
|
+
_id: string;
|
|
846
|
+
username: string;
|
|
847
|
+
department: string;
|
|
848
|
+
group: string;
|
|
849
|
+
email?: string;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export interface I_LoginResponse {
|
|
853
|
+
_id: string;
|
|
854
|
+
token?: string;
|
|
855
|
+
isAdmin: boolean;
|
|
856
|
+
userName: string;
|
|
857
|
+
expiresIn?: number;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export interface I_OidcConfig {
|
|
861
|
+
issuer: string;
|
|
862
|
+
clientId: string;
|
|
863
|
+
redirectUri: string;
|
|
864
|
+
scopes?: string[];
|
|
865
|
+
clientSecret?: string;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export interface I_OidcTokenResponse {
|
|
869
|
+
accessToken: string;
|
|
870
|
+
refreshToken?: string;
|
|
871
|
+
idToken?: string;
|
|
872
|
+
expiresIn: number;
|
|
873
|
+
tokenType: string;
|
|
874
|
+
scope: string;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export interface I_OidcUserInfo {
|
|
878
|
+
sub: string;
|
|
879
|
+
name?: string;
|
|
880
|
+
email?: string;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export interface I_OidcClientRegistration {
|
|
884
|
+
client_name: string;
|
|
885
|
+
redirect_uris: string[];
|
|
886
|
+
grant_types?: string[];
|
|
887
|
+
response_types?: string[];
|
|
888
|
+
scope?: string;
|
|
889
|
+
token_endpoint_auth_method?: 'client_secret_basic' | 'client_secret_post' | 'none';
|
|
890
|
+
application_type?: 'web' | 'native';
|
|
891
|
+
logo_uri?: string;
|
|
892
|
+
client_uri?: string;
|
|
893
|
+
policy_uri?: string;
|
|
894
|
+
tos_uri?: string;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export interface I_OidcClientResponse {
|
|
898
|
+
client_id: string;
|
|
899
|
+
client_secret?: string;
|
|
900
|
+
client_secret_expires_at?: number;
|
|
901
|
+
client_id_issued_at?: number;
|
|
902
|
+
registration_access_token?: string;
|
|
903
|
+
registration_client_uri?: string;
|
|
904
|
+
client_name?: string;
|
|
905
|
+
redirect_uris?: string[];
|
|
906
|
+
grant_types?: string[];
|
|
907
|
+
response_types?: string[];
|
|
908
|
+
scope?: string;
|
|
909
|
+
token_endpoint_auth_method?: string;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export interface I_AuthState {
|
|
913
|
+
method: 'jwt' | 'oidc' | 'none';
|
|
914
|
+
token: string | null;
|
|
915
|
+
isAdmin: boolean;
|
|
916
|
+
userName: string;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Document related types
|
|
920
|
+
export interface I_DocumentEntry extends I_DocumentCreationOwned {
|
|
921
|
+
_id: string;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export interface I_DocumentCreation {
|
|
925
|
+
title: string;
|
|
926
|
+
description?: string;
|
|
927
|
+
type: number;
|
|
928
|
+
subType: number;
|
|
929
|
+
content: any;
|
|
930
|
+
shareWithGroup: boolean;
|
|
931
|
+
shareWithDepartment: boolean;
|
|
932
|
+
public: boolean;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
export interface I_DocumentCreationOwned extends I_DocumentCreation {
|
|
937
|
+
owner: string;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export interface I_DocumentUpdate extends I_DocumentQuery {
|
|
941
|
+
content?: any;
|
|
942
|
+
description?: string;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export interface I_DocumentQuery {
|
|
946
|
+
_id?: string;
|
|
947
|
+
owner?: string;
|
|
948
|
+
title?: string;
|
|
949
|
+
type?: number;
|
|
950
|
+
subType?: number;
|
|
951
|
+
shareWithGroup?: boolean;
|
|
952
|
+
shareWithDepartment?: boolean;
|
|
953
|
+
public?: boolean;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
// Structure related types
|
|
958
|
+
export interface I_DataStructure {
|
|
959
|
+
_id?: string | undefined;
|
|
960
|
+
name: string;
|
|
961
|
+
description: string;
|
|
962
|
+
type: number;
|
|
963
|
+
subType: number
|
|
964
|
+
fields: I_StructureField[];
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export interface I_StructureField {
|
|
968
|
+
name: string;
|
|
969
|
+
displayName: string; //should not contain spaces or non-ASCII characters
|
|
970
|
+
type: string;
|
|
971
|
+
items?: string;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export interface I_StructureCreation {
|
|
975
|
+
name: string;
|
|
976
|
+
description?: string;
|
|
977
|
+
fields: I_StructureField[];
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export interface I_StructureUpdate {
|
|
981
|
+
_id?: string
|
|
982
|
+
name?: string;
|
|
983
|
+
description?: string;
|
|
984
|
+
fields?: I_StructureField[];
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// WebSocket-related types
|
|
988
|
+
export type I_EventString = 'heartbeatPong' | "heartbeatPing" | "newDocument" | "newStructure" |
|
|
989
|
+
"newUser" | "newType" | "removedID" | "changedDocument" | "changedStructure" | "changedUser" | "changedType" |
|
|
990
|
+
"removedUser" | "removedStructure" | "removedDocument" | "removedType";
|
|
991
|
+
|
|
992
|
+
export interface I_WsMessage {
|
|
993
|
+
newDocument?: I_DocumentEntry;
|
|
994
|
+
newStructure?: I_DataStructure;
|
|
995
|
+
newUser?: I_UserEntry;
|
|
996
|
+
removedID?: string;
|
|
997
|
+
changedDocument?: I_DocumentUpdate;
|
|
998
|
+
changedStructure?: I_StructureUpdate;
|
|
999
|
+
changedUser?: I_UserUpdate;
|
|
1000
|
+
confirmSubscription?: boolean;
|
|
1001
|
+
confirmUnsubscription?: boolean;
|
|
1002
|
+
heartbeatPing?: number;
|
|
1003
|
+
heartbeatPong?: number;
|
|
1004
|
+
}
|