kamau1 commited on
Commit
46739e8
·
1 Parent(s): 4f3ae76

feat: app management

Browse files
docs/api/auth/TOKEN_REFRESH_GUIDE.md ADDED
@@ -0,0 +1,797 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Token Refresh & Session Management Guide
2
+
3
+ ## Overview
4
+
5
+ This guide explains why users get auto-logged out and how to implement robust token refresh logic in your frontend application.
6
+
7
+ **TL;DR:** Refresh tokens expire after 30 days. You need proactive refresh logic in your frontend to prevent auto-logout.
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ 1. [Understanding the Problem](#understanding-the-problem)
14
+ 2. [How Supabase Tokens Work](#how-supabase-tokens-work)
15
+ 3. [Why Users Get Logged Out](#why-users-get-logged-out)
16
+ 4. [The Solution](#the-solution)
17
+ 5. [Frontend Implementation](#frontend-implementation)
18
+ 6. [Backend Behavior](#backend-behavior)
19
+ 7. [Testing & Validation](#testing--validation)
20
+ 8. [Troubleshooting](#troubleshooting)
21
+
22
+ ---
23
+
24
+ ## Understanding the Problem
25
+
26
+ ### The Error
27
+
28
+ ```
29
+ ERROR: invalid JWT: unable to parse or verify signature, token has invalid claims: token is expired
30
+ ```
31
+
32
+ This error appears when a **refresh token has expired**, not the access token.
33
+
34
+ ### What Happens
35
+
36
+ 1. User logs in → Receives access token (1 hour) + refresh token (30 days)
37
+ 2. Access token expires after 1 hour
38
+ 3. Frontend tries to refresh using refresh token
39
+ 4. **If refresh token is also expired (30+ days old)** → 401 Error → Auto-logout
40
+
41
+ ---
42
+
43
+ ## How Supabase Tokens Work
44
+
45
+ ### Token Types
46
+
47
+ | Token Type | Lifespan | Purpose | Storage |
48
+ |------------|----------|---------|---------|
49
+ | **Access Token** | 1 hour | Authenticate API requests | Memory/LocalStorage |
50
+ | **Refresh Token** | 30 days | Get new access tokens | LocalStorage (HttpOnly cookies recommended) |
51
+
52
+ ### Token Rotation
53
+
54
+ Supabase uses **rotating refresh tokens** for security:
55
+
56
+ - Each time you refresh, you get a **new refresh token**
57
+ - The **old refresh token becomes invalid**
58
+ - This prevents replay attacks
59
+ - If rotation fails (network issues, race conditions), tokens can become invalid
60
+
61
+ ### Storage
62
+
63
+ After successful login, you receive:
64
+
65
+ ```json
66
+ {
67
+ "access_token": "eyJhbG...",
68
+ "refresh_token": "v1.MKh...",
69
+ "expires_in": 3600,
70
+ "token_type": "bearer",
71
+ "user": { ... }
72
+ }
73
+ ```
74
+
75
+ Store in localStorage:
76
+
77
+ ```javascript
78
+ localStorage.setItem('access_token', data.access_token);
79
+ localStorage.setItem('refresh_token', data.refresh_token);
80
+ localStorage.setItem('expires_at', new Date(Date.now() + data.expires_in * 1000).toISOString());
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Why Users Get Logged Out
86
+
87
+ ### Scenario 1: Inactivity (Most Common)
88
+
89
+ - User doesn't use app for 30+ days
90
+ - Refresh token expires
91
+ - Next time they open app → Auto-logout
92
+
93
+ **Solution:** Display "Your session expired due to inactivity" message
94
+
95
+ ### Scenario 2: Token Rotation Failure
96
+
97
+ - Network interruption during refresh
98
+ - Race condition (multiple tabs refreshing simultaneously)
99
+ - Old refresh token stored, new one discarded
100
+
101
+ **Solution:** Implement retry logic and synchronize across tabs
102
+
103
+ ### Scenario 3: No Proactive Refresh
104
+
105
+ - Frontend waits for 401 error before refreshing
106
+ - User makes request with expired access token
107
+ - Backend returns 401
108
+ - Frontend tries to refresh, but refresh token also expired
109
+
110
+ **Solution:** Refresh tokens **before** they expire (5 minutes buffer)
111
+
112
+ ### Scenario 4: Multi-Tab/Device Issues
113
+
114
+ - User opens app in multiple tabs/devices
115
+ - Each tab/device tries to refresh independently
116
+ - Token rotation causes conflicts
117
+ - Tokens become invalid across sessions
118
+
119
+ **Solution:** Use BroadcastChannel API or shared worker for token sync
120
+
121
+ ---
122
+
123
+ ## The Solution
124
+
125
+ ### Architecture Overview
126
+
127
+ ```
128
+ ┌─────────────────────────────────────────────────────────────┐
129
+ │ FRONTEND │
130
+ │ │
131
+ │ 1. Login → Store tokens + Schedule refresh │
132
+ │ 2. Auto-refresh 5 min before expiry │
133
+ │ 3. Retry on 401 errors │
134
+ │ 4. Sync tokens across tabs │
135
+ │ 5. Graceful logout on refresh token expiry │
136
+ │ │
137
+ └─────────────────────────────────────────────────────────────┘
138
+
139
+
140
+ ┌─────────────────────────────────────────────────────────────┐
141
+ │ BACKEND │
142
+ │ │
143
+ │ POST /api/v1/auth/refresh-token │
144
+ │ • Validates refresh token with Supabase │
145
+ │ • Returns new access + refresh tokens (rotated) │
146
+ │ • Returns clear error if refresh token expired │
147
+ │ │
148
+ └─────────────────────────────────────────────────────────────┘
149
+ ```
150
+
151
+ ### Key Principles
152
+
153
+ 1. **Proactive Refresh** - Don't wait for 401 errors
154
+ 2. **Token Rotation Handling** - Always store new refresh token
155
+ 3. **Cross-Tab Sync** - Coordinate token updates across tabs
156
+ 4. **Graceful Degradation** - Clear messaging when re-login required
157
+ 5. **Retry Logic** - Handle transient failures
158
+
159
+ ---
160
+
161
+ ## Frontend Implementation
162
+
163
+ ### 1. Token Refresh Function
164
+
165
+ ```javascript
166
+ // utils/auth.js
167
+
168
+ const API_BASE = process.env.REACT_APP_API_URL;
169
+
170
+ /**
171
+ * Refresh access token using refresh token
172
+ * Returns new tokens or null if refresh failed
173
+ */
174
+ export const refreshTokens = async () => {
175
+ const refreshToken = localStorage.getItem('refresh_token');
176
+
177
+ if (!refreshToken) {
178
+ console.warn('No refresh token found');
179
+ handleLogout();
180
+ return null;
181
+ }
182
+
183
+ try {
184
+ const res = await fetch(`${API_BASE}/auth/refresh-token`, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ refresh_token: refreshToken }),
188
+ });
189
+
190
+ if (res.ok) {
191
+ const data = await res.json();
192
+
193
+ // Store new tokens (refresh token is rotated by Supabase)
194
+ localStorage.setItem('access_token', data.access_token);
195
+ localStorage.setItem('refresh_token', data.refresh_token);
196
+
197
+ const expiresAt = new Date(Date.now() + data.expires_in * 1000);
198
+ localStorage.setItem('expires_at', expiresAt.toISOString());
199
+
200
+ console.log('✅ Tokens refreshed successfully');
201
+
202
+ // Broadcast to other tabs
203
+ broadcastTokenUpdate(data);
204
+
205
+ // Schedule next refresh
206
+ scheduleTokenRefresh(expiresAt);
207
+
208
+ return data;
209
+ } else if (res.status === 401) {
210
+ // Refresh token expired
211
+ const error = await res.json();
212
+ console.error('Refresh token expired:', error.detail);
213
+
214
+ // Show user-friendly message
215
+ showToast('Your session has expired. Please log in again.', 'warning');
216
+
217
+ handleLogout();
218
+ return null;
219
+ } else {
220
+ throw new Error(`Refresh failed: ${res.status}`);
221
+ }
222
+ } catch (error) {
223
+ console.error('Token refresh error:', error);
224
+ handleLogout();
225
+ return null;
226
+ }
227
+ };
228
+
229
+ /**
230
+ * Logout user and clear all tokens
231
+ */
232
+ const handleLogout = () => {
233
+ localStorage.removeItem('access_token');
234
+ localStorage.removeItem('refresh_token');
235
+ localStorage.removeItem('expires_at');
236
+
237
+ // Redirect to login
238
+ window.location.href = '/login';
239
+ };
240
+ ```
241
+
242
+ ### 2. Proactive Token Refresh Scheduler
243
+
244
+ ```javascript
245
+ // utils/auth.js
246
+
247
+ let refreshTimeout = null;
248
+
249
+ /**
250
+ * Schedule automatic token refresh before expiration
251
+ * @param {Date|string} expiresAt - Token expiration timestamp
252
+ */
253
+ export const scheduleTokenRefresh = (expiresAt) => {
254
+ // Clear any existing timeout
255
+ if (refreshTimeout) {
256
+ clearTimeout(refreshTimeout);
257
+ }
258
+
259
+ const now = Date.now();
260
+ const expiresAtMs = new Date(expiresAt).getTime();
261
+ const timeUntilExpiry = expiresAtMs - now;
262
+
263
+ // Refresh 5 minutes (300000ms) before expiration
264
+ const BUFFER_TIME = 5 * 60 * 1000;
265
+ const refreshTime = timeUntilExpiry - BUFFER_TIME;
266
+
267
+ if (refreshTime > 0) {
268
+ console.log(`🕐 Token refresh scheduled in ${Math.round(refreshTime / 1000 / 60)} minutes`);
269
+
270
+ refreshTimeout = setTimeout(async () => {
271
+ console.log('⏰ Auto-refreshing token...');
272
+ await refreshTokens();
273
+ }, refreshTime);
274
+ } else {
275
+ // Token already expired or expires very soon - refresh immediately
276
+ console.log('⚠️ Token expired or expiring soon, refreshing now...');
277
+ refreshTokens();
278
+ }
279
+ };
280
+
281
+ /**
282
+ * Initialize token refresh on app startup
283
+ */
284
+ export const initializeTokenRefresh = () => {
285
+ const expiresAt = localStorage.getItem('expires_at');
286
+
287
+ if (expiresAt) {
288
+ scheduleTokenRefresh(expiresAt);
289
+ } else {
290
+ console.warn('No token expiration found');
291
+ }
292
+ };
293
+ ```
294
+
295
+ ### 3. Cross-Tab Token Synchronization
296
+
297
+ ```javascript
298
+ // utils/auth.js
299
+
300
+ /**
301
+ * Broadcast token updates to other tabs/windows
302
+ */
303
+ const broadcastTokenUpdate = (tokenData) => {
304
+ // Use BroadcastChannel API (modern browsers)
305
+ if ('BroadcastChannel' in window) {
306
+ const channel = new BroadcastChannel('auth_channel');
307
+ channel.postMessage({
308
+ type: 'TOKEN_UPDATED',
309
+ data: tokenData
310
+ });
311
+ }
312
+
313
+ // Fallback: localStorage events
314
+ localStorage.setItem('token_update_event', JSON.stringify({
315
+ timestamp: Date.now(),
316
+ data: tokenData
317
+ }));
318
+ };
319
+
320
+ /**
321
+ * Listen for token updates from other tabs
322
+ */
323
+ export const setupTokenSyncListener = () => {
324
+ // BroadcastChannel listener
325
+ if ('BroadcastChannel' in window) {
326
+ const channel = new BroadcastChannel('auth_channel');
327
+ channel.onmessage = (event) => {
328
+ if (event.data.type === 'TOKEN_UPDATED') {
329
+ console.log('📡 Received token update from another tab');
330
+ scheduleTokenRefresh(localStorage.getItem('expires_at'));
331
+ }
332
+ };
333
+ }
334
+
335
+ // localStorage listener (fallback)
336
+ window.addEventListener('storage', (event) => {
337
+ if (event.key === 'token_update_event' && event.newValue) {
338
+ console.log('📡 Received token update via storage event');
339
+ scheduleTokenRefresh(localStorage.getItem('expires_at'));
340
+ }
341
+ });
342
+ };
343
+ ```
344
+
345
+ ### 4. API Interceptor for 401 Errors
346
+
347
+ ```javascript
348
+ // utils/api.js
349
+
350
+ import axios from 'axios';
351
+ import { refreshTokens } from './auth';
352
+
353
+ const api = axios.create({
354
+ baseURL: process.env.REACT_APP_API_URL,
355
+ });
356
+
357
+ // Request interceptor - Add access token to all requests
358
+ api.interceptors.request.use(
359
+ (config) => {
360
+ const accessToken = localStorage.getItem('access_token');
361
+ if (accessToken) {
362
+ config.headers.Authorization = `Bearer ${accessToken}`;
363
+ }
364
+ return config;
365
+ },
366
+ (error) => Promise.reject(error)
367
+ );
368
+
369
+ // Response interceptor - Auto-retry on 401 with token refresh
370
+ api.interceptors.response.use(
371
+ (response) => response,
372
+ async (error) => {
373
+ const originalRequest = error.config;
374
+
375
+ // If 401 and not already retried
376
+ if (error.response?.status === 401 && !originalRequest._retry) {
377
+ originalRequest._retry = true;
378
+
379
+ console.log('🔄 401 error detected, attempting token refresh...');
380
+
381
+ const newTokens = await refreshTokens();
382
+
383
+ if (newTokens) {
384
+ // Update authorization header with new token
385
+ originalRequest.headers.Authorization = `Bearer ${newTokens.access_token}`;
386
+
387
+ // Retry original request
388
+ return api(originalRequest);
389
+ } else {
390
+ // Refresh failed - user will be logged out by refreshTokens()
391
+ return Promise.reject(error);
392
+ }
393
+ }
394
+
395
+ return Promise.reject(error);
396
+ }
397
+ );
398
+
399
+ export default api;
400
+ ```
401
+
402
+ ### 5. Login Flow Integration
403
+
404
+ ```javascript
405
+ // pages/Login.jsx
406
+
407
+ import { refreshTokens, scheduleTokenRefresh, setupTokenSyncListener } from '../utils/auth';
408
+
409
+ const handleLogin = async (email, password) => {
410
+ try {
411
+ const res = await fetch(`${API_BASE}/auth/login`, {
412
+ method: 'POST',
413
+ headers: { 'Content-Type': 'application/json' },
414
+ body: JSON.stringify({ email, password }),
415
+ });
416
+
417
+ if (res.ok) {
418
+ const data = await res.json();
419
+
420
+ // Store tokens
421
+ localStorage.setItem('access_token', data.access_token);
422
+ localStorage.setItem('refresh_token', data.refresh_token);
423
+
424
+ const expiresAt = new Date(Date.now() + data.expires_in * 1000);
425
+ localStorage.setItem('expires_at', expiresAt.toISOString());
426
+
427
+ // Schedule automatic refresh
428
+ scheduleTokenRefresh(expiresAt);
429
+
430
+ // Setup cross-tab sync
431
+ setupTokenSyncListener();
432
+
433
+ // Navigate to dashboard
434
+ navigate('/dashboard');
435
+ } else {
436
+ // Handle error
437
+ const error = await res.json();
438
+ showToast(error.detail, 'error');
439
+ }
440
+ } catch (error) {
441
+ console.error('Login error:', error);
442
+ showToast('Login failed. Please try again.', 'error');
443
+ }
444
+ };
445
+ ```
446
+
447
+ ### 6. App Initialization
448
+
449
+ ```javascript
450
+ // App.jsx or main.jsx
451
+
452
+ import { initializeTokenRefresh, setupTokenSyncListener } from './utils/auth';
453
+
454
+ function App() {
455
+ useEffect(() => {
456
+ // Check if user is logged in
457
+ const accessToken = localStorage.getItem('access_token');
458
+
459
+ if (accessToken) {
460
+ // Initialize automatic token refresh
461
+ initializeTokenRefresh();
462
+
463
+ // Setup cross-tab synchronization
464
+ setupTokenSyncListener();
465
+ }
466
+ }, []);
467
+
468
+ return <Router>...</Router>;
469
+ }
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Backend Behavior
475
+
476
+ ### Endpoint: POST /api/v1/auth/refresh-token
477
+
478
+ **Request:**
479
+ ```json
480
+ {
481
+ "refresh_token": "v1.MKh..."
482
+ }
483
+ ```
484
+
485
+ **Success Response (200):**
486
+ ```json
487
+ {
488
+ "access_token": "eyJhbG...",
489
+ "refresh_token": "v1.XYz...",
490
+ "expires_in": 3600,
491
+ "token_type": "bearer",
492
+ "user": {
493
+ "id": "uuid",
494
+ "email": "user@example.com",
495
+ "first_name": "John",
496
+ "last_name": "Doe",
497
+ "full_name": "John Doe",
498
+ "role": "platform_admin",
499
+ "is_active": true
500
+ }
501
+ }
502
+ ```
503
+
504
+ **Error Responses:**
505
+
506
+ | Status Code | Error | Meaning | Frontend Action |
507
+ |-------------|-------|---------|-----------------|
508
+ | 401 | `Refresh token expired. Please log in again.` | Refresh token is expired (30+ days old) | Logout user, redirect to login with message |
509
+ | 401 | `Token refresh failed: <error>` | Network or Supabase error | Retry once, then logout if still fails |
510
+ | 404 | `User account no longer exists` | User deleted from database | Logout user, show account deletion message |
511
+ | 403 | `Account is inactive. Contact support.` | User account deactivated | Logout user, show contact support message |
512
+
513
+ ### Backend Implementation Details
514
+
515
+ The backend:
516
+ 1. Validates refresh token with Supabase
517
+ 2. Returns **rotated tokens** (new refresh token on each call)
518
+ 3. Verifies user still exists and is active
519
+ 4. Provides clear, actionable error messages
520
+ 5. Logs all refresh attempts for security monitoring
521
+
522
+ **Important:** The backend does NOT store tokens in a database. Supabase manages all token storage and validation. The backend simply validates tokens and returns new ones.
523
+
524
+ ---
525
+
526
+ ## Testing & Validation
527
+
528
+ ### Test Case 1: Normal Token Refresh
529
+
530
+ **Steps:**
531
+ 1. Login to application
532
+ 2. Wait 55 minutes (or modify expiry for testing)
533
+ 3. Make API request
534
+ 4. Verify auto-refresh happens
535
+ 5. Verify new tokens stored in localStorage
536
+
537
+ **Expected:** No logout, seamless refresh
538
+
539
+ ### Test Case 2: Expired Refresh Token
540
+
541
+ **Steps:**
542
+ 1. Login to application
543
+ 2. Manually expire refresh token (modify timestamp or wait 30 days)
544
+ 3. Try to make API request
545
+ 4. Verify 401 error from backend
546
+ 5. Verify user redirected to login with message
547
+
548
+ **Expected:** Graceful logout with "Session expired" message
549
+
550
+ ### Test Case 3: Multi-Tab Sync
551
+
552
+ **Steps:**
553
+ 1. Open app in Tab A and Tab B
554
+ 2. Login in Tab A
555
+ 3. Verify Tab B receives token update
556
+ 4. Let token expire in Tab A
557
+ 5. Verify Tab B also logs out
558
+
559
+ **Expected:** Both tabs stay synchronized
560
+
561
+ ### Test Case 4: Network Failure During Refresh
562
+
563
+ **Steps:**
564
+ 1. Login to application
565
+ 2. Disable network
566
+ 3. Wait for auto-refresh time
567
+ 4. Verify retry logic activates
568
+ 5. Re-enable network
569
+ 6. Verify refresh succeeds
570
+
571
+ **Expected:** Retry successful, no logout
572
+
573
+ ### Test Case 5: Race Condition (Multiple Refreshes)
574
+
575
+ **Steps:**
576
+ 1. Login to application
577
+ 2. Trigger multiple API calls simultaneously when token is about to expire
578
+ 3. Verify only one refresh request sent
579
+ 4. Verify all pending requests use new token
580
+
581
+ **Expected:** No duplicate refresh calls, all requests succeed
582
+
583
+ ---
584
+
585
+ ## Troubleshooting
586
+
587
+ ### Issue: Users Still Getting Auto-Logged Out
588
+
589
+ **Diagnosis Steps:**
590
+
591
+ 1. **Check localStorage:**
592
+ ```javascript
593
+ console.log('Access Token:', localStorage.getItem('access_token'));
594
+ console.log('Refresh Token:', localStorage.getItem('refresh_token'));
595
+ console.log('Expires At:', localStorage.getItem('expires_at'));
596
+ ```
597
+
598
+ 2. **Check if refresh is scheduled:**
599
+ ```javascript
600
+ // Add to scheduleTokenRefresh function
601
+ console.log('Refresh scheduled for:', new Date(expiresAt).toLocaleString());
602
+ ```
603
+
604
+ 3. **Check backend logs:**
605
+ - Look for `Token refreshed successfully` (success)
606
+ - Look for `Token refresh error` (failure)
607
+ - Check error details for root cause
608
+
609
+ 4. **Verify token rotation:**
610
+ - After refresh, refresh_token should be different
611
+ - If same token returned, rotation may be disabled
612
+
613
+ 5. **Check Supabase settings:**
614
+ - Verify JWT expiry settings in Supabase dashboard
615
+ - Confirm refresh token rotation is enabled
616
+
617
+ ### Common Mistakes
618
+
619
+ ❌ **Mistake 1:** Waiting for 401 before refreshing
620
+ ```javascript
621
+ // BAD - Reactive
622
+ api.interceptors.response.use(null, async (error) => {
623
+ if (error.response.status === 401) {
624
+ await refreshTokens();
625
+ }
626
+ });
627
+ ```
628
+
629
+ ✅ **Fix:** Proactive refresh before expiry
630
+ ```javascript
631
+ // GOOD - Proactive
632
+ scheduleTokenRefresh(expiresAt);
633
+ ```
634
+
635
+ ❌ **Mistake 2:** Not storing new refresh token
636
+ ```javascript
637
+ // BAD - Old token reused
638
+ localStorage.setItem('access_token', data.access_token);
639
+ // Missing: localStorage.setItem('refresh_token', data.refresh_token);
640
+ ```
641
+
642
+ ✅ **Fix:** Always update both tokens
643
+ ```javascript
644
+ // GOOD - Both tokens updated
645
+ localStorage.setItem('access_token', data.access_token);
646
+ localStorage.setItem('refresh_token', data.refresh_token);
647
+ ```
648
+
649
+ ❌ **Mistake 3:** Not handling 401 on refresh endpoint
650
+ ```javascript
651
+ // BAD - Infinite loop
652
+ if (res.status === 401) {
653
+ await refreshTokens(); // Calls itself
654
+ }
655
+ ```
656
+
657
+ ✅ **Fix:** Logout on refresh failure
658
+ ```javascript
659
+ // GOOD - Break loop
660
+ if (res.status === 401) {
661
+ handleLogout();
662
+ }
663
+ ```
664
+
665
+ ### Debug Checklist
666
+
667
+ - [ ] Refresh token is stored in localStorage
668
+ - [ ] scheduleTokenRefresh is called after login
669
+ - [ ] Timeout is set (check browser console)
670
+ - [ ] New tokens stored after refresh
671
+ - [ ] API interceptor configured correctly
672
+ - [ ] 401 errors trigger refresh attempt
673
+ - [ ] Failed refresh triggers logout
674
+ - [ ] Cross-tab sync working (optional but recommended)
675
+ - [ ] Error messages displayed to user
676
+ - [ ] Backend logs show refresh attempts
677
+
678
+ ---
679
+
680
+ ## Best Practices Summary
681
+
682
+ ### DO ✅
683
+
684
+ - **Refresh proactively** 5 minutes before expiry
685
+ - **Store new refresh token** after each refresh
686
+ - **Schedule next refresh** after successful refresh
687
+ - **Synchronize across tabs** using BroadcastChannel
688
+ - **Handle 401 gracefully** with retry logic
689
+ - **Show clear messages** when re-login required
690
+ - **Log all token operations** for debugging
691
+ - **Test token expiry scenarios** thoroughly
692
+
693
+ ### DON'T ❌
694
+
695
+ - **Don't wait for 401** to refresh tokens
696
+ - **Don't ignore new refresh token** from response
697
+ - **Don't refresh on every 401** (check if not already refreshing)
698
+ - **Don't store tokens in cookies** unless using HttpOnly
699
+ - **Don't create database table** for tokens (Supabase handles this)
700
+ - **Don't show technical errors** to users
701
+ - **Don't refresh more than once** for same expiry
702
+ - **Don't forget to clear tokens** on logout
703
+
704
+ ---
705
+
706
+ ## Security Considerations
707
+
708
+ ### Token Storage
709
+
710
+ **LocalStorage (Current Implementation):**
711
+ - ✅ Simple to implement
712
+ - ✅ Persists across page refreshes
713
+ - ❌ Vulnerable to XSS attacks
714
+ - ❌ Accessible to all scripts
715
+
716
+ **HttpOnly Cookies (Recommended for Production):**
717
+ - ✅ Not accessible to JavaScript (XSS protection)
718
+ - ✅ Automatically sent with requests
719
+ - ✅ Can set secure and sameSite flags
720
+ - ❌ Requires backend cookie handling
721
+
722
+ **In-Memory Storage (Most Secure):**
723
+ - ✅ Not vulnerable to XSS
724
+ - ✅ Cleared on page refresh
725
+ - ❌ Poor UX (users logged out on refresh)
726
+ - ❌ Doesn't work with multi-tab sync
727
+
728
+ ### Token Rotation
729
+
730
+ Supabase automatically rotates refresh tokens for security:
731
+ - Each refresh returns a **new refresh token**
732
+ - Old refresh token becomes **invalid**
733
+ - Prevents token replay attacks
734
+ - Reduces impact of stolen tokens
735
+
736
+ ### Monitoring
737
+
738
+ Log these events for security monitoring:
739
+ - Login attempts (success/failure)
740
+ - Token refresh attempts (success/failure)
741
+ - Logout events (user-initiated vs. auto-logout)
742
+ - Multiple failed refresh attempts (potential attack)
743
+ - Token expiry patterns (identify inactive users)
744
+
745
+ ---
746
+
747
+ ## Migration Guide
748
+
749
+ If you're currently experiencing auto-logout issues:
750
+
751
+ ### Phase 1: Immediate Fix (Frontend)
752
+
753
+ 1. Add proactive token refresh scheduler
754
+ 2. Update token after each refresh
755
+ 3. Add 401 error interceptor
756
+ 4. Test with short expiry times
757
+
758
+ ### Phase 2: Enhanced UX
759
+
760
+ 1. Add cross-tab synchronization
761
+ 2. Show clear expiry messages
762
+ 3. Add retry logic for network failures
763
+ 4. Implement token refresh progress indicator
764
+
765
+ ### Phase 3: Production Hardening
766
+
767
+ 1. Switch to HttpOnly cookies (optional)
768
+ 2. Add security monitoring/logging
769
+ 3. Implement token refresh rate limiting
770
+ 4. Add user session management dashboard
771
+
772
+ ---
773
+
774
+ ## Additional Resources
775
+
776
+ - **Supabase Auth Docs:** https://supabase.com/docs/guides/auth
777
+ - **JWT Best Practices:** https://tools.ietf.org/html/rfc8725
778
+ - **Token Refresh Patterns:** https://auth0.com/docs/secure/tokens/refresh-tokens
779
+ - **BroadcastChannel API:** https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
780
+
781
+ ---
782
+
783
+ ## Support
784
+
785
+ If you continue experiencing issues:
786
+
787
+ 1. Enable debug logging in frontend
788
+ 2. Check backend logs for refresh attempts
789
+ 3. Verify Supabase project settings
790
+ 4. Test with curl/Postman to isolate frontend vs. backend issues
791
+ 5. Check for browser extensions blocking requests
792
+
793
+ ---
794
+
795
+ **Last Updated:** November 18, 2025
796
+ **Maintained By:** SwiftOps Backend Team
797
+ **Related Docs:** AUTH_API_COMPLETE.md, GETTING_STARTED_AUTH.md
docs/api/user-profile/APP_MANAGEMENT_SYSTEM.md ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # App Management System Documentation
2
+
3
+ ## Overview
4
+
5
+ The SwiftOps platform uses a centralized **App Management System** that defines all applications, controls role-based access, and manages user favorites. This system ensures consistency between backend permissions and frontend navigation.
6
+
7
+ **Key Features:**
8
+ - Single source of truth for all apps (`app.config.apps`)
9
+ - Role-based access control (8 roles, 22 apps)
10
+ - Rich app metadata (name, icon, route, category, description)
11
+ - User-customizable favorites (max 6 per user)
12
+ - Category-based organization
13
+ - Permission-based access control
14
+
15
+ ---
16
+
17
+ ## Table of Contents
18
+
19
+ 1. [Architecture Overview](#architecture-overview)
20
+ 2. [App Definition](#app-definition)
21
+ 3. [API Endpoints](#api-endpoints)
22
+ 4. [Frontend Integration](#frontend-integration)
23
+ 5. [Adding New Apps](#adding-new-apps)
24
+ 6. [Role Configuration](#role-configuration)
25
+ 7. [Best Practices](#best-practices)
26
+
27
+ ---
28
+
29
+ ## Architecture Overview
30
+
31
+ ### System Components
32
+
33
+ ```
34
+ ┌─────────────────────────────────────────────────────────────┐
35
+ │ app.config.apps.py │
36
+ │ • APPS: Dict of all app definitions (22 apps) │
37
+ │ • ROLE_APP_ACCESS: Role-to-apps mapping (8 roles) │
38
+ │ • DEFAULT_FAVORITE_APPS: Initial favorites per role │
39
+ │ • Helper functions: validate, get, filter │
40
+ └─────────────────────────────────────────────────────────────┘
41
+
42
+
43
+ ┌─────────────────────────────────────────────────────────────┐
44
+ │ API Endpoints │
45
+ │ • GET /api/v1/auth/apps │
46
+ │ Returns all apps with access info │
47
+ │ │
48
+ │ • GET /api/v1/auth/me/preferences/available-apps │
49
+ │ Returns apps user can favorite │
50
+ │ │
51
+ │ • PUT /api/v1/auth/me/preferences │
52
+ │ Update favorite apps (validated against role) │
53
+ └─────────────────────────────────────────────────────────────┘
54
+
55
+
56
+ ┌─────────────────────────────────────────────────────────────┐
57
+ │ Frontend │
58
+ │ • Navigation bar (shows favorite apps) │
59
+ │ • App launcher/drawer (shows all accessible apps) │
60
+ │ • Settings page (customize favorites) │
61
+ │ • Route protection (based on role access) │
62
+ └─────────────────────────────────────────────────────────────┘
63
+ ```
64
+
65
+ ### Data Flow
66
+
67
+ 1. **Backend** defines all apps in `app.config.apps.py`
68
+ 2. **API** exposes apps with role-based filtering
69
+ 3. **Frontend** fetches apps and renders navigation
70
+ 4. **User** customizes favorites (stored in `user_preferences` table)
71
+ 5. **System** validates favorites against role permissions
72
+
73
+ ---
74
+
75
+ ## App Definition
76
+
77
+ ### AppDefinition Class
78
+
79
+ Each app is defined with rich metadata:
80
+
81
+ ```python
82
+ class AppDefinition:
83
+ code: str # Unique identifier (e.g., "dashboard")
84
+ name: str # Display name (e.g., "Dashboard")
85
+ description: str # Brief description
86
+ icon: str # Icon name from icon library
87
+ route: str # Frontend route (e.g., "/dashboard")
88
+ category: AppCategory # Category for grouping
89
+ requires_permission: str # Optional specific permission
90
+ is_active: bool # Whether app is currently available
91
+ ```
92
+
93
+ ### App Categories
94
+
95
+ Apps are organized into 6 categories:
96
+
97
+ | Category | Purpose | Example Apps |
98
+ |----------|---------|--------------|
99
+ | **Core** | Essential platform features | Dashboard, Organizations, Users, Activity |
100
+ | **Operations** | Field operations & ticketing | Tickets, Projects, Maps, Contractors |
101
+ | **Sales** | Sales & customer management | Sales Orders, Customers, Reports |
102
+ | **Team** | Team management & scheduling | Team, Timesheets |
103
+ | **Finance** | Financial management | Payroll, Billing, Expenses |
104
+ | **Settings** | Configuration & support | Settings, Profile, Help, Notifications |
105
+
106
+ ### Example App Definition
107
+
108
+ ```python
109
+ "dashboard": AppDefinition(
110
+ code="dashboard",
111
+ name="Dashboard",
112
+ description="Overview of key metrics and activities",
113
+ icon="dashboard",
114
+ route="/dashboard",
115
+ category=AppCategory.CORE
116
+ )
117
+ ```
118
+
119
+ ---
120
+
121
+ ## API Endpoints
122
+
123
+ ### 1. Get All Apps
124
+
125
+ **Endpoint:** `GET /api/v1/auth/apps`
126
+
127
+ **Description:** Returns all apps in the system with role-based access information.
128
+
129
+ **Authentication:** Required (Bearer token)
130
+
131
+ **Response:**
132
+
133
+ ```json
134
+ {
135
+ "user_role": "platform_admin",
136
+ "total_apps": 22,
137
+ "accessible_apps": 8,
138
+ "apps": [
139
+ {
140
+ "code": "dashboard",
141
+ "name": "Dashboard",
142
+ "description": "Overview of key metrics and activities",
143
+ "icon": "dashboard",
144
+ "route": "/dashboard",
145
+ "category": "core",
146
+ "requires_permission": null,
147
+ "is_active": true,
148
+ "has_access": true
149
+ },
150
+ {
151
+ "code": "payroll",
152
+ "name": "Payroll",
153
+ "description": "Payroll management and processing",
154
+ "icon": "dollar-sign",
155
+ "route": "/payroll",
156
+ "category": "finance",
157
+ "requires_permission": "view_payroll",
158
+ "is_active": true,
159
+ "has_access": false
160
+ }
161
+ // ... 20 more apps
162
+ ],
163
+ "apps_by_category": {
164
+ "core": [...],
165
+ "operations": [...],
166
+ "sales": [...],
167
+ "team": [...],
168
+ "finance": [...],
169
+ "settings": [...]
170
+ },
171
+ "categories": ["core", "operations", "sales", "team", "finance", "settings"]
172
+ }
173
+ ```
174
+
175
+ **Use Cases:**
176
+ - Render main navigation menu
177
+ - Build app launcher/drawer
178
+ - Generate accessible routes for user
179
+ - Show app directory with categories
180
+
181
+ ---
182
+
183
+ ### 2. Get Available Apps for Favorites
184
+
185
+ **Endpoint:** `GET /api/v1/auth/me/preferences/available-apps`
186
+
187
+ **Description:** Returns apps user can add to favorites (role-filtered).
188
+
189
+ **Authentication:** Required (Bearer token)
190
+
191
+ **Response:**
192
+
193
+ ```json
194
+ {
195
+ "role": "platform_admin",
196
+ "current_favorites": ["dashboard", "organizations", "users", "activity"],
197
+ "available_apps": [
198
+ {
199
+ "code": "dashboard",
200
+ "name": "Dashboard",
201
+ "description": "Overview of key metrics and activities",
202
+ "icon": "dashboard",
203
+ "route": "/dashboard",
204
+ "category": "core",
205
+ "requires_permission": null,
206
+ "is_active": true
207
+ },
208
+ // ... 7 more apps user can favorite
209
+ ],
210
+ "default_favorites": ["dashboard", "organizations", "users", "activity"],
211
+ "max_favorites": 6
212
+ }
213
+ ```
214
+
215
+ **Use Cases:**
216
+ - Populate app picker in settings
217
+ - Show which apps can be favorited
218
+ - Validate favorites client-side
219
+
220
+ ---
221
+
222
+ ### 3. Update User Preferences (Including Favorites)
223
+
224
+ **Endpoint:** `PUT /api/v1/auth/me/preferences`
225
+
226
+ **Description:** Update user preferences including favorite apps.
227
+
228
+ **Authentication:** Required (Bearer token)
229
+
230
+ **Request Body:**
231
+
232
+ ```json
233
+ {
234
+ "favorite_apps": ["dashboard", "tickets", "maps", "projects", "reports", "help"]
235
+ }
236
+ ```
237
+
238
+ **Validation Rules:**
239
+ - Maximum 6 apps
240
+ - Apps must be in user's available apps list (role-based)
241
+ - Invalid apps return 400 error with details
242
+
243
+ **Response:** Updated preferences object
244
+
245
+ **Error Example:**
246
+
247
+ ```json
248
+ {
249
+ "detail": "Invalid apps for field_agent: dashboard, organizations. Available apps: tickets, maps, timesheets, profile, expenses, documents, help"
250
+ }
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Frontend Integration
256
+
257
+ ### 1. Fetch All Apps on Login
258
+
259
+ ```javascript
260
+ // After successful login, fetch all apps
261
+ const fetchApps = async () => {
262
+ const res = await api.get('/auth/apps');
263
+ const data = res.data;
264
+
265
+ // Store in state
266
+ dispatch(setApps({
267
+ all: data.apps,
268
+ byCategory: data.apps_by_category,
269
+ accessible: data.apps.filter(app => app.has_access)
270
+ }));
271
+ };
272
+ ```
273
+
274
+ ### 2. Render Navigation with Favorites
275
+
276
+ ```javascript
277
+ // Navigation Bar Component
278
+ const NavigationBar = () => {
279
+ const { preferences } = useAuth();
280
+ const { all: allApps } = useApps();
281
+
282
+ // Get favorite app objects
283
+ const favoriteApps = preferences.favorite_apps
284
+ .map(code => allApps.find(app => app.code === code))
285
+ .filter(app => app && app.has_access);
286
+
287
+ return (
288
+ <nav>
289
+ {favoriteApps.map(app => (
290
+ <NavLink key={app.code} to={app.route}>
291
+ <Icon name={app.icon} />
292
+ <span>{app.name}</span>
293
+ </NavLink>
294
+ ))}
295
+ <AppLauncherButton /> {/* Shows all accessible apps */}
296
+ </nav>
297
+ );
298
+ };
299
+ ```
300
+
301
+ ### 3. App Launcher/Drawer
302
+
303
+ ```javascript
304
+ // App Launcher Component
305
+ const AppLauncher = () => {
306
+ const { accessible: accessibleApps, byCategory } = useApps();
307
+
308
+ return (
309
+ <Drawer>
310
+ <h2>All Apps</h2>
311
+ {Object.entries(byCategory).map(([category, apps]) => (
312
+ <AppCategory key={category} name={category}>
313
+ {apps
314
+ .filter(app => app.has_access)
315
+ .map(app => (
316
+ <AppTile
317
+ key={app.code}
318
+ icon={app.icon}
319
+ name={app.name}
320
+ description={app.description}
321
+ onClick={() => navigate(app.route)}
322
+ />
323
+ ))}
324
+ </AppCategory>
325
+ ))}
326
+ </Drawer>
327
+ );
328
+ };
329
+ ```
330
+
331
+ ### 4. Favorite Apps Customizer
332
+
333
+ ```javascript
334
+ // Settings Page - Favorite Apps Section
335
+ const FavoriteAppsSettings = () => {
336
+ const [availableApps, setAvailableApps] = useState([]);
337
+ const [favorites, setFavorites] = useState([]);
338
+
339
+ useEffect(() => {
340
+ // Fetch available apps
341
+ api.get('/auth/me/preferences/available-apps').then(res => {
342
+ setAvailableApps(res.data.available_apps);
343
+ setFavorites(res.data.current_favorites);
344
+ });
345
+ }, []);
346
+
347
+ const addFavorite = (appCode) => {
348
+ if (favorites.length >= 6) {
349
+ toast.error('Maximum 6 favorites allowed');
350
+ return;
351
+ }
352
+
353
+ const newFavorites = [...favorites, appCode];
354
+ updateFavorites(newFavorites);
355
+ };
356
+
357
+ const removeFavorite = (appCode) => {
358
+ const newFavorites = favorites.filter(code => code !== appCode);
359
+ updateFavorites(newFavorites);
360
+ };
361
+
362
+ const updateFavorites = async (newFavorites) => {
363
+ try {
364
+ await api.put('/auth/me/preferences', {
365
+ favorite_apps: newFavorites
366
+ });
367
+ setFavorites(newFavorites);
368
+ toast.success('Favorites updated');
369
+ } catch (error) {
370
+ toast.error(error.response?.data?.detail || 'Update failed');
371
+ }
372
+ };
373
+
374
+ return (
375
+ <div>
376
+ <h3>Favorite Apps ({favorites.length}/6)</h3>
377
+
378
+ {/* Current Favorites - Draggable to reorder */}
379
+ <DragDropContext onDragEnd={handleReorder}>
380
+ <Droppable droppableId="favorites">
381
+ {availableApps
382
+ .filter(app => favorites.includes(app.code))
383
+ .map((app, index) => (
384
+ <Draggable key={app.code} draggableId={app.code} index={index}>
385
+ <AppCard
386
+ icon={app.icon}
387
+ name={app.name}
388
+ onRemove={() => removeFavorite(app.code)}
389
+ />
390
+ </Draggable>
391
+ ))}
392
+ </Droppable>
393
+ </DragDropContext>
394
+
395
+ {/* Available to Add */}
396
+ <h4>Add Apps</h4>
397
+ <div>
398
+ {availableApps
399
+ .filter(app => !favorites.includes(app.code))
400
+ .map(app => (
401
+ <AppCard
402
+ key={app.code}
403
+ icon={app.icon}
404
+ name={app.name}
405
+ description={app.description}
406
+ onAdd={() => addFavorite(app.code)}
407
+ />
408
+ ))}
409
+ </div>
410
+ </div>
411
+ );
412
+ };
413
+ ```
414
+
415
+ ### 5. Route Protection
416
+
417
+ ```javascript
418
+ // Protected Route Component
419
+ const ProtectedRoute = ({ appCode, children }) => {
420
+ const { accessible: accessibleApps } = useApps();
421
+
422
+ const hasAccess = accessibleApps.some(app => app.code === appCode);
423
+
424
+ if (!hasAccess) {
425
+ return <Navigate to="/unauthorized" />;
426
+ }
427
+
428
+ return children;
429
+ };
430
+
431
+ // Usage in Router
432
+ <Route path="/payroll" element={
433
+ <ProtectedRoute appCode="payroll">
434
+ <PayrollPage />
435
+ </ProtectedRoute>
436
+ } />
437
+ ```
438
+
439
+ ---
440
+
441
+ ## Adding New Apps
442
+
443
+ ### Step 1: Define App in `app.config.apps.py`
444
+
445
+ ```python
446
+ "new_app": AppDefinition(
447
+ code="new_app",
448
+ name="New App",
449
+ description="Description of what this app does",
450
+ icon="icon-name", # From your icon library
451
+ route="/new-app",
452
+ category=AppCategory.OPERATIONS, # or SALES, TEAM, etc.
453
+ requires_permission="view_new_app", # Optional
454
+ is_active=True
455
+ )
456
+ ```
457
+
458
+ ### Step 2: Add to Role Access
459
+
460
+ ```python
461
+ ROLE_APP_ACCESS = {
462
+ "platform_admin": [
463
+ # ... existing apps ...
464
+ "new_app" # Add here
465
+ ],
466
+ "client_admin": [
467
+ # ... existing apps ...
468
+ "new_app" # Add here if this role should access it
469
+ ],
470
+ # ... other roles ...
471
+ }
472
+ ```
473
+
474
+ ### Step 3: (Optional) Add to Default Favorites
475
+
476
+ ```python
477
+ DEFAULT_FAVORITE_APPS = {
478
+ "platform_admin": ["dashboard", "organizations", "users", "new_app"],
479
+ # ... other roles ...
480
+ }
481
+ ```
482
+
483
+ ### Step 4: Frontend Route
484
+
485
+ Create the frontend page and add route:
486
+
487
+ ```javascript
488
+ <Route path="/new-app" element={
489
+ <ProtectedRoute appCode="new_app">
490
+ <NewAppPage />
491
+ </ProtectedRoute>
492
+ } />
493
+ ```
494
+
495
+ **That's it!** The app will automatically:
496
+ - Appear in `/auth/apps` endpoint
497
+ - Be available for favoriting (if role has access)
498
+ - Show up in app launcher
499
+ - Be protected by route guards
500
+
501
+ ---
502
+
503
+ ## Role Configuration
504
+
505
+ ### Current Roles and App Access
506
+
507
+ | Role | Accessible Apps | Default Favorites |
508
+ |------|----------------|-------------------|
509
+ | **Platform Admin** | 8 apps: Dashboard, Organizations, Users, Activity, Settings, Billing, Notifications, Help | Dashboard, Organizations, Users, Activity |
510
+ | **Client Admin** | 10 apps: Dashboard, Projects, Tickets, Team, Sales Orders, Customers, Contractors, Reports, Settings, Help | Dashboard, Projects, Tickets, Team |
511
+ | **Contractor Admin** | 9 apps: Dashboard, Projects, Tickets, Team, Timesheets, Payroll, Reports, Settings, Help | Dashboard, Projects, Tickets, Team |
512
+ | **Sales Manager** | 8 apps: Dashboard, Sales Orders, Customers, Reports, Team, Maps, Settings, Help | Dashboard, Sales Orders, Customers, Reports |
513
+ | **Project Manager** | 8 apps: Dashboard, Projects, Tickets, Team, Reports, Maps, Settings, Help | Dashboard, Projects, Tickets, Team |
514
+ | **Dispatcher** | 8 apps: Dashboard, Tickets, Maps, Team, Projects, Reports, Settings, Help | Dashboard, Tickets, Maps, Team |
515
+ | **Field Agent** | 7 apps: Tickets, Maps, Timesheets, Profile, Expenses, Documents, Help | Tickets, Maps, Timesheets, Profile |
516
+ | **Sales Agent** | 7 apps: Dashboard, Sales Orders, Customers, Maps, Profile, Reports, Help | Dashboard, Sales Orders, Customers, Maps |
517
+
518
+ ### Modifying Role Access
519
+
520
+ To give a role access to a new app:
521
+
522
+ ```python
523
+ ROLE_APP_ACCESS = {
524
+ "field_agent": [
525
+ "tickets", "maps", "timesheets",
526
+ "profile", "expenses", "documents",
527
+ "help",
528
+ "reports" # Add new app here
529
+ ]
530
+ }
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Best Practices
536
+
537
+ ### Backend
538
+
539
+ 1. **Always define apps in `app.config.apps`** - Never hardcode app lists elsewhere
540
+ 2. **Use helper functions** - `validate_apps_for_role()`, `get_available_apps_for_role()`
541
+ 3. **Validate favorites on update** - Prevent users from favoriting apps they can't access
542
+ 4. **Keep categories consistent** - Use predefined `AppCategory` enum
543
+ 5. **Add permissions for sensitive apps** - Use `requires_permission` field
544
+
545
+ ### Frontend
546
+
547
+ 1. **Fetch apps once on login** - Cache in state management (Redux/Zuex/Context)
548
+ 2. **Filter by `has_access`** - Never show apps user can't access
549
+ 3. **Use app metadata** - Don't duplicate names, icons, routes in frontend
550
+ 4. **Respect max favorites (6)** - Validate client-side before API call
551
+ 5. **Handle reordering** - Allow drag-and-drop for favorites
552
+ 6. **Show categories** - Group apps logically in launcher/drawer
553
+ 7. **Protect routes** - Use `ProtectedRoute` component with app codes
554
+
555
+ ### UX Guidelines
556
+
557
+ 1. **Navigation Bar** - Show 4-6 favorite apps (most used)
558
+ 2. **App Launcher** - Show all accessible apps grouped by category
559
+ 3. **Settings Page** - Allow customization with drag-and-drop
560
+ 4. **Tooltips** - Show app descriptions on hover
561
+ 5. **Empty State** - Show helpful message if user has no favorites
562
+ 6. **Max Indicator** - Show "4/6 favorites" counter
563
+ 7. **Disabled Apps** - Grey out apps in launcher if `is_active: false`
564
+
565
+ ---
566
+
567
+ ## Migration Guide
568
+
569
+ If you're migrating from hardcoded app lists:
570
+
571
+ ### Phase 1: Backend Migration
572
+
573
+ 1. Move all app definitions to `app.config.apps.py`
574
+ 2. Update imports in `auth.py` endpoints
575
+ 3. Replace hardcoded lists with helper functions
576
+ 4. Test all preference endpoints
577
+
578
+ ### Phase 2: Frontend Migration
579
+
580
+ 1. Update API calls to use `/auth/apps` endpoint
581
+ 2. Replace hardcoded app arrays with API data
582
+ 3. Update navigation components to use app metadata
583
+ 4. Test favorites customization
584
+
585
+ ### Phase 3: Cleanup
586
+
587
+ 1. Remove old app constants from codebase
588
+ 2. Update documentation
589
+ 3. Remove unused imports
590
+ 4. Add tests for app management
591
+
592
+ ---
593
+
594
+ ## Troubleshooting
595
+
596
+ ### Issue: User can't favorite an app
597
+
598
+ **Check:**
599
+ 1. Is app in `ROLE_APP_ACCESS` for user's role?
600
+ 2. Is app `is_active: true`?
601
+ 3. Has user reached max 6 favorites?
602
+ 4. Is app code spelled correctly (lowercase with underscores)?
603
+
604
+ ### Issue: App shows in launcher but route is 404
605
+
606
+ **Check:**
607
+ 1. Is frontend route defined?
608
+ 2. Does route path match `app.route` in definition?
609
+ 3. Is route wrapped in `ProtectedRoute`?
610
+
611
+ ### Issue: Wrong apps showing for role
612
+
613
+ **Check:**
614
+ 1. User's role in database (check `users.role` field)
615
+ 2. Role exists in `ROLE_APP_ACCESS` dictionary
616
+ 3. App codes are spelled correctly
617
+
618
+ ---
619
+
620
+ ## API Reference Summary
621
+
622
+ | Endpoint | Method | Auth | Purpose |
623
+ |----------|--------|------|---------|
624
+ | `/api/v1/auth/apps` | GET | Required | Get all apps with access info |
625
+ | `/api/v1/auth/me/preferences/available-apps` | GET | Required | Get apps user can favorite |
626
+ | `/api/v1/auth/me/preferences` | GET | Required | Get user preferences |
627
+ | `/api/v1/auth/me/preferences` | PUT | Required | Update user preferences |
628
+
629
+ ---
630
+
631
+ ## Future Enhancements
632
+
633
+ ### Planned Features
634
+
635
+ 1. **App Permissions** - Granular permissions beyond role-based
636
+ 2. **Custom Apps** - Allow organizations to add custom apps
637
+ 3. **App Marketplace** - Third-party app integrations
638
+ 4. **Usage Analytics** - Track which apps users access most
639
+ 5. **Dynamic Icons** - Load icons from CDN/database
640
+ 6. **App Versioning** - Support multiple versions of same app
641
+ 7. **Feature Flags** - Enable/disable apps per organization
642
+ 8. **App Bundles** - Predefined app collections for common use cases
643
+
644
+ ---
645
+
646
+ **Last Updated:** November 18, 2025
647
+ **Maintained By:** SwiftOps Backend Team
648
+ **Related Docs:** USER_PREFERENCES_API.md, AUTH_API_COMPLETE.md
src/app/api/v1/auth.py CHANGED
@@ -15,7 +15,15 @@ from app.schemas.user import (
15
  )
16
  from app.schemas.user_preferences import (
17
  UserPreferencesUpdate, UserPreferencesResponse,
18
- DEFAULT_FAVORITE_APPS, AVAILABLE_APPS, DEFAULT_DASHBOARD_WIDGETS
 
 
 
 
 
 
 
 
19
  )
20
  from app.models.user_preference import UserPreference
21
  from app.models.user import User
@@ -833,9 +841,10 @@ async def get_my_preferences(
833
 
834
  # If no preferences exist, create with role-based defaults
835
  if not preferences:
 
836
  preferences = UserPreference(
837
  user_id=current_user.id,
838
- favorite_apps=DEFAULT_FAVORITE_APPS.get(current_user.role, ['dashboard', 'tickets', 'projects', 'maps']),
839
  dashboard_widgets=DEFAULT_DASHBOARD_WIDGETS.get(current_user.role, ['recent_tickets', 'team_performance', 'sla_metrics']),
840
  theme='light',
841
  language='en'
@@ -843,7 +852,7 @@ async def get_my_preferences(
843
  db.add(preferences)
844
  db.commit()
845
  db.refresh(preferences)
846
- logger.info(f"Created default preferences for user: {current_user.email}")
847
 
848
  return UserPreferencesResponse.from_orm(preferences)
849
 
@@ -868,10 +877,11 @@ async def update_my_preferences(
868
  ).first()
869
 
870
  if not preferences:
871
- # Create new preferences record
 
872
  preferences = UserPreference(
873
  user_id=current_user.id,
874
- favorite_apps=DEFAULT_FAVORITE_APPS.get(current_user.role, []),
875
  dashboard_widgets=DEFAULT_DASHBOARD_WIDGETS.get(current_user.role, [])
876
  )
877
  db.add(preferences)
@@ -893,10 +903,10 @@ async def update_my_preferences(
893
  )
894
 
895
  # Validate against role-specific available apps
896
- available_apps = AVAILABLE_APPS.get(current_user.role, [])
897
- invalid_apps = [app for app in update_data['favorite_apps'] if app not in available_apps]
898
 
899
- if invalid_apps:
 
900
  raise HTTPException(
901
  status_code=status.HTTP_400_BAD_REQUEST,
902
  detail=f"Invalid apps for {current_user.role}: {', '.join(invalid_apps)}. "
@@ -938,15 +948,23 @@ async def update_my_preferences(
938
 
939
 
940
  @router.get("/me/preferences/available-apps", response_model=dict)
941
- async def get_available_apps(
942
  current_user: User = Depends(get_current_active_user),
943
  db: Session = Depends(get_db)
944
  ):
945
  """
946
  Get list of apps available for user to favorite based on their role
947
 
948
- Returns currently favorited apps, all available apps for the role,
949
- and role-based default favorites.
 
 
 
 
 
 
 
 
950
  """
951
  # Get current preferences
952
  preferences = db.query(UserPreference).filter(
@@ -954,12 +972,77 @@ async def get_available_apps(
954
  UserPreference.deleted_at == None
955
  ).first()
956
 
957
- current_favorites = preferences.favorite_apps if preferences else DEFAULT_FAVORITE_APPS.get(current_user.role, [])
 
 
 
 
 
 
 
 
958
 
959
  return {
960
  "role": current_user.role,
961
  "current_favorites": current_favorites,
962
- "available_apps": AVAILABLE_APPS.get(current_user.role, []),
963
- "default_favorites": DEFAULT_FAVORITE_APPS.get(current_user.role, []),
964
  "max_favorites": 6
965
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  )
16
  from app.schemas.user_preferences import (
17
  UserPreferencesUpdate, UserPreferencesResponse,
18
+ DEFAULT_DASHBOARD_WIDGETS
19
+ )
20
+ from app.config.apps import (
21
+ get_available_apps_for_role,
22
+ get_available_app_codes_for_role,
23
+ get_default_favorites_for_role,
24
+ validate_apps_for_role,
25
+ get_app_by_code,
26
+ APPS
27
  )
28
  from app.models.user_preference import UserPreference
29
  from app.models.user import User
 
841
 
842
  # If no preferences exist, create with role-based defaults
843
  if not preferences:
844
+ default_favorites = get_default_favorites_for_role(current_user.role)
845
  preferences = UserPreference(
846
  user_id=current_user.id,
847
+ favorite_apps=default_favorites,
848
  dashboard_widgets=DEFAULT_DASHBOARD_WIDGETS.get(current_user.role, ['recent_tickets', 'team_performance', 'sla_metrics']),
849
  theme='light',
850
  language='en'
 
852
  db.add(preferences)
853
  db.commit()
854
  db.refresh(preferences)
855
+ logger.info(f"Created default preferences for user: {current_user.email} (role: {current_user.role})")
856
 
857
  return UserPreferencesResponse.from_orm(preferences)
858
 
 
877
  ).first()
878
 
879
  if not preferences:
880
+ # Create new preferences record with role-based defaults
881
+ default_favorites = get_default_favorites_for_role(current_user.role)
882
  preferences = UserPreference(
883
  user_id=current_user.id,
884
+ favorite_apps=default_favorites,
885
  dashboard_widgets=DEFAULT_DASHBOARD_WIDGETS.get(current_user.role, [])
886
  )
887
  db.add(preferences)
 
903
  )
904
 
905
  # Validate against role-specific available apps
906
+ is_valid, invalid_apps = validate_apps_for_role(update_data['favorite_apps'], current_user.role)
 
907
 
908
+ if not is_valid:
909
+ available_apps = get_available_app_codes_for_role(current_user.role)
910
  raise HTTPException(
911
  status_code=status.HTTP_400_BAD_REQUEST,
912
  detail=f"Invalid apps for {current_user.role}: {', '.join(invalid_apps)}. "
 
948
 
949
 
950
  @router.get("/me/preferences/available-apps", response_model=dict)
951
+ async def get_available_apps_for_user(
952
  current_user: User = Depends(get_current_active_user),
953
  db: Session = Depends(get_db)
954
  ):
955
  """
956
  Get list of apps available for user to favorite based on their role
957
 
958
+ **Returns:**
959
+ - Current favorite app codes
960
+ - All available apps with full metadata (name, icon, route, etc.)
961
+ - Default favorites for the role
962
+ - Maximum favorites allowed (6)
963
+
964
+ **Use Cases:**
965
+ - Populate app picker in settings UI
966
+ - Show which apps can be added/removed from favorites
967
+ - Display role-appropriate app options with icons and descriptions
968
  """
969
  # Get current preferences
970
  preferences = db.query(UserPreference).filter(
 
972
  UserPreference.deleted_at == None
973
  ).first()
974
 
975
+ # Get current favorites or defaults
976
+ current_favorites = preferences.favorite_apps if preferences else get_default_favorites_for_role(current_user.role)
977
+
978
+ # Get available apps with full metadata
979
+ available_app_objects = get_available_apps_for_role(current_user.role)
980
+ available_apps_detail = [app.to_dict() for app in available_app_objects]
981
+
982
+ # Get default favorites
983
+ default_favorites = get_default_favorites_for_role(current_user.role)
984
 
985
  return {
986
  "role": current_user.role,
987
  "current_favorites": current_favorites,
988
+ "available_apps": available_apps_detail, # Full app metadata
989
+ "default_favorites": default_favorites,
990
  "max_favorites": 6
991
  }
992
+
993
+
994
+ @router.get("/apps", response_model=dict)
995
+ async def get_all_apps_for_user(
996
+ current_user: User = Depends(get_current_active_user)
997
+ ):
998
+ """
999
+ Get ALL apps in the system with role-based access information
1000
+
1001
+ **Returns:**
1002
+ - All apps with full metadata (name, description, icon, route, category)
1003
+ - User's role and which apps they can access
1004
+ - Categorized app groupings (Core, Operations, Sales, Team, Finance, Settings)
1005
+
1006
+ **Use Cases:**
1007
+ - Render main navigation menu with all accessible apps
1008
+ - Show app launcher/drawer with categories
1009
+ - Display app directory or marketplace
1010
+ - Generate sitemap for user's accessible routes
1011
+
1012
+ **Frontend Integration:**
1013
+ - Filter apps by `has_access: true` to show only accessible apps
1014
+ - Group apps by `category` for organized navigation
1015
+ - Use `icon` and `route` to render navigation items
1016
+ - Show disabled state for apps where `has_access: false`
1017
+ """
1018
+ from app.config.apps import get_all_apps, AppCategory
1019
+
1020
+ # Get all apps in the system
1021
+ all_apps = get_all_apps()
1022
+
1023
+ # Get apps user has access to
1024
+ accessible_app_codes = set(get_available_app_codes_for_role(current_user.role))
1025
+
1026
+ # Build response with access information
1027
+ apps_with_access = []
1028
+ for app in all_apps:
1029
+ app_dict = app.to_dict()
1030
+ app_dict['has_access'] = app.code in accessible_app_codes
1031
+ apps_with_access.append(app_dict)
1032
+
1033
+ # Group by category
1034
+ apps_by_category = {}
1035
+ for category in AppCategory:
1036
+ apps_by_category[category.value] = [
1037
+ app for app in apps_with_access
1038
+ if app['category'] == category.value
1039
+ ]
1040
+
1041
+ return {
1042
+ "user_role": current_user.role,
1043
+ "total_apps": len(all_apps),
1044
+ "accessible_apps": len(accessible_app_codes),
1045
+ "apps": apps_with_access,
1046
+ "apps_by_category": apps_by_category,
1047
+ "categories": [cat.value for cat in AppCategory]
1048
+ }
src/app/config/apps.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Application Configuration
3
+ Defines all apps in the system with metadata, permissions, and role-based access
4
+ """
5
+ from typing import Dict, List, Optional
6
+ from enum import Enum
7
+
8
+
9
+ class AppCategory(str, Enum):
10
+ """App categories for organization"""
11
+ CORE = "core"
12
+ OPERATIONS = "operations"
13
+ SALES = "sales"
14
+ TEAM = "team"
15
+ FINANCE = "finance"
16
+ SETTINGS = "settings"
17
+
18
+
19
+ class AppDefinition:
20
+ """
21
+ Application definition with metadata
22
+
23
+ Attributes:
24
+ code: Unique app code (lowercase with underscores)
25
+ name: Display name
26
+ description: Brief description of the app
27
+ icon: Icon name (from your icon library)
28
+ route: Frontend route path
29
+ category: App category for grouping
30
+ requires_permission: Optional specific permission required
31
+ is_active: Whether app is currently available
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ code: str,
37
+ name: str,
38
+ description: str,
39
+ icon: str,
40
+ route: str,
41
+ category: AppCategory,
42
+ requires_permission: Optional[str] = None,
43
+ is_active: bool = True
44
+ ):
45
+ self.code = code
46
+ self.name = name
47
+ self.description = description
48
+ self.icon = icon
49
+ self.route = route
50
+ self.category = category
51
+ self.requires_permission = requires_permission
52
+ self.is_active = is_active
53
+
54
+ def to_dict(self) -> dict:
55
+ """Convert to dictionary for API responses"""
56
+ return {
57
+ "code": self.code,
58
+ "name": self.name,
59
+ "description": self.description,
60
+ "icon": self.icon,
61
+ "route": self.route,
62
+ "category": self.category.value,
63
+ "requires_permission": self.requires_permission,
64
+ "is_active": self.is_active
65
+ }
66
+
67
+
68
+ # ============================================================
69
+ # ALL APPS DEFINITION - Single Source of Truth
70
+ # ============================================================
71
+
72
+ APPS: Dict[str, AppDefinition] = {
73
+ # CORE APPS
74
+ "dashboard": AppDefinition(
75
+ code="dashboard",
76
+ name="Dashboard",
77
+ description="Overview of key metrics and activities",
78
+ icon="dashboard",
79
+ route="/dashboard",
80
+ category=AppCategory.CORE
81
+ ),
82
+
83
+ "organizations": AppDefinition(
84
+ code="organizations",
85
+ name="Organizations",
86
+ description="Manage client and contractor organizations",
87
+ icon="building",
88
+ route="/organizations",
89
+ category=AppCategory.CORE,
90
+ requires_permission="view_organizations"
91
+ ),
92
+
93
+ "users": AppDefinition(
94
+ code="users",
95
+ name="Users",
96
+ description="User management and permissions",
97
+ icon="users",
98
+ route="/users",
99
+ category=AppCategory.CORE,
100
+ requires_permission="view_users"
101
+ ),
102
+
103
+ "activity": AppDefinition(
104
+ code="activity",
105
+ name="Activity Log",
106
+ description="System-wide audit trail and activity monitoring",
107
+ icon="activity",
108
+ route="/activity",
109
+ category=AppCategory.CORE,
110
+ requires_permission="view_audit_logs"
111
+ ),
112
+
113
+ # OPERATIONS
114
+ "tickets": AppDefinition(
115
+ code="tickets",
116
+ name="Tickets",
117
+ description="Service tickets and work orders",
118
+ icon="ticket",
119
+ route="/tickets",
120
+ category=AppCategory.OPERATIONS
121
+ ),
122
+
123
+ "projects": AppDefinition(
124
+ code="projects",
125
+ name="Projects",
126
+ description="Project management and tracking",
127
+ icon="folder",
128
+ route="/projects",
129
+ category=AppCategory.OPERATIONS
130
+ ),
131
+
132
+ "maps": AppDefinition(
133
+ code="maps",
134
+ name="Maps",
135
+ description="Field operations map view",
136
+ icon="map",
137
+ route="/map",
138
+ category=AppCategory.OPERATIONS
139
+ ),
140
+
141
+ "contractors": AppDefinition(
142
+ code="contractors",
143
+ name="Contractors",
144
+ description="Contractor management and assignments",
145
+ icon="hard-hat",
146
+ route="/contractors",
147
+ category=AppCategory.OPERATIONS,
148
+ requires_permission="view_contractors"
149
+ ),
150
+
151
+ # SALES
152
+ "sales_orders": AppDefinition(
153
+ code="sales_orders",
154
+ name="Sales Orders",
155
+ description="Sales order management and tracking",
156
+ icon="shopping-cart",
157
+ route="/sales-orders",
158
+ category=AppCategory.SALES,
159
+ requires_permission="view_sales_orders"
160
+ ),
161
+
162
+ "customers": AppDefinition(
163
+ code="customers",
164
+ name="Customers",
165
+ description="Customer relationship management",
166
+ icon="users",
167
+ route="/customers",
168
+ category=AppCategory.SALES,
169
+ requires_permission="view_customers"
170
+ ),
171
+
172
+ "reports": AppDefinition(
173
+ code="reports",
174
+ name="Reports",
175
+ description="Analytics and reporting",
176
+ icon="chart-bar",
177
+ route="/reports",
178
+ category=AppCategory.OPERATIONS
179
+ ),
180
+
181
+ # TEAM MANAGEMENT
182
+ "team": AppDefinition(
183
+ code="team",
184
+ name="Team",
185
+ description="Team member management and scheduling",
186
+ icon="users",
187
+ route="/team",
188
+ category=AppCategory.TEAM
189
+ ),
190
+
191
+ "timesheets": AppDefinition(
192
+ code="timesheets",
193
+ name="Timesheets",
194
+ description="Time tracking and attendance",
195
+ icon="clock",
196
+ route="/timesheets",
197
+ category=AppCategory.TEAM
198
+ ),
199
+
200
+ "payroll": AppDefinition(
201
+ code="payroll",
202
+ name="Payroll",
203
+ description="Payroll management and processing",
204
+ icon="dollar-sign",
205
+ route="/payroll",
206
+ category=AppCategory.FINANCE,
207
+ requires_permission="view_payroll"
208
+ ),
209
+
210
+ # PERSONAL
211
+ "profile": AppDefinition(
212
+ code="profile",
213
+ name="My Profile",
214
+ description="Personal profile and settings",
215
+ icon="user",
216
+ route="/profile",
217
+ category=AppCategory.SETTINGS
218
+ ),
219
+
220
+ "expenses": AppDefinition(
221
+ code="expenses",
222
+ name="Expenses",
223
+ description="Expense reporting and reimbursement",
224
+ icon="receipt",
225
+ route="/expenses",
226
+ category=AppCategory.FINANCE
227
+ ),
228
+
229
+ "documents": AppDefinition(
230
+ code="documents",
231
+ name="Documents",
232
+ description="Document management and storage",
233
+ icon="file-text",
234
+ route="/documents",
235
+ category=AppCategory.OPERATIONS
236
+ ),
237
+
238
+ # SETTINGS & ADMIN
239
+ "settings": AppDefinition(
240
+ code="settings",
241
+ name="Settings",
242
+ description="Application settings and preferences",
243
+ icon="settings",
244
+ route="/settings",
245
+ category=AppCategory.SETTINGS
246
+ ),
247
+
248
+ "billing": AppDefinition(
249
+ code="billing",
250
+ name="Billing",
251
+ description="Billing and subscription management",
252
+ icon="credit-card",
253
+ route="/billing",
254
+ category=AppCategory.FINANCE,
255
+ requires_permission="view_billing"
256
+ ),
257
+
258
+ "notifications": AppDefinition(
259
+ code="notifications",
260
+ name="Notifications",
261
+ description="Notification center and alerts",
262
+ icon="bell",
263
+ route="/notifications",
264
+ category=AppCategory.CORE
265
+ ),
266
+
267
+ "help": AppDefinition(
268
+ code="help",
269
+ name="Help & Support",
270
+ description="Documentation and support resources",
271
+ icon="help-circle",
272
+ route="/help",
273
+ category=AppCategory.SETTINGS
274
+ ),
275
+ }
276
+
277
+
278
+ # ============================================================
279
+ # ROLE-BASED ACCESS - Defines which apps each role can access
280
+ # ============================================================
281
+
282
+ ROLE_APP_ACCESS: Dict[str, List[str]] = {
283
+ "platform_admin": [
284
+ # Core admin apps
285
+ "dashboard", "organizations", "users", "activity",
286
+ # Management & settings
287
+ "settings", "billing", "notifications", "help"
288
+ ],
289
+
290
+ "client_admin": [
291
+ # Core operations
292
+ "dashboard", "projects", "tickets", "team",
293
+ # Sales & customers
294
+ "sales_orders", "customers",
295
+ # Contractor management
296
+ "contractors",
297
+ # Analytics & settings
298
+ "reports", "settings", "help"
299
+ ],
300
+
301
+ "contractor_admin": [
302
+ # Core operations
303
+ "dashboard", "projects", "tickets", "team",
304
+ # Time & payroll
305
+ "timesheets", "payroll",
306
+ # Analytics & settings
307
+ "reports", "settings", "help"
308
+ ],
309
+
310
+ "sales_manager": [
311
+ # Sales focus
312
+ "dashboard", "sales_orders", "customers",
313
+ # Operations support
314
+ "team", "maps", "reports",
315
+ # Settings
316
+ "settings", "help"
317
+ ],
318
+
319
+ "project_manager": [
320
+ # Project operations
321
+ "dashboard", "projects", "tickets", "team",
322
+ # Field operations
323
+ "maps", "reports",
324
+ # Settings
325
+ "settings", "help"
326
+ ],
327
+
328
+ "dispatcher": [
329
+ # Dispatch operations
330
+ "dashboard", "tickets", "maps", "team",
331
+ # Operations support
332
+ "projects", "reports",
333
+ # Settings
334
+ "settings", "help"
335
+ ],
336
+
337
+ "field_agent": [
338
+ # Field operations
339
+ "tickets", "maps", "timesheets",
340
+ # Personal
341
+ "profile", "expenses", "documents",
342
+ # Support
343
+ "help"
344
+ ],
345
+
346
+ "sales_agent": [
347
+ # Sales operations
348
+ "dashboard", "sales_orders", "customers", "maps",
349
+ # Personal
350
+ "profile", "reports",
351
+ # Support
352
+ "help"
353
+ ]
354
+ }
355
+
356
+
357
+ # ============================================================
358
+ # DEFAULT FAVORITE APPS - Initial favorites for each role
359
+ # ============================================================
360
+
361
+ DEFAULT_FAVORITE_APPS: Dict[str, List[str]] = {
362
+ "platform_admin": ["dashboard", "organizations", "users", "activity"],
363
+ "client_admin": ["dashboard", "projects", "tickets", "team"],
364
+ "contractor_admin": ["dashboard", "projects", "tickets", "team"],
365
+ "sales_manager": ["dashboard", "sales_orders", "customers", "reports"],
366
+ "project_manager": ["dashboard", "projects", "tickets", "team"],
367
+ "dispatcher": ["dashboard", "tickets", "maps", "team"],
368
+ "field_agent": ["tickets", "maps", "timesheets", "profile"],
369
+ "sales_agent": ["dashboard", "sales_orders", "customers", "maps"]
370
+ }
371
+
372
+
373
+ # ============================================================
374
+ # HELPER FUNCTIONS
375
+ # ============================================================
376
+
377
+ def get_app_by_code(app_code: str) -> Optional[AppDefinition]:
378
+ """Get app definition by code"""
379
+ return APPS.get(app_code)
380
+
381
+
382
+ def get_apps_by_codes(app_codes: List[str]) -> List[AppDefinition]:
383
+ """Get multiple app definitions by codes"""
384
+ return [APPS[code] for code in app_codes if code in APPS]
385
+
386
+
387
+ def get_available_apps_for_role(role: str) -> List[AppDefinition]:
388
+ """
389
+ Get all apps available for a specific role
390
+
391
+ Args:
392
+ role: User role (e.g., 'platform_admin', 'field_agent')
393
+
394
+ Returns:
395
+ List of AppDefinition objects for the role
396
+ """
397
+ app_codes = ROLE_APP_ACCESS.get(role, [])
398
+ return get_apps_by_codes(app_codes)
399
+
400
+
401
+ def get_available_app_codes_for_role(role: str) -> List[str]:
402
+ """Get list of app codes available for a role"""
403
+ return ROLE_APP_ACCESS.get(role, [])
404
+
405
+
406
+ def get_default_favorites_for_role(role: str) -> List[str]:
407
+ """Get default favorite app codes for a role"""
408
+ return DEFAULT_FAVORITE_APPS.get(role, [])
409
+
410
+
411
+ def validate_apps_for_role(app_codes: List[str], role: str) -> tuple[bool, List[str]]:
412
+ """
413
+ Validate that app codes are available for the given role
414
+
415
+ Args:
416
+ app_codes: List of app codes to validate
417
+ role: User role
418
+
419
+ Returns:
420
+ Tuple of (is_valid, invalid_apps)
421
+ - is_valid: True if all apps are valid for role
422
+ - invalid_apps: List of invalid app codes
423
+ """
424
+ available_apps = set(get_available_app_codes_for_role(role))
425
+ invalid_apps = [code for code in app_codes if code not in available_apps]
426
+
427
+ return (len(invalid_apps) == 0, invalid_apps)
428
+
429
+
430
+ def get_apps_by_category(category: AppCategory) -> List[AppDefinition]:
431
+ """Get all apps in a specific category"""
432
+ return [app for app in APPS.values() if app.category == category]
433
+
434
+
435
+ def get_all_app_codes() -> List[str]:
436
+ """Get list of all app codes in the system"""
437
+ return list(APPS.keys())
438
+
439
+
440
+ def get_all_apps() -> List[AppDefinition]:
441
+ """Get list of all app definitions"""
442
+ return list(APPS.values())
src/app/schemas/user_preferences.py CHANGED
@@ -144,57 +144,8 @@ class UserPreferencesResponse(BaseModel):
144
  }
145
 
146
 
147
- # Default favorite apps by role (using database app codes - lowercase with underscores)
148
- DEFAULT_FAVORITE_APPS = {
149
- "platform_admin": ["dashboard", "organizations", "users", "activity"],
150
- "client_admin": ["dashboard", "projects", "tickets", "team"],
151
- "contractor_admin": ["dashboard", "projects", "tickets", "team"],
152
- "sales_manager": ["dashboard", "sales_orders", "customers", "reports"],
153
- "project_manager": ["dashboard", "projects", "tickets", "team"],
154
- "dispatcher": ["dashboard", "tickets", "maps", "team"],
155
- "field_agent": ["tickets", "maps", "timesheets", "profile"],
156
- "sales_agent": ["dashboard", "sales_orders", "customers", "maps"]
157
- }
158
-
159
- # Available apps by role (what they can favorite - max 6)
160
- AVAILABLE_APPS = {
161
- "platform_admin": [
162
- # Core apps
163
- "dashboard", "organizations", "users", "activity",
164
- # Management
165
- "settings", "billing", "notifications", "help"
166
- ],
167
- "client_admin": [
168
- "dashboard", "projects", "tickets", "team", "sales_orders",
169
- "customers", "contractors", "reports", "settings", "help"
170
- ],
171
- "contractor_admin": [
172
- "dashboard", "projects", "tickets", "team", "timesheets",
173
- "payroll", "reports", "settings", "help"
174
- ],
175
- "sales_manager": [
176
- "dashboard", "sales_orders", "customers", "reports",
177
- "team", "maps", "settings", "help"
178
- ],
179
- "project_manager": [
180
- "dashboard", "projects", "tickets", "team", "reports",
181
- "maps", "settings", "help"
182
- ],
183
- "dispatcher": [
184
- "dashboard", "tickets", "maps", "team", "projects",
185
- "reports", "settings", "help"
186
- ],
187
- "field_agent": [
188
- "tickets", "maps", "timesheets", "profile", "expenses",
189
- "documents", "help"
190
- ],
191
- "sales_agent": [
192
- "dashboard", "sales_orders", "customers", "maps",
193
- "profile", "reports", "help"
194
- ]
195
- }
196
-
197
  # Available dashboard widgets by role
 
198
  DEFAULT_DASHBOARD_WIDGETS = {
199
  "platform_admin": ["recent_tickets", "team_performance", "sla_metrics", "organizations_overview"],
200
  "client_admin": ["recent_tickets", "team_performance", "sla_metrics", "project_status"],
 
144
  }
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  # Available dashboard widgets by role
148
+ # Note: DEFAULT_FAVORITE_APPS and AVAILABLE_APPS moved to app.config.apps
149
  DEFAULT_DASHBOARD_WIDGETS = {
150
  "platform_admin": ["recent_tickets", "team_performance", "sla_metrics", "organizations_overview"],
151
  "client_admin": ["recent_tickets", "team_performance", "sla_metrics", "project_status"],