@clarityops/preferences 0.1.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/dist/index.d.mts +204 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +625 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +596 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/PreferencesContext.tsx +309 -0
- package/src/PreferencesService.ts +443 -0
- package/src/avatar-cache.ts +55 -0
- package/src/index.ts +38 -0
- package/src/types.ts +94 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
UserPreferences,
|
|
3
|
+
PrepareAvatarUploadResponse,
|
|
4
|
+
ConfirmAvatarUploadResponse,
|
|
5
|
+
MyAvatarResponse,
|
|
6
|
+
PreferencesConfig,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate if a token is a well-formed JWT with 3 segments
|
|
11
|
+
*/
|
|
12
|
+
function isValidJwtFormat(token: string | null | undefined): boolean {
|
|
13
|
+
if (!token || typeof token !== 'string') return false;
|
|
14
|
+
// Check for placeholder values
|
|
15
|
+
if (token === 'null' || token === 'undefined' || token.trim() === '') return false;
|
|
16
|
+
// JWT must have exactly 3 segments
|
|
17
|
+
const segments = token.split('.');
|
|
18
|
+
return segments.length === 3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a JWT token is expired
|
|
23
|
+
*/
|
|
24
|
+
function isTokenExpired(token: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
27
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
28
|
+
return payload.exp < currentTime;
|
|
29
|
+
} catch {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Shared refresh lock to prevent concurrent refresh attempts across service instances
|
|
35
|
+
let refreshInProgress: Promise<string | null> | null = null;
|
|
36
|
+
let lastRefreshTime = 0;
|
|
37
|
+
const REFRESH_DEBOUNCE_MS = 5000; // Minimum 5 seconds between refresh attempts
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Service class for user preferences and avatar management
|
|
41
|
+
* Shared across InsightForge and InsightFlow platforms
|
|
42
|
+
*/
|
|
43
|
+
export class PreferencesService {
|
|
44
|
+
private baseUrl: string;
|
|
45
|
+
private brandingUrl: string;
|
|
46
|
+
private getToken: () => string | null;
|
|
47
|
+
private onTokenInvalid?: () => Promise<string | null>;
|
|
48
|
+
|
|
49
|
+
constructor(config: PreferencesConfig) {
|
|
50
|
+
this.baseUrl = config.apiBaseUrl;
|
|
51
|
+
this.brandingUrl = config.brandingUrl;
|
|
52
|
+
this.getToken = config.getToken;
|
|
53
|
+
this.onTokenInvalid = config.onTokenInvalid;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Update the config (e.g., when token changes)
|
|
58
|
+
*/
|
|
59
|
+
updateConfig(config: Partial<PreferencesConfig>) {
|
|
60
|
+
if (config.getToken) {
|
|
61
|
+
this.getToken = config.getToken;
|
|
62
|
+
}
|
|
63
|
+
if (config.onTokenInvalid !== undefined) {
|
|
64
|
+
this.onTokenInvalid = config.onTokenInvalid;
|
|
65
|
+
}
|
|
66
|
+
if (config.apiBaseUrl) {
|
|
67
|
+
this.baseUrl = config.apiBaseUrl;
|
|
68
|
+
}
|
|
69
|
+
if (config.brandingUrl) {
|
|
70
|
+
this.brandingUrl = config.brandingUrl;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Refresh token with debounce to prevent multiple simultaneous refresh attempts
|
|
76
|
+
*/
|
|
77
|
+
private async refreshWithDebounce(): Promise<string | null> {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
|
|
80
|
+
// If a refresh is already in progress, wait for it
|
|
81
|
+
if (refreshInProgress) {
|
|
82
|
+
console.log('[PreferencesService] ⏳ Waiting for existing refresh to complete...');
|
|
83
|
+
return refreshInProgress;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Debounce: if we just refreshed, get current token from localStorage
|
|
87
|
+
if (now - lastRefreshTime < REFRESH_DEBOUNCE_MS) {
|
|
88
|
+
console.log('[PreferencesService] ⏳ Recent refresh detected, using current token');
|
|
89
|
+
return this.getToken();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Start a new refresh
|
|
93
|
+
if (this.onTokenInvalid) {
|
|
94
|
+
console.log('[PreferencesService] 🔄 Starting token refresh...');
|
|
95
|
+
lastRefreshTime = now;
|
|
96
|
+
refreshInProgress = this.onTokenInvalid().finally(() => {
|
|
97
|
+
refreshInProgress = null;
|
|
98
|
+
});
|
|
99
|
+
return refreshInProgress;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get a valid token, refreshing if necessary with debounce protection
|
|
107
|
+
*/
|
|
108
|
+
private async getValidToken(): Promise<string> {
|
|
109
|
+
let token = this.getToken();
|
|
110
|
+
|
|
111
|
+
// Check if token is valid format
|
|
112
|
+
if (!isValidJwtFormat(token)) {
|
|
113
|
+
console.warn('[PreferencesService] Token is missing or malformed, attempting refresh...');
|
|
114
|
+
token = await this.refreshWithDebounce();
|
|
115
|
+
if (!isValidJwtFormat(token)) {
|
|
116
|
+
throw new Error('Authentication required - no valid token available');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if token is expired
|
|
121
|
+
if (isTokenExpired(token!)) {
|
|
122
|
+
console.warn('[PreferencesService] Token is expired, attempting refresh...');
|
|
123
|
+
token = await this.refreshWithDebounce();
|
|
124
|
+
if (!token || isTokenExpired(token)) {
|
|
125
|
+
throw new Error('Session expired - please log in again');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return token!;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
133
|
+
const token = await this.getValidToken();
|
|
134
|
+
|
|
135
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
136
|
+
...options,
|
|
137
|
+
headers: {
|
|
138
|
+
'Authorization': `Bearer ${token}`,
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
...options.headers,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
|
146
|
+
|
|
147
|
+
// If we get a 401, try to refresh and retry once
|
|
148
|
+
if (response.status === 401 && this.onTokenInvalid) {
|
|
149
|
+
console.warn('[PreferencesService] Got 401, attempting token refresh and retry...');
|
|
150
|
+
const newToken = await this.onTokenInvalid();
|
|
151
|
+
if (newToken && isValidJwtFormat(newToken)) {
|
|
152
|
+
const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
153
|
+
...options,
|
|
154
|
+
headers: {
|
|
155
|
+
'Authorization': `Bearer ${newToken}`,
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
...options.headers,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (retryResponse.ok) {
|
|
162
|
+
return retryResponse.json();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new Error(error.error || error.message || 'Request failed');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const data = await response.json();
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get user preferences
|
|
176
|
+
*/
|
|
177
|
+
async getPreferences(): Promise<{ data: { preferences: UserPreferences } }> {
|
|
178
|
+
return this.request('/forge/users/me/preferences');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Update preferences (merge update)
|
|
183
|
+
*/
|
|
184
|
+
async updatePreferences(preferences: Partial<UserPreferences>): Promise<{ data: { preferences: UserPreferences }; message?: string }> {
|
|
185
|
+
return this.request('/forge/users/me/preferences', {
|
|
186
|
+
method: 'PATCH',
|
|
187
|
+
body: JSON.stringify(preferences),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Replace all preferences
|
|
193
|
+
*/
|
|
194
|
+
async replacePreferences(preferences: UserPreferences): Promise<{ data: { preferences: UserPreferences }; message?: string }> {
|
|
195
|
+
return this.request('/forge/users/me/preferences', {
|
|
196
|
+
method: 'PUT',
|
|
197
|
+
body: JSON.stringify(preferences),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Step 1: Prepare avatar upload - get signed URL for GCS
|
|
203
|
+
*/
|
|
204
|
+
async prepareAvatarUpload(filename: string, mimeType: string): Promise<PrepareAvatarUploadResponse> {
|
|
205
|
+
const token = await this.getValidToken();
|
|
206
|
+
|
|
207
|
+
const response = await fetch(`${this.brandingUrl}/branding/prepare-avatar-upload`, {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: {
|
|
210
|
+
'Authorization': `Bearer ${token}`,
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
filename,
|
|
215
|
+
mime_type: mimeType,
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
const error = await response.json().catch(() => ({ error: 'Failed to prepare upload' }));
|
|
221
|
+
|
|
222
|
+
// If we get a 401, try to refresh and retry once
|
|
223
|
+
if (response.status === 401 && this.onTokenInvalid) {
|
|
224
|
+
console.warn('[PreferencesService] Got 401 on prepareAvatarUpload, attempting refresh...');
|
|
225
|
+
const newToken = await this.onTokenInvalid();
|
|
226
|
+
if (newToken && isValidJwtFormat(newToken)) {
|
|
227
|
+
const retryResponse = await fetch(`${this.brandingUrl}/branding/prepare-avatar-upload`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'Authorization': `Bearer ${newToken}`,
|
|
231
|
+
'Content-Type': 'application/json',
|
|
232
|
+
},
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
filename,
|
|
235
|
+
mime_type: mimeType,
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (retryResponse.ok) {
|
|
240
|
+
const data = await retryResponse.json();
|
|
241
|
+
if (data.success) return data.data;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
throw new Error(error.error || error.message || 'Failed to prepare avatar upload');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const data = await response.json();
|
|
250
|
+
if (!data.success) {
|
|
251
|
+
throw new Error(data.error || 'Failed to prepare avatar upload');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return data.data;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Step 2: Upload file directly to GCS
|
|
259
|
+
*/
|
|
260
|
+
async uploadToGCS(uploadUrl: string, file: File): Promise<void> {
|
|
261
|
+
const response = await fetch(uploadUrl, {
|
|
262
|
+
method: 'PUT',
|
|
263
|
+
headers: {
|
|
264
|
+
'Content-Type': file.type,
|
|
265
|
+
},
|
|
266
|
+
body: file,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
throw new Error('Failed to upload file to storage');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Step 3: Confirm avatar upload
|
|
276
|
+
*/
|
|
277
|
+
async confirmAvatarUpload(storagePath: string): Promise<ConfirmAvatarUploadResponse> {
|
|
278
|
+
const token = await this.getValidToken();
|
|
279
|
+
|
|
280
|
+
const response = await fetch(`${this.brandingUrl}/branding/confirm-avatar-upload`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: {
|
|
283
|
+
'Authorization': `Bearer ${token}`,
|
|
284
|
+
'Content-Type': 'application/json',
|
|
285
|
+
},
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
storage_path: storagePath,
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
const error = await response.json().catch(() => ({ error: 'Failed to confirm upload' }));
|
|
293
|
+
|
|
294
|
+
// If we get a 401, try to refresh and retry once
|
|
295
|
+
if (response.status === 401 && this.onTokenInvalid) {
|
|
296
|
+
console.warn('[PreferencesService] Got 401 on confirmAvatarUpload, attempting refresh...');
|
|
297
|
+
const newToken = await this.onTokenInvalid();
|
|
298
|
+
if (newToken && isValidJwtFormat(newToken)) {
|
|
299
|
+
const retryResponse = await fetch(`${this.brandingUrl}/branding/confirm-avatar-upload`, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: {
|
|
302
|
+
'Authorization': `Bearer ${newToken}`,
|
|
303
|
+
'Content-Type': 'application/json',
|
|
304
|
+
},
|
|
305
|
+
body: JSON.stringify({
|
|
306
|
+
storage_path: storagePath,
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (retryResponse.ok) {
|
|
311
|
+
const data = await retryResponse.json();
|
|
312
|
+
if (data.success) return data.data;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
throw new Error(error.error || error.message || 'Failed to confirm avatar upload');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const data = await response.json();
|
|
321
|
+
if (!data.success) {
|
|
322
|
+
throw new Error(data.error || 'Failed to confirm avatar upload');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return data.data;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get current user's avatar
|
|
330
|
+
*/
|
|
331
|
+
async getMyAvatar(): Promise<MyAvatarResponse> {
|
|
332
|
+
const token = await this.getValidToken();
|
|
333
|
+
|
|
334
|
+
const response = await fetch(`${this.brandingUrl}/branding/my-avatar`, {
|
|
335
|
+
headers: {
|
|
336
|
+
'Authorization': `Bearer ${token}`,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!response.ok) {
|
|
341
|
+
const error = await response.json().catch(() => ({ error: 'Failed to fetch avatar' }));
|
|
342
|
+
|
|
343
|
+
// If we get a 401, try to refresh and retry once
|
|
344
|
+
if (response.status === 401 && this.onTokenInvalid) {
|
|
345
|
+
console.warn('[PreferencesService] Got 401 on getMyAvatar, attempting refresh...');
|
|
346
|
+
const newToken = await this.onTokenInvalid();
|
|
347
|
+
if (newToken && isValidJwtFormat(newToken)) {
|
|
348
|
+
const retryResponse = await fetch(`${this.brandingUrl}/branding/my-avatar`, {
|
|
349
|
+
headers: {
|
|
350
|
+
'Authorization': `Bearer ${newToken}`,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (retryResponse.ok) {
|
|
355
|
+
const data = await retryResponse.json();
|
|
356
|
+
if (data.success) return data.data;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
throw new Error(error.error || error.message || 'Failed to fetch avatar');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const data = await response.json();
|
|
365
|
+
if (!data.success) {
|
|
366
|
+
throw new Error(data.error || 'Failed to fetch avatar');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return data.data;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Get any user's avatar
|
|
374
|
+
*/
|
|
375
|
+
async getUserAvatar(userId: string): Promise<MyAvatarResponse> {
|
|
376
|
+
const token = await this.getValidToken();
|
|
377
|
+
|
|
378
|
+
const response = await fetch(`${this.brandingUrl}/branding/users/${userId}/avatar`, {
|
|
379
|
+
headers: {
|
|
380
|
+
'Authorization': `Bearer ${token}`,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
const error = await response.json().catch(() => ({ error: 'Failed to fetch avatar' }));
|
|
386
|
+
|
|
387
|
+
// If we get a 401, try to refresh and retry once
|
|
388
|
+
if (response.status === 401 && this.onTokenInvalid) {
|
|
389
|
+
console.warn('[PreferencesService] Got 401 on getUserAvatar, attempting refresh...');
|
|
390
|
+
const newToken = await this.onTokenInvalid();
|
|
391
|
+
if (newToken && isValidJwtFormat(newToken)) {
|
|
392
|
+
const retryResponse = await fetch(`${this.brandingUrl}/branding/users/${userId}/avatar`, {
|
|
393
|
+
headers: {
|
|
394
|
+
'Authorization': `Bearer ${newToken}`,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (retryResponse.ok) {
|
|
399
|
+
const data = await retryResponse.json();
|
|
400
|
+
if (data.success) return data.data;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
throw new Error(error.error || error.message || 'Failed to fetch avatar');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const data = await response.json();
|
|
409
|
+
if (!data.success) {
|
|
410
|
+
throw new Error(data.error || 'Failed to fetch avatar');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return data.data;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Complete avatar upload flow (3-step process)
|
|
418
|
+
*/
|
|
419
|
+
async uploadAvatar(file: File): Promise<string> {
|
|
420
|
+
// Validate file type
|
|
421
|
+
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
|
422
|
+
if (!validTypes.includes(file.type)) {
|
|
423
|
+
throw new Error('Please select a PNG, JPEG, or WebP image');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Validate file size (5MB max)
|
|
427
|
+
const maxSize = 5 * 1024 * 1024;
|
|
428
|
+
if (file.size > maxSize) {
|
|
429
|
+
throw new Error('Image must be less than 5MB');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Step 1: Prepare upload
|
|
433
|
+
const prepareData = await this.prepareAvatarUpload(file.name, file.type);
|
|
434
|
+
|
|
435
|
+
// Step 2: Upload to GCS
|
|
436
|
+
await this.uploadToGCS(prepareData.upload_url, file);
|
|
437
|
+
|
|
438
|
+
// Step 3: Confirm upload
|
|
439
|
+
const confirmData = await this.confirmAvatarUpload(prepareData.storage_path);
|
|
440
|
+
|
|
441
|
+
return confirmData.avatar_url;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache for avatar URLs
|
|
3
|
+
* Shared across all components using the preferences context
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface CacheEntry {
|
|
7
|
+
url: string;
|
|
8
|
+
cachedAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Cache TTL: 1 day (as per API guidance)
|
|
12
|
+
export const AVATAR_CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
// Global in-memory cache
|
|
15
|
+
const avatarCache = new Map<string, CacheEntry>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get cached avatar URL if valid
|
|
19
|
+
*/
|
|
20
|
+
export function getCachedAvatar(userId: string): string | null {
|
|
21
|
+
const cached = avatarCache.get(userId);
|
|
22
|
+
if (cached && Date.now() - cached.cachedAt < AVATAR_CACHE_TTL) {
|
|
23
|
+
return cached.url;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set avatar URL in cache
|
|
30
|
+
*/
|
|
31
|
+
export function setCachedAvatar(userId: string, url: string): void {
|
|
32
|
+
avatarCache.set(userId, {
|
|
33
|
+
url,
|
|
34
|
+
cachedAt: Date.now(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Clear cached avatar for a specific user or all users
|
|
40
|
+
*/
|
|
41
|
+
export function clearAvatarCache(userId?: string): void {
|
|
42
|
+
if (userId) {
|
|
43
|
+
avatarCache.delete(userId);
|
|
44
|
+
} else {
|
|
45
|
+
avatarCache.clear();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if cache entry exists and is valid
|
|
51
|
+
*/
|
|
52
|
+
export function hasCachedAvatar(userId: string): boolean {
|
|
53
|
+
const cached = avatarCache.get(userId);
|
|
54
|
+
return cached !== undefined && Date.now() - cached.cachedAt < AVATAR_CACHE_TTL;
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @clarityops/preferences
|
|
3
|
+
*
|
|
4
|
+
* Shared user preferences context for InsightForge and InsightFlow platforms.
|
|
5
|
+
* Provides unified avatar, theme, and notification preferences management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Context and hooks
|
|
9
|
+
export {
|
|
10
|
+
PreferencesProvider,
|
|
11
|
+
usePreferences,
|
|
12
|
+
usePreferencesSafe,
|
|
13
|
+
useAvatar,
|
|
14
|
+
type PreferencesProviderProps,
|
|
15
|
+
} from './PreferencesContext';
|
|
16
|
+
|
|
17
|
+
// Service
|
|
18
|
+
export { PreferencesService } from './PreferencesService';
|
|
19
|
+
|
|
20
|
+
// Types
|
|
21
|
+
export type {
|
|
22
|
+
UserPreferences,
|
|
23
|
+
AvatarData,
|
|
24
|
+
PreferencesConfig,
|
|
25
|
+
PreferencesContextValue,
|
|
26
|
+
PrepareAvatarUploadResponse,
|
|
27
|
+
ConfirmAvatarUploadResponse,
|
|
28
|
+
MyAvatarResponse,
|
|
29
|
+
} from './types';
|
|
30
|
+
|
|
31
|
+
// Cache utilities
|
|
32
|
+
export {
|
|
33
|
+
getCachedAvatar,
|
|
34
|
+
setCachedAvatar,
|
|
35
|
+
clearAvatarCache,
|
|
36
|
+
hasCachedAvatar,
|
|
37
|
+
AVATAR_CACHE_TTL,
|
|
38
|
+
} from './avatar-cache';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User preferences types shared across InsightForge and InsightFlow
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface AnalysisCenterFilterPreset {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
pattern: 'one_time' | 'recurring_structured' | 'continuous_monitoring';
|
|
9
|
+
category: string | null;
|
|
10
|
+
depth: 'quick_scan' | 'deep_dive' | null;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UserPreferences {
|
|
15
|
+
theme?: 'light' | 'dark' | 'system';
|
|
16
|
+
avatar_url?: string;
|
|
17
|
+
locale?: string;
|
|
18
|
+
notification_settings?: {
|
|
19
|
+
email?: boolean;
|
|
20
|
+
push?: boolean;
|
|
21
|
+
sound?: boolean;
|
|
22
|
+
sound_volume?: number; // 0-100
|
|
23
|
+
};
|
|
24
|
+
sidebar_collapsed?: boolean;
|
|
25
|
+
default_client_id?: string;
|
|
26
|
+
timezone?: string;
|
|
27
|
+
avatar_updated_at?: string;
|
|
28
|
+
avatar_storage_path?: string;
|
|
29
|
+
show_demo_companies?: boolean;
|
|
30
|
+
analysis_center_filter_presets?: AnalysisCenterFilterPreset[];
|
|
31
|
+
/** Show WebSocket debug panel for connection troubleshooting (platform admins only) */
|
|
32
|
+
show_ws_debug_panel?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AvatarData {
|
|
36
|
+
avatarUrl: string | null;
|
|
37
|
+
hasAvatar: boolean;
|
|
38
|
+
loading: boolean;
|
|
39
|
+
error: string | null;
|
|
40
|
+
refresh: () => Promise<void>;
|
|
41
|
+
handleImageError: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PrepareAvatarUploadResponse {
|
|
45
|
+
upload_id: string;
|
|
46
|
+
user_id: string;
|
|
47
|
+
upload_url: string;
|
|
48
|
+
storage_path: string;
|
|
49
|
+
expires_at: string;
|
|
50
|
+
max_size_bytes: number;
|
|
51
|
+
allowed_types: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ConfirmAvatarUploadResponse {
|
|
55
|
+
user_id: string;
|
|
56
|
+
avatar_url: string;
|
|
57
|
+
file_size_bytes: number;
|
|
58
|
+
storage_path: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MyAvatarResponse {
|
|
62
|
+
user_id: string;
|
|
63
|
+
has_avatar: boolean;
|
|
64
|
+
avatar_url: string | null;
|
|
65
|
+
expires_at?: string;
|
|
66
|
+
cache_max_age_seconds?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PreferencesConfig {
|
|
70
|
+
apiBaseUrl: string;
|
|
71
|
+
brandingUrl: string;
|
|
72
|
+
getToken: () => string | null;
|
|
73
|
+
/** Called when token is invalid/expired to attempt refresh */
|
|
74
|
+
onTokenInvalid?: () => Promise<string | null>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PreferencesContextValue {
|
|
78
|
+
// Preferences state
|
|
79
|
+
preferences: UserPreferences;
|
|
80
|
+
loading: boolean;
|
|
81
|
+
error: string | null;
|
|
82
|
+
|
|
83
|
+
// Avatar data
|
|
84
|
+
avatar: AvatarData;
|
|
85
|
+
|
|
86
|
+
// Actions
|
|
87
|
+
updatePreferences: (prefs: Partial<UserPreferences>) => Promise<void>;
|
|
88
|
+
refreshPreferences: () => Promise<void>;
|
|
89
|
+
uploadAvatar: (file: File) => Promise<string>;
|
|
90
|
+
clearAvatarCache: (userId?: string) => void;
|
|
91
|
+
|
|
92
|
+
/** Sync preferences on login and apply theme immediately */
|
|
93
|
+
syncOnLogin: () => Promise<void>;
|
|
94
|
+
}
|