mbkauthe 4.1.4 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/api.md +182 -0
- package/docs/db.md +34 -0
- package/docs/db.sql +20 -4
- package/docs/error-messages.md +10 -0
- package/index.d.ts +21 -0
- package/lib/config/tokenScopes.js +59 -0
- package/lib/middleware/auth.js +87 -37
- package/lib/middleware/scopeValidator.js +32 -0
- package/lib/routes/misc.js +6 -0
- package/lib/utils/errors.js +7 -1
- package/package.json +1 -1
- package/views/header.handlebars +2 -100
- package/views/profilemenu.handlebars +187 -0
- package/views/sharedStyles.handlebars +0 -90
package/docs/api.md
CHANGED
|
@@ -41,11 +41,15 @@ Authorization: Bearer <your_api_token>
|
|
|
41
41
|
- **Stateless:** Validates against the `ApiTokens` table on every request.
|
|
42
42
|
- **Expiration:** Tokens can have an optional expiration date.
|
|
43
43
|
- **Permissions:** API tokens inherit the permissions of the user who created them.
|
|
44
|
+
- **Scopes:** Tokens have a scope (`read-only` or `write`) that controls which HTTP methods are allowed:
|
|
45
|
+
- `read-only`: Only GET, HEAD, and OPTIONS requests (safe, read-only operations)
|
|
46
|
+
- `write`: All HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
|
|
44
47
|
- **Usage Tracking:** The system updates the `LastUsed` timestamp on every successful request.
|
|
45
48
|
|
|
46
49
|
**Errors:**
|
|
47
50
|
- `401 Unauthorized` (Code 1005: `INVALID_AUTH_TOKEN`): Token is malformed or not found.
|
|
48
51
|
- `401 Unauthorized` (Code 1006: `API_TOKEN_EXPIRED`): Token exists but has passed its expiration date.
|
|
52
|
+
- `403 Forbidden` (Code 1007: `TOKEN_SCOPE_INSUFFICIENT`): Token scope doesn't allow this HTTP method.
|
|
49
53
|
|
|
50
54
|
**Example Usage:**
|
|
51
55
|
|
|
@@ -424,6 +428,184 @@ fetch('/mbkauthe/api/logout', {
|
|
|
424
428
|
|
|
425
429
|
### Multi-Account Endpoints
|
|
426
430
|
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
#### Token Management Endpoints
|
|
434
|
+
|
|
435
|
+
##### `POST /mbkauthe/api/token`
|
|
436
|
+
|
|
437
|
+
Create a new API token for the authenticated user.
|
|
438
|
+
|
|
439
|
+
**Rate Limit:** 10 requests per minute
|
|
440
|
+
|
|
441
|
+
**Authentication:** Session required (cookie or Bearer token)
|
|
442
|
+
|
|
443
|
+
**Request Body:**
|
|
444
|
+
```json
|
|
445
|
+
{
|
|
446
|
+
"name": "string (required, 1-255 chars, friendly name for the token)",
|
|
447
|
+
"expiresDays": "number (optional, 1-365, default 90)",
|
|
448
|
+
"scope": "string (optional, 'read-only' or 'write', default 'read-only')",
|
|
449
|
+
"allowedApps": "array (optional, app names or ['*'] for all apps)"
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
**Token Scopes:**
|
|
454
|
+
- `read-only`: Allows only read operations (GET, HEAD, OPTIONS methods)
|
|
455
|
+
- `write`: Allows all operations (GET, POST, PUT, DELETE, PATCH, etc.)
|
|
456
|
+
|
|
457
|
+
**Token Application Access:**
|
|
458
|
+
- Omit `allowedApps`: Token inherits from user's allowed apps
|
|
459
|
+
- `["app1", "app2"]`: Token restricted to specific apps (must be subset of user's apps for non-SuperAdmin)
|
|
460
|
+
- `["*"]`: All user's apps (non-SuperAdmin) or all system apps (SuperAdmin only)
|
|
461
|
+
- **SuperAdmin bypass**: SuperAdmin tokens work on any app regardless of `allowedApps` configuration
|
|
462
|
+
|
|
463
|
+
**Success Response (201 Created):**
|
|
464
|
+
```json
|
|
465
|
+
{
|
|
466
|
+
"success": true,
|
|
467
|
+
"token": "mbk_7f83a92b1dc4e5a6f89b012c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e",
|
|
468
|
+
"tokenId": 42,
|
|
469
|
+
"prefix": "mbk_7f83a92",
|
|
470
|
+
"name": "My API Token",
|
|
471
|
+
"scope": "read-only",
|
|
472
|
+
"allowedApps": ["App1", "App2"],
|
|
473
|
+
"expiresAt": "2025-04-27T12:34:56.000Z",
|
|
474
|
+
"createdAt": "2025-01-27T12:34:56.000Z",
|
|
475
|
+
"message": "Token created successfully. Save it now - it won't be shown again."
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
**Error Responses:**
|
|
480
|
+
|
|
481
|
+
| Status Code | Error Code | Message |
|
|
482
|
+
|------------|------------|---------|
|
|
483
|
+
| 400 | MISSING_REQUIRED_FIELD | Token name is required (1-255 characters) |
|
|
484
|
+
| 400 | MISSING_REQUIRED_FIELD | expiresDays must be between 1 and 365 |
|
|
485
|
+
| 400 | MISSING_REQUIRED_FIELD | Invalid scope. Available scopes: read-only, write |
|
|
486
|
+
| 400 | MISSING_REQUIRED_FIELD | allowedApps must be an array |
|
|
487
|
+
| 401 | SESSION_NOT_FOUND | Not authenticated |
|
|
488
|
+
| 403 | INSUFFICIENT_PERMISSIONS | Only SuperAdmin can create tokens with '*' (all apps) access |
|
|
489
|
+
| 403 | INSUFFICIENT_PERMISSIONS | You don't have access to app 'X' |
|
|
490
|
+
| 500 | INTERNAL_SERVER_ERROR | Internal Server Error |
|
|
491
|
+
|
|
492
|
+
**Example Requests:**
|
|
493
|
+
|
|
494
|
+
*Create a read-only token for specific apps:*
|
|
495
|
+
```javascript
|
|
496
|
+
const response = await fetch('/mbkauthe/api/token', {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: {
|
|
499
|
+
'Content-Type': 'application/json',
|
|
500
|
+
'Authorization': 'Bearer mbk_existing_token...' // or use session cookie
|
|
501
|
+
},
|
|
502
|
+
body: JSON.stringify({
|
|
503
|
+
name: 'CI/CD Pipeline Token',
|
|
504
|
+
expiresDays: 30,
|
|
505
|
+
scope: 'read-only',
|
|
506
|
+
allowedApps: ['App1', 'App2']
|
|
507
|
+
})
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
*Create a write token with inherited app access:*
|
|
512
|
+
```javascript
|
|
513
|
+
const response = await fetch('/mbkauthe/api/token', {
|
|
514
|
+
method: 'POST',
|
|
515
|
+
headers: { 'Content-Type': 'application/json' },
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
name: 'Admin API Token',
|
|
518
|
+
scope: 'write'
|
|
519
|
+
// allowedApps omitted - inherits from user
|
|
520
|
+
})
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
##### `GET /mbkauthe/api/tokens`
|
|
527
|
+
|
|
528
|
+
List all API tokens for the authenticated user (token value not included).
|
|
529
|
+
|
|
530
|
+
**Rate Limit:** 10 requests per minute
|
|
531
|
+
|
|
532
|
+
**Authentication:** Session required (cookie or Bearer token)
|
|
533
|
+
|
|
534
|
+
**Success Response (200 OK):**
|
|
535
|
+
```json
|
|
536
|
+
{
|
|
537
|
+
"success": true,
|
|
538
|
+
"tokens": [
|
|
539
|
+
{
|
|
540
|
+
"id": 42,
|
|
541
|
+
"name": "CI/CD Pipeline Token",
|
|
542
|
+
"prefix": "mbk_7f83a92",
|
|
543
|
+
"scope": "read-only",
|
|
544
|
+
"allowedApps": ["App1", "App2"],
|
|
545
|
+
"lastUsed": "2025-01-27T10:15:30.000Z",
|
|
546
|
+
"createdAt": "2025-01-27T12:34:56.000Z",
|
|
547
|
+
"expiresAt": "2025-04-27T12:34:56.000Z",
|
|
548
|
+
"expired": false
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
"id": 43,
|
|
552
|
+
"name": "Admin API Token",
|
|
553
|
+
"prefix": "mbk_a1b2c3d",
|
|
554
|
+
"scope": "write",
|
|
555
|
+
"allowedApps": null,
|
|
556
|
+
"lastUsed": null,
|
|
557
|
+
"createdAt": "2025-01-26T08:20:15.000Z",
|
|
558
|
+
"expiresAt": null,
|
|
559
|
+
"expired": false
|
|
560
|
+
}
|
|
561
|
+
],
|
|
562
|
+
"count": 2
|
|
563
|
+
}
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
**Note:** `allowedApps: null` means the token inherits from user's allowed apps.
|
|
567
|
+
|
|
568
|
+
**Error Responses:**
|
|
569
|
+
|
|
570
|
+
| Status Code | Error Code | Message |
|
|
571
|
+
|------------|------------|---------|
|
|
572
|
+
| 401 | SESSION_NOT_FOUND | Not authenticated |
|
|
573
|
+
| 500 | INTERNAL_SERVER_ERROR | Internal Server Error |
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
##### `DELETE /mbkauthe/api/token/:id`
|
|
578
|
+
|
|
579
|
+
Revoke (delete) an API token by its ID.
|
|
580
|
+
|
|
581
|
+
**Rate Limit:** 10 requests per minute
|
|
582
|
+
|
|
583
|
+
**Authentication:** Session required (cookie or Bearer token)
|
|
584
|
+
|
|
585
|
+
**URL Parameters:**
|
|
586
|
+
- `id`: Token ID (integer)
|
|
587
|
+
|
|
588
|
+
**Success Response (200 OK):**
|
|
589
|
+
```json
|
|
590
|
+
{
|
|
591
|
+
"success": true,
|
|
592
|
+
"message": "Token revoked successfully"
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Error Responses:**
|
|
597
|
+
|
|
598
|
+
| Status Code | Error Code | Message |
|
|
599
|
+
|------------|------------|---------|
|
|
600
|
+
| 400 | MISSING_REQUIRED_FIELD | Invalid token ID |
|
|
601
|
+
| 401 | SESSION_NOT_FOUND | Not authenticated |
|
|
602
|
+
| 404 | SESSION_NOT_FOUND | Token not found or not owned by you |
|
|
603
|
+
| 500 | INTERNAL_SERVER_ERROR | Internal Server Error |
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
### Multi-Account Endpoints
|
|
608
|
+
|
|
427
609
|
#### `GET /mbkauthe/accounts`
|
|
428
610
|
|
|
429
611
|
Renders the account switching page, allowing users to switch between remembered accounts on the device.
|
package/docs/db.md
CHANGED
|
@@ -78,6 +78,8 @@ CREATE TABLE "ApiTokens" (
|
|
|
78
78
|
"Name" VARCHAR(255) NOT NULL, -- User-provided friendly name
|
|
79
79
|
"TokenHash" VARCHAR(128) NOT NULL UNIQUE, -- Hashed access token (SHA-256)
|
|
80
80
|
"Prefix" VARCHAR(32) NOT NULL, -- First few chars of token for ID
|
|
81
|
+
"Scope" VARCHAR(20) DEFAULT 'read-only', -- Token scope: 'read-only' or 'write'
|
|
82
|
+
"AllowedApps" JSONB DEFAULT NULL, -- Apps this token can access
|
|
81
83
|
"LastUsed" TIMESTAMP WITH TIME ZONE,
|
|
82
84
|
"CreatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
83
85
|
"ExpiresAt" TIMESTAMP WITH TIME ZONE -- Optional expiration
|
|
@@ -87,6 +89,38 @@ CREATE INDEX IF NOT EXISTS idx_apitokens_tokenhash ON "ApiTokens" ("TokenHash");
|
|
|
87
89
|
CREATE INDEX IF NOT EXISTS idx_apitokens_username ON "ApiTokens" ("UserName");
|
|
88
90
|
```
|
|
89
91
|
|
|
92
|
+
**Token Permissions (JSONB):**
|
|
93
|
+
|
|
94
|
+
The `Permissions` column stores both scope and allowed apps in a single JSONB structure for optimal performance:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"scope": "read-only" | "write",
|
|
99
|
+
"allowedApps": null | ["app1", "app2"] | ["*"] | []
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Scope Values:**
|
|
104
|
+
- `read-only`: Allows only safe, read-only HTTP methods (GET, HEAD, OPTIONS)
|
|
105
|
+
- `write`: Allows all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
|
|
106
|
+
|
|
107
|
+
**AllowedApps Values:**
|
|
108
|
+
- `null` (default): Token inherits allowed apps from user's `AllowedApps` in Users table
|
|
109
|
+
- `["app1", "app2"]`: Token is restricted to specific apps (must be subset of user's apps)
|
|
110
|
+
- `["*"]`: Token has access to all user's apps (for non-SuperAdmin) or all apps in system (SuperAdmin only)
|
|
111
|
+
- `[]` (empty array): Token has no app access (effectively disabled)
|
|
112
|
+
|
|
113
|
+
**Note**: SuperAdmin users bypass all app permission checks, so their tokens work on any app regardless of the `allowedApps` value.
|
|
114
|
+
|
|
115
|
+
**Security:**
|
|
116
|
+
- Tokens are stored as SHA-256 hashes (never plain text)
|
|
117
|
+
- Only the prefix (first 11 characters) is stored for identification
|
|
118
|
+
- The full token is only shown once during creation
|
|
119
|
+
- Scope enforcement is applied at the middleware level before route processing
|
|
120
|
+
- App access is validated against both token restrictions and user permissions
|
|
121
|
+
- **SuperAdmin exception**: SuperAdmin users bypass app permission checks - their tokens work on any app regardless of `allowedApps` configuration
|
|
122
|
+
- Wildcard `["*"]` for non-SuperAdmin means "all apps the user has access to", not "all apps in system"
|
|
123
|
+
|
|
90
124
|
## How It Works
|
|
91
125
|
|
|
92
126
|
### Login Flow (GitHub/Google)
|
package/docs/db.sql
CHANGED
|
@@ -137,13 +137,29 @@ INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount"
|
|
|
137
137
|
CREATE TABLE "ApiTokens" (
|
|
138
138
|
"id" SERIAL PRIMARY KEY,
|
|
139
139
|
"UserName" VARCHAR(50) NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
140
|
-
"Name" VARCHAR(255) NOT NULL,
|
|
141
|
-
"TokenHash" VARCHAR(128) NOT NULL UNIQUE,
|
|
142
|
-
"Prefix" VARCHAR(32) NOT NULL,
|
|
140
|
+
"Name" VARCHAR(255) NOT NULL,
|
|
141
|
+
"TokenHash" VARCHAR(128) NOT NULL UNIQUE,
|
|
142
|
+
"Prefix" VARCHAR(32) NOT NULL,
|
|
143
|
+
"Permissions" JSONB NOT NULL DEFAULT '{"scope":"read-only","allowedApps":null}'::jsonb,
|
|
143
144
|
"LastUsed" TIMESTAMP WITH TIME ZONE,
|
|
144
145
|
"CreatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
145
|
-
"ExpiresAt" TIMESTAMP WITH TIME ZONE
|
|
146
|
+
"ExpiresAt" TIMESTAMP WITH TIME ZONE
|
|
146
147
|
);
|
|
147
148
|
|
|
149
|
+
-- Basic indexes
|
|
148
150
|
CREATE INDEX IF NOT EXISTS idx_apitokens_tokenhash ON "ApiTokens" ("TokenHash");
|
|
149
151
|
CREATE INDEX IF NOT EXISTS idx_apitokens_username ON "ApiTokens" ("UserName");
|
|
152
|
+
|
|
153
|
+
-- Performance indexes
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_tokenhash_expires ON "ApiTokens" ("TokenHash", "ExpiresAt") WHERE "ExpiresAt" IS NOT NULL;
|
|
155
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_username_created ON "ApiTokens" ("UserName", "CreatedAt" DESC);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_expires ON "ApiTokens" ("ExpiresAt") WHERE "ExpiresAt" IS NOT NULL;
|
|
157
|
+
|
|
158
|
+
-- JSONB indexes for fast permission queries
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_permissions_gin ON "ApiTokens" USING GIN ("Permissions");
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_permissions_scope ON "ApiTokens" (("Permissions"->>'scope'));
|
|
161
|
+
|
|
162
|
+
-- Data integrity constraints
|
|
163
|
+
ALTER TABLE "ApiTokens" ADD CONSTRAINT chk_apitokens_permissions_scope CHECK ("Permissions"->>'scope' IN ('read-only', 'write'));
|
|
164
|
+
ALTER TABLE "ApiTokens" ADD CONSTRAINT chk_apitokens_name_not_empty CHECK (LENGTH(TRIM("Name")) > 0);
|
|
165
|
+
ALTER TABLE "ApiTokens" ADD CONSTRAINT chk_apitokens_expires_future CHECK ("ExpiresAt" IS NULL OR "ExpiresAt" > "CreatedAt");
|
package/docs/error-messages.md
CHANGED
|
@@ -174,6 +174,16 @@ The provided API token is invalid.
|
|
|
174
174
|
#### `1006 - API_TOKEN_EXPIRED`
|
|
175
175
|
The provided API token has expired.
|
|
176
176
|
|
|
177
|
+
#### `1007 - TOKEN_SCOPE_INSUFFICIENT`
|
|
178
|
+
Token scope doesn't allow the requested operation.
|
|
179
|
+
```javascript
|
|
180
|
+
{
|
|
181
|
+
errorCode: 1007,
|
|
182
|
+
message: "This API token doesn't have permission for this operation.",
|
|
183
|
+
hint: "Use a token with 'write' scope or create a new one with appropriate permissions"
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
177
187
|
### Rate Limiting (1100-1199)
|
|
178
188
|
|
|
179
189
|
#### `1101 - RATE_LIMIT_EXCEEDED`
|
package/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ declare global {
|
|
|
25
25
|
role: 'SuperAdmin' | 'NormalUser' | 'Guest';
|
|
26
26
|
sessionId?: string;
|
|
27
27
|
allowedApps?: string[];
|
|
28
|
+
tokenScope?: 'read-only' | 'write' | null;
|
|
28
29
|
};
|
|
29
30
|
preAuthUser?: {
|
|
30
31
|
id: number;
|
|
@@ -146,6 +147,26 @@ declare module 'mbkauthe' {
|
|
|
146
147
|
updated_at: Date;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// API Token Types
|
|
151
|
+
export type TokenScope = 'read-only' | 'write';
|
|
152
|
+
|
|
153
|
+
export interface TokenPermissions {
|
|
154
|
+
scope: TokenScope;
|
|
155
|
+
allowedApps: string[] | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ApiToken {
|
|
159
|
+
id: number;
|
|
160
|
+
UserName: string;
|
|
161
|
+
Name: string;
|
|
162
|
+
TokenHash: string;
|
|
163
|
+
Prefix: string;
|
|
164
|
+
Permissions: TokenPermissions;
|
|
165
|
+
LastUsed?: Date;
|
|
166
|
+
CreatedAt: Date;
|
|
167
|
+
ExpiresAt?: Date;
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
// API Response Types
|
|
150
171
|
export interface LoginResponse {
|
|
151
172
|
success: boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Token Scope Configuration
|
|
3
|
+
* Defines available scopes and methods for token-based access control
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Available scopes
|
|
7
|
+
export const TOKEN_SCOPES = {
|
|
8
|
+
'read-only': {
|
|
9
|
+
name: 'Read Only',
|
|
10
|
+
description: 'Allows only read operations (GET, HEAD, OPTIONS)',
|
|
11
|
+
allowedMethods: ['GET', 'HEAD', 'OPTIONS']
|
|
12
|
+
},
|
|
13
|
+
'write': {
|
|
14
|
+
name: 'Write (Full Access)',
|
|
15
|
+
description: 'Allows all operations (GET, POST, PUT, DELETE, PATCH, etc.)',
|
|
16
|
+
allowedMethods: '*' // All methods
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SCOPE = 'read-only';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a token scope allows the given HTTP method
|
|
24
|
+
* @param {string} scope - Token scope ('read-only' or 'write')
|
|
25
|
+
* @param {string} method - HTTP method (GET, POST, etc.)
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function canAccessMethod(scope, method) {
|
|
29
|
+
if (!scope || !TOKEN_SCOPES[scope]) return false;
|
|
30
|
+
|
|
31
|
+
const scopeConfig = TOKEN_SCOPES[scope];
|
|
32
|
+
|
|
33
|
+
// Full access scope
|
|
34
|
+
if (scopeConfig.allowedMethods === '*') return true;
|
|
35
|
+
|
|
36
|
+
// Check if method is in allowed list
|
|
37
|
+
return scopeConfig.allowedMethods.includes(method.toUpperCase());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate if a scope is valid
|
|
42
|
+
* @param {string} scope - Scope to validate
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isValidScope(scope) {
|
|
46
|
+
return TOKEN_SCOPES.hasOwnProperty(scope);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get all available scopes with descriptions
|
|
51
|
+
* @returns {Object}
|
|
52
|
+
*/
|
|
53
|
+
export function getAvailableScopes() {
|
|
54
|
+
return Object.entries(TOKEN_SCOPES).map(([key, value]) => ({
|
|
55
|
+
scope: key,
|
|
56
|
+
name: value.name,
|
|
57
|
+
description: value.description
|
|
58
|
+
}));
|
|
59
|
+
}
|
package/lib/middleware/auth.js
CHANGED
|
@@ -4,6 +4,7 @@ import { renderError } from "../utils/response.js";
|
|
|
4
4
|
import { clearSessionCookies, cachedCookieOptions, readAccountListFromCookie } from "../config/cookies.js";
|
|
5
5
|
import { ErrorCodes, createErrorResponse } from "../utils/errors.js";
|
|
6
6
|
import { hashApiToken } from "../config/security.js";
|
|
7
|
+
import { canAccessMethod } from "../config/tokenScopes.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Validates a Bearer token (API Token or Session UUID)
|
|
@@ -21,7 +22,8 @@ async function validateTokenAuthentication(req) {
|
|
|
21
22
|
if (token.startsWith('mbk_')) {
|
|
22
23
|
const tokenHash = hashApiToken(token);
|
|
23
24
|
const tokenQuery = `
|
|
24
|
-
SELECT t.id, t."UserName", t."ExpiresAt",
|
|
25
|
+
SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
|
|
26
|
+
u.id as uid, u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
|
|
25
27
|
FROM "ApiTokens" t
|
|
26
28
|
JOIN "Users" u ON t."UserName" = u."UserName"
|
|
27
29
|
WHERE t."TokenHash" = $1 LIMIT 1
|
|
@@ -29,24 +31,37 @@ async function validateTokenAuthentication(req) {
|
|
|
29
31
|
const tokenResult = await dblogin.query({ name: 'validate-api-token', text: tokenQuery, values: [tokenHash] });
|
|
30
32
|
|
|
31
33
|
if (tokenResult.rows.length === 0) return { error: 'INVALID_TOKEN' };
|
|
32
|
-
|
|
34
|
+
|
|
33
35
|
const row = tokenResult.rows[0];
|
|
34
36
|
if (row.ExpiresAt && new Date(row.ExpiresAt) <= new Date()) return { error: 'TOKEN_EXPIRED' };
|
|
35
37
|
|
|
38
|
+
// Parse permissions from JSONB
|
|
39
|
+
const permissions = row.Permissions || { scope: 'read-only', allowedApps: null };
|
|
40
|
+
const tokenScope = permissions.scope || 'read-only';
|
|
41
|
+
const tokenAllowedApps = permissions.allowedApps;
|
|
42
|
+
|
|
43
|
+
// Determine allowed apps: token-specific takes precedence over user's apps
|
|
44
|
+
let allowedApps = row.user_allowed_apps;
|
|
45
|
+
if (tokenAllowedApps !== null) {
|
|
46
|
+
allowedApps = tokenAllowedApps;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// Update usage
|
|
37
50
|
dblogin.query({
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
text: 'UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1',
|
|
52
|
+
values: [row.id]
|
|
40
53
|
}).catch(e => console.error('[mbkauthe] Failed to update token usage:', e));
|
|
41
54
|
|
|
42
55
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
id: row.uid,
|
|
57
|
+
username: row.UserName,
|
|
58
|
+
fullname: row.FullName,
|
|
59
|
+
role: row.Role,
|
|
60
|
+
sessionId: 'api-token-session',
|
|
61
|
+
allowedApps: allowedApps,
|
|
62
|
+
userAllowedApps: row.user_allowed_apps, // Pass user apps for wildcard validation
|
|
63
|
+
active: row.Active,
|
|
64
|
+
tokenScope: tokenScope
|
|
50
65
|
};
|
|
51
66
|
}
|
|
52
67
|
|
|
@@ -58,37 +73,72 @@ async function validateSession(req, res, next) {
|
|
|
58
73
|
if (req.headers.authorization) {
|
|
59
74
|
try {
|
|
60
75
|
const tokenUser = await validateTokenAuthentication(req);
|
|
61
|
-
|
|
76
|
+
|
|
62
77
|
if (tokenUser && !tokenUser.error) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
if (!tokenUser.active) {
|
|
79
|
+
return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// SuperAdmin bypasses app permission checks
|
|
83
|
+
if (tokenUser.role !== "SuperAdmin") {
|
|
84
|
+
// API tokens must respect their app restrictions for non-SuperAdmin users
|
|
85
|
+
const allowedApps = tokenUser.allowedApps;
|
|
86
|
+
const userAllowedApps = tokenUser.userAllowedApps;
|
|
87
|
+
|
|
88
|
+
// allowedApps should always be an array (never null at this point)
|
|
89
|
+
// If token had null allowedApps, it was already replaced with user's apps in validateTokenAuthentication
|
|
90
|
+
if (!Array.isArray(allowedApps) || allowedApps.length === 0) {
|
|
91
|
+
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if token has access to current app
|
|
95
|
+
const hasWildcard = allowedApps.includes('*');
|
|
96
|
+
const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
|
|
97
|
+
|
|
98
|
+
// If wildcard, check against user's allowed apps (wildcard means "all user's apps", not "all apps")
|
|
99
|
+
if (hasWildcard) {
|
|
100
|
+
const userHasApp = userAllowedApps && Array.isArray(userAllowedApps) &&
|
|
101
|
+
userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
|
|
102
|
+
if (!userHasApp) {
|
|
71
103
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
|
|
72
104
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
} else if (!hasSpecificApp) {
|
|
106
|
+
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Populate session for downstream
|
|
111
|
+
req.session.user = {
|
|
112
|
+
id: tokenUser.id,
|
|
113
|
+
username: tokenUser.username,
|
|
114
|
+
fullname: tokenUser.fullname,
|
|
115
|
+
role: tokenUser.role,
|
|
116
|
+
sessionId: tokenUser.sessionId,
|
|
117
|
+
allowedApps: tokenUser.allowedApps,
|
|
118
|
+
tokenScope: tokenUser.tokenScope || null, // Add scope for token-based auth
|
|
119
|
+
};
|
|
120
|
+
req.userRole = tokenUser.role;
|
|
121
|
+
|
|
122
|
+
// Validate token scope for API token requests
|
|
123
|
+
if (tokenUser.tokenScope) {
|
|
124
|
+
const requestMethod = req.method;
|
|
125
|
+
if (!canAccessMethod(tokenUser.tokenScope, requestMethod)) {
|
|
126
|
+
return res.status(403).json(createErrorResponse(403, ErrorCodes.TOKEN_SCOPE_INSUFFICIENT, {
|
|
127
|
+
message: `Token scope '${tokenUser.tokenScope}' does not allow ${requestMethod} requests`,
|
|
128
|
+
tokenScope: tokenUser.tokenScope,
|
|
129
|
+
requestedMethod: requestMethod,
|
|
130
|
+
hint: 'Use a token with write scope for write operations'
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return next();
|
|
86
136
|
}
|
|
87
|
-
|
|
137
|
+
|
|
88
138
|
// Token provided but invalid (or null if format incorrect)
|
|
89
139
|
let errorCode = ErrorCodes.INVALID_AUTH_TOKEN;
|
|
90
140
|
if (tokenUser && tokenUser.error === 'TOKEN_EXPIRED') {
|
|
91
|
-
|
|
141
|
+
errorCode = ErrorCodes.API_TOKEN_EXPIRED;
|
|
92
142
|
}
|
|
93
143
|
return res.status(401).json(createErrorResponse(401, errorCode));
|
|
94
144
|
|
|
@@ -102,7 +152,7 @@ async function validateSession(req, res, next) {
|
|
|
102
152
|
if (!req.session.user) {
|
|
103
153
|
console.log("[mbkauthe] User not authenticated");
|
|
104
154
|
console.log("[mbkauthe]: ", req.session.user);
|
|
105
|
-
return renderError(res, {
|
|
155
|
+
return renderError(res, req, {
|
|
106
156
|
code: 401,
|
|
107
157
|
error: "Not Logged In",
|
|
108
158
|
message: "You Are Not Logged In. Please Log In To Continue.",
|
|
@@ -365,7 +415,7 @@ export async function reloadSessionUser(req, res) {
|
|
|
365
415
|
|
|
366
416
|
// Sync cookies for client UI (non-httpOnly)
|
|
367
417
|
try {
|
|
368
|
-
|
|
418
|
+
res.cookie('username', req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
369
419
|
res.cookie('fullName', req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
370
420
|
res.cookie('sessionId', req.session.user.sessionId, cachedCookieOptions);
|
|
371
421
|
} catch (cookieErr) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope Validation Middleware
|
|
3
|
+
* Validates HTTP methods against token scopes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { canAccessMethod } from '../config/tokenScopes.js';
|
|
7
|
+
import { ErrorCodes, createErrorResponse } from '../utils/errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Middleware to validate that the token's scope allows the request method
|
|
11
|
+
* Only applies to API token authentication (not session cookies)
|
|
12
|
+
*/
|
|
13
|
+
export function validateTokenScope(req, res, next) {
|
|
14
|
+
// Only validate for API token requests (not cookie-based sessions)
|
|
15
|
+
// Check if this request was authenticated via API token
|
|
16
|
+
if (req.session?.user?.sessionId === 'api-token-session' && req.session?.user?.tokenScope) {
|
|
17
|
+
const tokenScope = req.session.user.tokenScope;
|
|
18
|
+
const requestMethod = req.method;
|
|
19
|
+
|
|
20
|
+
// Check if scope allows this HTTP method
|
|
21
|
+
if (!canAccessMethod(tokenScope, requestMethod)) {
|
|
22
|
+
return res.status(403).json(createErrorResponse(403, ErrorCodes.TOKEN_SCOPE_INSUFFICIENT, {
|
|
23
|
+
message: `Token scope '${tokenScope}' does not allow ${requestMethod} requests`,
|
|
24
|
+
tokenScope,
|
|
25
|
+
requestedMethod: requestMethod,
|
|
26
|
+
hint: 'Use a token with write scope for write operations'
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
next();
|
|
32
|
+
}
|
package/lib/routes/misc.js
CHANGED
|
@@ -186,6 +186,12 @@ router.get('/test', validateSession, LoginLimit, async (req, res) => {
|
|
|
186
186
|
}
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
router.post('/test', validateSession, LoginLimit, async (req, res) => {
|
|
190
|
+
if (req.session?.user) {
|
|
191
|
+
return res.json({ success: true, message: "You are logged in" });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
189
195
|
// API: check current session validity (JSON) — minimal response
|
|
190
196
|
router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
191
197
|
try {
|
package/lib/utils/errors.js
CHANGED
|
@@ -34,6 +34,7 @@ export const ErrorCodes = {
|
|
|
34
34
|
INVALID_TOKEN_FORMAT: 1004,
|
|
35
35
|
INVALID_AUTH_TOKEN: 1005,
|
|
36
36
|
API_TOKEN_EXPIRED: 1006,
|
|
37
|
+
TOKEN_SCOPE_INSUFFICIENT: 1007,
|
|
37
38
|
|
|
38
39
|
// Rate limiting (1100-1199)
|
|
39
40
|
RATE_LIMIT_EXCEEDED: 1101,
|
|
@@ -75,7 +76,7 @@ export const ErrorMessages = {
|
|
|
75
76
|
[ErrorCodes.APP_NOT_AUTHORIZED]: {
|
|
76
77
|
message: "Not authorized for this application",
|
|
77
78
|
userMessage: "You don't have permission to access this application.",
|
|
78
|
-
hint: "
|
|
79
|
+
hint: "This token may not be authorized for the requested app."
|
|
79
80
|
},
|
|
80
81
|
|
|
81
82
|
// 2FA
|
|
@@ -160,6 +161,11 @@ export const ErrorMessages = {
|
|
|
160
161
|
userMessage: "The provided API token has expired.",
|
|
161
162
|
hint: "Please generate a new API token"
|
|
162
163
|
},
|
|
164
|
+
[ErrorCodes.TOKEN_SCOPE_INSUFFICIENT]: {
|
|
165
|
+
message: "Token scope insufficient",
|
|
166
|
+
userMessage: "This API token doesn't have permission for this operation.",
|
|
167
|
+
hint: "Use a token with 'write' scope or create a new one with appropriate permissions"
|
|
168
|
+
},
|
|
163
169
|
|
|
164
170
|
// Rate Limiting
|
|
165
171
|
[ErrorCodes.RATE_LIMIT_EXCEEDED]: {
|
package/package.json
CHANGED
package/views/header.handlebars
CHANGED
|
@@ -13,104 +13,6 @@
|
|
|
13
13
|
<span class="logo-comp">mbktech</span></span>
|
|
14
14
|
</a>
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
{{#if userLoggedIn}}
|
|
18
|
-
<div class="profile-menu">
|
|
19
|
-
<button class="profile-trigger" type="button" aria-expanded="false" aria-haspopup="true">
|
|
20
|
-
<img src="/mbkauthe/user/profilepic?u={{username}}" alt="Profile" class="profile-avatar">
|
|
21
|
-
</button>
|
|
22
|
-
<div class="profile-dropdown" role="menu">
|
|
23
|
-
<a class="profile-item" role="menuitem" title="{{username}}">
|
|
24
|
-
<span>@{{username}}</span>
|
|
25
|
-
</a>
|
|
26
|
-
<a class="profile-item" href="https://portal.mbktech.org/user/settings" role="menuitem"
|
|
27
|
-
title="Settings">
|
|
28
|
-
<i class="fas fa-cog"></i>
|
|
29
|
-
<span>Settings</span>
|
|
30
|
-
</a>
|
|
31
|
-
<a class="profile-item" href="/mbkauthe/accounts" role="menuitem"
|
|
32
|
-
title="Switch or Add another Account">
|
|
33
|
-
<i class="fa fa-user-group"></i>
|
|
34
|
-
<span>Switch account</span>
|
|
35
|
-
</a>
|
|
36
|
-
<button class="profile-item" type="button" data-action="logout" role="menuitem" title="Logout">
|
|
37
|
-
<i class="fas fa-sign-out-alt"></i>
|
|
38
|
-
<span>Logout</span>
|
|
39
|
-
</button>
|
|
40
|
-
</div>
|
|
41
|
-
</div>
|
|
42
|
-
{{else}}
|
|
43
|
-
<a class="btn-login" style="text-decoration: none;" href="/mbkauthe/login"><i
|
|
44
|
-
class="fas fa-sign-in-alt"></i> Login</a>
|
|
45
|
-
{{/if}}
|
|
46
|
-
</div>
|
|
16
|
+
{{> profilemenu}}
|
|
47
17
|
</div>
|
|
48
|
-
</header>
|
|
49
|
-
|
|
50
|
-
<script>
|
|
51
|
-
(() => {
|
|
52
|
-
const trigger = document.querySelector('header .profile-trigger');
|
|
53
|
-
const dropdown = document.querySelector('header .profile-dropdown');
|
|
54
|
-
|
|
55
|
-
if (!trigger || !dropdown) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const closeMenu = () => {
|
|
60
|
-
dropdown.classList.remove('open');
|
|
61
|
-
trigger.setAttribute('aria-expanded', 'false');
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
trigger.addEventListener('click', (event) => {
|
|
65
|
-
event.stopPropagation();
|
|
66
|
-
const isOpen = dropdown.classList.toggle('open');
|
|
67
|
-
trigger.setAttribute('aria-expanded', String(isOpen));
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
document.addEventListener('click', (event) => {
|
|
71
|
-
if (!dropdown.contains(event.target) && !trigger.contains(event.target)) {
|
|
72
|
-
closeMenu();
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
document.addEventListener('keydown', (event) => {
|
|
77
|
-
if (event.key === 'Escape') {
|
|
78
|
-
closeMenu();
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const logoutButton = dropdown.querySelector('[data-action="logout"]');
|
|
83
|
-
if (logoutButton) {
|
|
84
|
-
logoutButton.addEventListener('click', async () => {
|
|
85
|
-
const originalLabel = logoutButton.textContent;
|
|
86
|
-
logoutButton.disabled = true;
|
|
87
|
-
logoutButton.textContent = 'Logging out...';
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
const response = await fetch('/mbkauthe/api/logout', {
|
|
91
|
-
method: 'POST',
|
|
92
|
-
headers: {
|
|
93
|
-
'Content-Type': 'application/json'
|
|
94
|
-
},
|
|
95
|
-
credentials: 'include'
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const result = await response.json().catch(() => ({}));
|
|
99
|
-
|
|
100
|
-
if (response.ok) {
|
|
101
|
-
window.location.reload();
|
|
102
|
-
} else {
|
|
103
|
-
alert(result.message || 'Logout failed. Please try again.');
|
|
104
|
-
}
|
|
105
|
-
} catch (error) {
|
|
106
|
-
console.error('[mbkauthe] Error during logout:', error);
|
|
107
|
-
alert('Logout failed. Please try again.');
|
|
108
|
-
} finally {
|
|
109
|
-
logoutButton.disabled = false;
|
|
110
|
-
logoutButton.textContent = originalLabel;
|
|
111
|
-
closeMenu();
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
})();
|
|
116
|
-
</script>
|
|
18
|
+
</header>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<div class="header-actions">
|
|
2
|
+
{{#if userLoggedIn}}
|
|
3
|
+
<div class="profile-menu">
|
|
4
|
+
<button class="profile-trigger" type="button" aria-expanded="false" aria-haspopup="true">
|
|
5
|
+
<img src="/mbkauthe/user/profilepic?u={{username}}" alt="Profile" class="profile-avatar">
|
|
6
|
+
</button>
|
|
7
|
+
<div class="profile-dropdown" role="menu">
|
|
8
|
+
<a class="profile-item" role="menuitem" title="{{username}}">
|
|
9
|
+
<span>@{{username}}</span>
|
|
10
|
+
</a>
|
|
11
|
+
<a class="profile-item" href="https://portal.mbktech.org/user/settings" role="menuitem" title="Settings">
|
|
12
|
+
<i class="fas fa-cog"></i>
|
|
13
|
+
<span>Settings</span>
|
|
14
|
+
</a>
|
|
15
|
+
<a class="profile-item" href="/mbkauthe/accounts" role="menuitem" title="Switch or Add another Account">
|
|
16
|
+
<i class="fa fa-user-group"></i>
|
|
17
|
+
<span>Switch account</span>
|
|
18
|
+
</a>
|
|
19
|
+
<button class="profile-item" type="button" data-action="logout" role="menuitem" title="Logout">
|
|
20
|
+
<i class="fas fa-sign-out-alt"></i>
|
|
21
|
+
<span>Logout</span>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
{{else}}
|
|
26
|
+
<a class="btn-login" style="text-decoration: none;" href="/mbkauthe/login"><i class="fas fa-sign-in-alt"></i>
|
|
27
|
+
Login</a>
|
|
28
|
+
{{/if}}
|
|
29
|
+
</div>
|
|
30
|
+
<style>
|
|
31
|
+
.header-actions {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: 0.75rem;
|
|
35
|
+
margin-left: auto;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.profile-menu {
|
|
39
|
+
position: relative;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.profile-trigger {
|
|
43
|
+
width: 45px;
|
|
44
|
+
height: 45px;
|
|
45
|
+
border-radius: 999px;
|
|
46
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
47
|
+
background: rgba(255, 255, 255, 0.04);
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
transition: var(--transition);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.profile-trigger:hover {
|
|
56
|
+
border-color: var(--accent);
|
|
57
|
+
box-shadow: 0 10px 30px rgba(0, 184, 148, 0.2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.profile-trigger:focus-visible {
|
|
61
|
+
outline: 2px solid var(--accent);
|
|
62
|
+
outline-offset: 2px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.profile-avatar {
|
|
66
|
+
width: 40px;
|
|
67
|
+
height: 40px;
|
|
68
|
+
border-radius: 999px;
|
|
69
|
+
object-fit: cover;
|
|
70
|
+
background: var(--dark);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.profile-dropdown {
|
|
74
|
+
position: absolute;
|
|
75
|
+
right: 0;
|
|
76
|
+
top: calc(100% + 0.5rem);
|
|
77
|
+
background: var(--darker);
|
|
78
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
79
|
+
border-radius: var(--border-radius);
|
|
80
|
+
min-width: 190px;
|
|
81
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
|
82
|
+
padding: 0;
|
|
83
|
+
opacity: 0;
|
|
84
|
+
visibility: hidden;
|
|
85
|
+
transform: translateY(-6px);
|
|
86
|
+
transition: var(--transition);
|
|
87
|
+
z-index: 1000;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.profile-dropdown.open {
|
|
91
|
+
opacity: 1;
|
|
92
|
+
visibility: visible;
|
|
93
|
+
transform: translateY(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.profile-item {
|
|
97
|
+
width: 100%;
|
|
98
|
+
display: block;
|
|
99
|
+
padding: 0.85rem 1rem;
|
|
100
|
+
color: var(--text);
|
|
101
|
+
background: transparent;
|
|
102
|
+
border: none;
|
|
103
|
+
text-decoration: none;
|
|
104
|
+
text-align: left;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
letter-spacing: 0.01em;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
transition: var(--transition);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.profile-item:hover {
|
|
112
|
+
background: rgba(255, 255, 255, 0.05);
|
|
113
|
+
color: var(--accent);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.profile-item:disabled {
|
|
117
|
+
opacity: 0.6;
|
|
118
|
+
cursor: not-allowed;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
121
|
+
<script>
|
|
122
|
+
(() => {
|
|
123
|
+
const trigger = document.querySelector('header .profile-trigger');
|
|
124
|
+
const dropdown = document.querySelector('header .profile-dropdown');
|
|
125
|
+
|
|
126
|
+
if (!trigger || !dropdown) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const closeMenu = () => {
|
|
131
|
+
dropdown.classList.remove('open');
|
|
132
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
trigger.addEventListener('click', (event) => {
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
const isOpen = dropdown.classList.toggle('open');
|
|
138
|
+
trigger.setAttribute('aria-expanded', String(isOpen));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
document.addEventListener('click', (event) => {
|
|
142
|
+
if (!dropdown.contains(event.target) && !trigger.contains(event.target)) {
|
|
143
|
+
closeMenu();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
document.addEventListener('keydown', (event) => {
|
|
148
|
+
if (event.key === 'Escape') {
|
|
149
|
+
closeMenu();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const logoutButton = dropdown.querySelector('[data-action="logout"]');
|
|
154
|
+
if (logoutButton) {
|
|
155
|
+
logoutButton.addEventListener('click', async () => {
|
|
156
|
+
const originalLabel = logoutButton.textContent;
|
|
157
|
+
logoutButton.disabled = true;
|
|
158
|
+
logoutButton.textContent = 'Logging out...';
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch('/mbkauthe/api/logout', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json'
|
|
165
|
+
},
|
|
166
|
+
credentials: 'include'
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const result = await response.json().catch(() => ({}));
|
|
170
|
+
|
|
171
|
+
if (response.ok) {
|
|
172
|
+
window.location.reload();
|
|
173
|
+
} else {
|
|
174
|
+
alert(result.message || 'Logout failed. Please try again.');
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error('[mbkauthe] Error during logout:', error);
|
|
178
|
+
alert('Logout failed. Please try again.');
|
|
179
|
+
} finally {
|
|
180
|
+
logoutButton.disabled = false;
|
|
181
|
+
logoutButton.textContent = originalLabel;
|
|
182
|
+
closeMenu();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
</script>
|
|
@@ -53,96 +53,6 @@
|
|
|
53
53
|
margin: 0 auto;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
.header-actions {
|
|
57
|
-
display: flex;
|
|
58
|
-
align-items: center;
|
|
59
|
-
gap: 0.75rem;
|
|
60
|
-
margin-left: auto;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
.profile-menu {
|
|
64
|
-
position: relative;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.profile-trigger {
|
|
68
|
-
width: 45px;
|
|
69
|
-
height: 45px;
|
|
70
|
-
border-radius: 999px;
|
|
71
|
-
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
72
|
-
background: rgba(255, 255, 255, 0.04);
|
|
73
|
-
display: flex;
|
|
74
|
-
align-items: center;
|
|
75
|
-
justify-content: center;
|
|
76
|
-
cursor: pointer;
|
|
77
|
-
transition: var(--transition);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.profile-trigger:hover {
|
|
81
|
-
border-color: var(--accent);
|
|
82
|
-
box-shadow: 0 10px 30px rgba(0, 184, 148, 0.2);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
.profile-trigger:focus-visible {
|
|
86
|
-
outline: 2px solid var(--accent);
|
|
87
|
-
outline-offset: 2px;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
.profile-avatar {
|
|
91
|
-
width: 40px;
|
|
92
|
-
height: 40px;
|
|
93
|
-
border-radius: 999px;
|
|
94
|
-
object-fit: cover;
|
|
95
|
-
background: var(--dark);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
.profile-dropdown {
|
|
99
|
-
position: absolute;
|
|
100
|
-
right: 0;
|
|
101
|
-
top: calc(100% + 0.5rem);
|
|
102
|
-
background: var(--darker);
|
|
103
|
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
104
|
-
border-radius: var(--border-radius);
|
|
105
|
-
min-width: 190px;
|
|
106
|
-
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
|
107
|
-
padding: 0;
|
|
108
|
-
opacity: 0;
|
|
109
|
-
visibility: hidden;
|
|
110
|
-
transform: translateY(-6px);
|
|
111
|
-
transition: var(--transition);
|
|
112
|
-
z-index: 1000;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.profile-dropdown.open {
|
|
116
|
-
opacity: 1;
|
|
117
|
-
visibility: visible;
|
|
118
|
-
transform: translateY(0);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
.profile-item {
|
|
122
|
-
width: 100%;
|
|
123
|
-
display: block;
|
|
124
|
-
padding: 0.85rem 1rem;
|
|
125
|
-
color: var(--text);
|
|
126
|
-
background: transparent;
|
|
127
|
-
border: none;
|
|
128
|
-
text-decoration: none;
|
|
129
|
-
text-align: left;
|
|
130
|
-
font-weight: 600;
|
|
131
|
-
letter-spacing: 0.01em;
|
|
132
|
-
cursor: pointer;
|
|
133
|
-
transition: var(--transition);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
.profile-item:hover {
|
|
137
|
-
background: rgba(255, 255, 255, 0.05);
|
|
138
|
-
color: var(--accent);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
.profile-item:disabled {
|
|
142
|
-
opacity: 0.6;
|
|
143
|
-
cursor: not-allowed;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
56
|
.logo {
|
|
147
57
|
display: flex;
|
|
148
58
|
align-items: center;
|