@payez/next-mvp 4.1.9 → 4.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +792 -792
- package/dist/api-handlers/auth/refresh.js +38 -15
- package/dist/auth/better-auth.js +4 -3
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/ensure-fresh-access-token.js +42 -8
- package/dist/lib/idp-fetch.js +25 -9
- package/dist/lib/session-store.d.ts +1 -0
- package/dist/lib/session-store.js +12 -8
- package/dist/lib/token-lifecycle.d.ts +2 -0
- package/dist/lib/token-lifecycle.js +21 -3
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/package.json +893 -893
- package/scripts/dev-broker.ps1 +35 -35
- package/scripts/dev-local.ps1 +45 -45
- package/src/api/auth-handler.ts +548 -548
- package/src/api-handlers/account/change-password.ts +142 -142
- package/src/api-handlers/account/update-phone.ts +79 -79
- package/src/api-handlers/account/verify-email.ts +125 -125
- package/src/api-handlers/account/verify-sms.ts +125 -125
- package/src/api-handlers/admin/analytics.ts +443 -443
- package/src/api-handlers/admin/audit.ts +223 -223
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +251 -251
- package/src/api-handlers/admin/sessions.ts +318 -318
- package/src/api-handlers/admin/site-logs.ts +365 -365
- package/src/api-handlers/admin/users.ts +242 -242
- package/src/api-handlers/admin/vibe-data.ts +324 -324
- package/src/api-handlers/auth/refresh.ts +705 -682
- package/src/api-handlers/auth/signout.ts +199 -199
- package/src/api-handlers/auth/status.ts +20 -20
- package/src/api-handlers/auth/update-session.ts +110 -110
- package/src/api-handlers/auth/verify-code.ts +112 -112
- package/src/api-handlers/session/refresh-viability.ts +36 -36
- package/src/api-handlers/test/force-expire.ts +60 -60
- package/src/auth/auth-decision.ts +229 -229
- package/src/auth/better-auth.ts +467 -466
- package/src/auth/route-config.ts +219 -219
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/utils/idp-client.ts +445 -445
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +143 -143
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +266 -266
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +505 -505
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +414 -414
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/FederatedAuthSection.tsx +95 -95
- package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
- package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
- package/src/components/auth/TraditionalAuthSection.tsx +99 -99
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +97 -97
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +57 -57
- package/src/config/logger.ts +62 -62
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +97 -97
- package/src/hooks/useAvailableProviders.ts +120 -120
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/usePublicAuthSettings.ts +149 -149
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +336 -336
- package/src/index.ts +60 -60
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +178 -178
- package/src/lib/ensure-fresh-access-token.ts +369 -335
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-fetch.ts +26 -10
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/roles.ts +177 -177
- package/src/lib/session-store.ts +4 -1
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-lifecycle.ts +30 -3
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +663 -663
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/pages/admin-login/page.tsx +221 -221
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +456 -456
- package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +234 -234
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
- package/src/pages/security/index.ts +9 -9
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
- package/src/pages/test-env/JwtInspectPage.tsx +456 -456
- package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
- package/src/pages/test-env/TestEnvPage.tsx +118 -118
- package/src/pages/test-env/index.ts +25 -25
- package/src/pages/verify-code/page.tsx +652 -652
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/auth/logout.ts +108 -108
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/settings.ts +75 -75
- package/src/routes/auth/viability.ts +224 -224
- package/src/server/auth-guard.ts +170 -170
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/with-auth.ts +93 -93
- package/src/stores/authStore.ts +1899 -1899
- package/src/templates/instrumentation.ts +41 -41
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +590 -590
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +185 -185
- package/src/vibe/generic.ts +431 -431
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +132 -132
- package/src/vibe/sessions.ts +551 -551
- package/src/vibe/types.ts +577 -577
|
@@ -1,469 +1,469 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Admin Role Modals for @payez/next-mvp
|
|
3
|
-
*
|
|
4
|
-
* - RoleEditorModal: Create/edit roles with permissions
|
|
5
|
-
* - UserRoleAssignerModal: Assign roles to users
|
|
6
|
-
*
|
|
7
|
-
* Security Addendum implemented:
|
|
8
|
-
* - Role name validation (3-50 chars, lowercase alphanumeric + underscore, no payez_ prefix)
|
|
9
|
-
* - Last admin warning
|
|
10
|
-
* - IDP roles read-only
|
|
11
|
-
* - Self-assignment warning
|
|
12
|
-
*
|
|
13
|
-
* @see docs/specs/ROLES_MANAGEMENT_SPEC.md
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
'use client';
|
|
17
|
-
|
|
18
|
-
import { useState, useEffect } from 'react';
|
|
19
|
-
|
|
20
|
-
// ============================================================================
|
|
21
|
-
// TYPES
|
|
22
|
-
// ============================================================================
|
|
23
|
-
|
|
24
|
-
interface PagePermission {
|
|
25
|
-
page_permission_id: number;
|
|
26
|
-
route_pattern: string;
|
|
27
|
-
display_name: string;
|
|
28
|
-
requires_2fa: boolean;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface Role {
|
|
32
|
-
role_id?: number;
|
|
33
|
-
role_name: string;
|
|
34
|
-
display_name?: string;
|
|
35
|
-
description?: string;
|
|
36
|
-
page_permissions?: string[];
|
|
37
|
-
requires_2fa?: boolean;
|
|
38
|
-
is_system_role?: boolean;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface User {
|
|
42
|
-
user_id: number;
|
|
43
|
-
email: string;
|
|
44
|
-
full_name?: string;
|
|
45
|
-
roles: {
|
|
46
|
-
idp: string[];
|
|
47
|
-
app: string[];
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ============================================================================
|
|
52
|
-
// VALIDATION
|
|
53
|
-
// ============================================================================
|
|
54
|
-
|
|
55
|
-
// Role name validation (Security Addendum)
|
|
56
|
-
function validateRoleName(name: string): { valid: boolean; error?: string } {
|
|
57
|
-
if (!name) return { valid: false, error: 'Role name is required' };
|
|
58
|
-
if (name.length < 3) return { valid: false, error: 'Role name must be at least 3 characters' };
|
|
59
|
-
if (name.length > 50) return { valid: false, error: 'Role name must be at most 50 characters' };
|
|
60
|
-
if (!/^[a-z0-9_]+$/.test(name)) return { valid: false, error: 'Only lowercase letters, numbers, and underscores allowed' };
|
|
61
|
-
if (name.startsWith('payez_')) return { valid: false, error: 'Role name cannot start with "payez_"' };
|
|
62
|
-
return { valid: true };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ============================================================================
|
|
66
|
-
// ROLE EDITOR MODAL
|
|
67
|
-
// ============================================================================
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* RoleEditorModal - Create or edit a role
|
|
71
|
-
*/
|
|
72
|
-
export function RoleEditorModal({
|
|
73
|
-
role,
|
|
74
|
-
pagePermissions,
|
|
75
|
-
adminAccessiblePages,
|
|
76
|
-
onSave,
|
|
77
|
-
onClose,
|
|
78
|
-
isDark = true,
|
|
79
|
-
}: {
|
|
80
|
-
role?: Role;
|
|
81
|
-
pagePermissions: PagePermission[];
|
|
82
|
-
adminAccessiblePages?: string[];
|
|
83
|
-
onSave: (data: {
|
|
84
|
-
role_name: string;
|
|
85
|
-
display_name?: string;
|
|
86
|
-
description?: string;
|
|
87
|
-
page_permissions: string[];
|
|
88
|
-
requires_2fa: boolean;
|
|
89
|
-
}) => Promise<void>;
|
|
90
|
-
onClose: () => void;
|
|
91
|
-
isDark?: boolean;
|
|
92
|
-
}) {
|
|
93
|
-
const isEditing = !!role?.role_id;
|
|
94
|
-
|
|
95
|
-
// Form state
|
|
96
|
-
const [roleName, setRoleName] = useState(role?.role_name || '');
|
|
97
|
-
const [displayName, setDisplayName] = useState(role?.display_name || '');
|
|
98
|
-
const [description, setDescription] = useState(role?.description || '');
|
|
99
|
-
const [selectedPermissions, setSelectedPermissions] = useState<string[]>(role?.page_permissions || []);
|
|
100
|
-
const [requires2FA, setRequires2FA] = useState(role?.requires_2fa || false);
|
|
101
|
-
const [error, setError] = useState('');
|
|
102
|
-
const [saving, setSaving] = useState(false);
|
|
103
|
-
|
|
104
|
-
// Role name validation
|
|
105
|
-
const [nameValidation, setNameValidation] = useState<{ valid: boolean; error?: string }>({ valid: true });
|
|
106
|
-
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
if (!isEditing && roleName) {
|
|
109
|
-
setNameValidation(validateRoleName(roleName));
|
|
110
|
-
}
|
|
111
|
-
}, [roleName, isEditing]);
|
|
112
|
-
|
|
113
|
-
// Filter permissions based on admin access (Security Addendum)
|
|
114
|
-
const availablePermissions = adminAccessiblePages
|
|
115
|
-
? pagePermissions.filter((p) =>
|
|
116
|
-
adminAccessiblePages.some((ap) => p.route_pattern.startsWith(ap.replace('*', '')))
|
|
117
|
-
)
|
|
118
|
-
: pagePermissions;
|
|
119
|
-
|
|
120
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
setError('');
|
|
123
|
-
|
|
124
|
-
if (!isEditing) {
|
|
125
|
-
const validation = validateRoleName(roleName);
|
|
126
|
-
if (!validation.valid) {
|
|
127
|
-
setError(validation.error || 'Invalid role name');
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
setSaving(true);
|
|
134
|
-
await onSave({
|
|
135
|
-
role_name: roleName,
|
|
136
|
-
display_name: displayName || undefined,
|
|
137
|
-
description: description || undefined,
|
|
138
|
-
page_permissions: selectedPermissions,
|
|
139
|
-
requires_2fa: requires2FA,
|
|
140
|
-
});
|
|
141
|
-
onClose();
|
|
142
|
-
} catch (err) {
|
|
143
|
-
setError(err instanceof Error ? err.message : 'Failed to save role');
|
|
144
|
-
} finally {
|
|
145
|
-
setSaving(false);
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const togglePermission = (pattern: string) => {
|
|
150
|
-
setSelectedPermissions((prev) =>
|
|
151
|
-
prev.includes(pattern) ? prev.filter((p) => p !== pattern) : [...prev, pattern]
|
|
152
|
-
);
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
// Theme colors
|
|
156
|
-
const modalBg = isDark ? 'bg-slate-800' : 'bg-white';
|
|
157
|
-
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
158
|
-
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
159
|
-
const textMuted = isDark ? 'text-slate-400' : 'text-gray-500';
|
|
160
|
-
const inputBg = isDark ? 'bg-slate-700' : 'bg-white';
|
|
161
|
-
const inputBorder = isDark ? 'border-slate-600' : 'border-gray-300';
|
|
162
|
-
|
|
163
|
-
return (
|
|
164
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
165
|
-
<div className={`w-full max-w-lg mx-4 rounded-lg shadow-xl ${modalBg} border ${borderColor} max-h-[90vh] overflow-hidden flex flex-col`}>
|
|
166
|
-
{/* Header */}
|
|
167
|
-
<div className={`px-6 py-4 border-b ${borderColor} flex items-center justify-between`}>
|
|
168
|
-
<h3 className={`text-lg font-semibold ${textPrimary}`}>
|
|
169
|
-
{isEditing ? `Edit Role: ${role?.role_name}` : 'Create New Role'}
|
|
170
|
-
</h3>
|
|
171
|
-
<button onClick={onClose} className={textMuted}>
|
|
172
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
173
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
174
|
-
</svg>
|
|
175
|
-
</button>
|
|
176
|
-
</div>
|
|
177
|
-
|
|
178
|
-
{/* Form */}
|
|
179
|
-
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
|
180
|
-
<div className="px-6 py-4 space-y-4">
|
|
181
|
-
{/* Role Name */}
|
|
182
|
-
<div>
|
|
183
|
-
<label className={`block text-sm font-medium mb-1 ${textMuted}`}>
|
|
184
|
-
Role Name <span className="text-red-500">*</span>
|
|
185
|
-
</label>
|
|
186
|
-
<input
|
|
187
|
-
type="text"
|
|
188
|
-
value={roleName}
|
|
189
|
-
onChange={(e) => setRoleName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
|
190
|
-
disabled={isEditing}
|
|
191
|
-
className={`w-full px-3 py-2 rounded-md border ${inputBorder} ${inputBg} ${textPrimary} ${isEditing ? 'opacity-50 cursor-not-allowed' : ''} font-mono`}
|
|
192
|
-
placeholder="e.g., content_editor"
|
|
193
|
-
/>
|
|
194
|
-
{!isEditing && roleName && !nameValidation.valid && (
|
|
195
|
-
<p className="mt-1 text-sm text-red-500">{nameValidation.error}</p>
|
|
196
|
-
)}
|
|
197
|
-
{!isEditing && (
|
|
198
|
-
<p className={`mt-1 text-xs ${textMuted}`}>
|
|
199
|
-
3-50 characters, lowercase letters, numbers, underscores only
|
|
200
|
-
</p>
|
|
201
|
-
)}
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
{/* Display Name */}
|
|
205
|
-
<div>
|
|
206
|
-
<label className={`block text-sm font-medium mb-1 ${textMuted}`}>Display Name</label>
|
|
207
|
-
<input
|
|
208
|
-
type="text"
|
|
209
|
-
value={displayName}
|
|
210
|
-
onChange={(e) => setDisplayName(e.target.value)}
|
|
211
|
-
className={`w-full px-3 py-2 rounded-md border ${inputBorder} ${inputBg} ${textPrimary}`}
|
|
212
|
-
placeholder="e.g., Content Editor"
|
|
213
|
-
/>
|
|
214
|
-
</div>
|
|
215
|
-
|
|
216
|
-
{/* Description */}
|
|
217
|
-
<div>
|
|
218
|
-
<label className={`block text-sm font-medium mb-1 ${textMuted}`}>Description</label>
|
|
219
|
-
<textarea
|
|
220
|
-
value={description}
|
|
221
|
-
onChange={(e) => setDescription(e.target.value)}
|
|
222
|
-
rows={2}
|
|
223
|
-
className={`w-full px-3 py-2 rounded-md border ${inputBorder} ${inputBg} ${textPrimary}`}
|
|
224
|
-
placeholder="What does this role do?"
|
|
225
|
-
/>
|
|
226
|
-
</div>
|
|
227
|
-
|
|
228
|
-
{/* Page Permissions */}
|
|
229
|
-
<div>
|
|
230
|
-
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>Page Permissions</label>
|
|
231
|
-
<div className={`border ${borderColor} rounded-md max-h-48 overflow-y-auto`}>
|
|
232
|
-
{availablePermissions.length === 0 ? (
|
|
233
|
-
<p className={`p-3 text-sm ${textMuted}`}>No page permissions available</p>
|
|
234
|
-
) : (
|
|
235
|
-
availablePermissions.map((perm) => (
|
|
236
|
-
<label
|
|
237
|
-
key={perm.page_permission_id}
|
|
238
|
-
className={`flex items-center px-3 py-2 border-b last:border-b-0 ${borderColor} cursor-pointer hover:${isDark ? 'bg-slate-700' : 'bg-gray-50'}`}
|
|
239
|
-
>
|
|
240
|
-
<input
|
|
241
|
-
type="checkbox"
|
|
242
|
-
checked={selectedPermissions.includes(perm.route_pattern)}
|
|
243
|
-
onChange={() => togglePermission(perm.route_pattern)}
|
|
244
|
-
className="mr-3"
|
|
245
|
-
/>
|
|
246
|
-
<div>
|
|
247
|
-
<span className={`font-mono text-sm ${textPrimary}`}>{perm.route_pattern}</span>
|
|
248
|
-
{perm.display_name && (
|
|
249
|
-
<span className={`block text-xs ${textMuted}`}>{perm.display_name}</span>
|
|
250
|
-
)}
|
|
251
|
-
</div>
|
|
252
|
-
</label>
|
|
253
|
-
))
|
|
254
|
-
)}
|
|
255
|
-
</div>
|
|
256
|
-
</div>
|
|
257
|
-
|
|
258
|
-
{/* Security Options */}
|
|
259
|
-
<div className={`border-t ${borderColor} pt-4`}>
|
|
260
|
-
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>Security Options</label>
|
|
261
|
-
<label className="flex items-center cursor-pointer">
|
|
262
|
-
<input
|
|
263
|
-
type="checkbox"
|
|
264
|
-
checked={requires2FA}
|
|
265
|
-
onChange={(e) => setRequires2FA(e.target.checked)}
|
|
266
|
-
className="mr-3"
|
|
267
|
-
/>
|
|
268
|
-
<span className={textPrimary}>Requires 2FA for all pages</span>
|
|
269
|
-
</label>
|
|
270
|
-
</div>
|
|
271
|
-
|
|
272
|
-
{/* Error */}
|
|
273
|
-
{error && (
|
|
274
|
-
<div className="bg-red-900/30 border border-red-600 rounded-lg p-3">
|
|
275
|
-
<p className="text-red-400 text-sm">{error}</p>
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
278
|
-
</div>
|
|
279
|
-
|
|
280
|
-
{/* Footer */}
|
|
281
|
-
<div className={`px-6 py-4 border-t ${borderColor} flex justify-end gap-3`}>
|
|
282
|
-
<button
|
|
283
|
-
type="button"
|
|
284
|
-
onClick={onClose}
|
|
285
|
-
className={`px-4 py-2 rounded-md border ${borderColor} ${textMuted}`}
|
|
286
|
-
>
|
|
287
|
-
Cancel
|
|
288
|
-
</button>
|
|
289
|
-
<button
|
|
290
|
-
type="submit"
|
|
291
|
-
disabled={saving || (!isEditing && !nameValidation.valid)}
|
|
292
|
-
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
293
|
-
>
|
|
294
|
-
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Role'}
|
|
295
|
-
</button>
|
|
296
|
-
</div>
|
|
297
|
-
</form>
|
|
298
|
-
</div>
|
|
299
|
-
</div>
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ============================================================================
|
|
304
|
-
// USER ROLE ASSIGNER MODAL
|
|
305
|
-
// ============================================================================
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* UserRoleAssignerModal - Assign roles to a user
|
|
309
|
-
*/
|
|
310
|
-
export function UserRoleAssignerModal({
|
|
311
|
-
user,
|
|
312
|
-
availableRoles,
|
|
313
|
-
isLastAdmin,
|
|
314
|
-
currentUserId,
|
|
315
|
-
onSave,
|
|
316
|
-
onClose,
|
|
317
|
-
isDark = true,
|
|
318
|
-
}: {
|
|
319
|
-
user: User;
|
|
320
|
-
availableRoles: { role_name: string; display_name?: string; description?: string }[];
|
|
321
|
-
isLastAdmin?: boolean;
|
|
322
|
-
currentUserId?: number;
|
|
323
|
-
onSave: (userId: number, appRoles: string[]) => Promise<void>;
|
|
324
|
-
onClose: () => void;
|
|
325
|
-
isDark?: boolean;
|
|
326
|
-
}) {
|
|
327
|
-
const [selectedRoles, setSelectedRoles] = useState<string[]>(user.roles.app);
|
|
328
|
-
const [saving, setSaving] = useState(false);
|
|
329
|
-
const [error, setError] = useState('');
|
|
330
|
-
|
|
331
|
-
const handleSubmit = async (e: React.FormEvent) => {
|
|
332
|
-
e.preventDefault();
|
|
333
|
-
setError('');
|
|
334
|
-
|
|
335
|
-
// Security: Self-assignment warning
|
|
336
|
-
if (user.user_id === currentUserId) {
|
|
337
|
-
if (!confirm('You are modifying your own roles. Changes take effect on next login. Continue?')) {
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Security Addendum: Last admin warning
|
|
343
|
-
const hadAdmin = user.roles.app.some((r) => r.includes('admin'));
|
|
344
|
-
const hasAdmin = selectedRoles.some((r) => r.includes('admin'));
|
|
345
|
-
|
|
346
|
-
if (hadAdmin && !hasAdmin && isLastAdmin) {
|
|
347
|
-
if (!confirm('Warning: This is the last administrator. Removing admin access will lock out admin access. Continue?')) {
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
setSaving(true);
|
|
354
|
-
await onSave(user.user_id, selectedRoles);
|
|
355
|
-
onClose();
|
|
356
|
-
} catch (err) {
|
|
357
|
-
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
|
358
|
-
} finally {
|
|
359
|
-
setSaving(false);
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
const toggleRole = (roleName: string) => {
|
|
364
|
-
setSelectedRoles((prev) =>
|
|
365
|
-
prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName]
|
|
366
|
-
);
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
// Theme colors
|
|
370
|
-
const modalBg = isDark ? 'bg-slate-800' : 'bg-white';
|
|
371
|
-
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
372
|
-
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
373
|
-
const textMuted = isDark ? 'text-slate-400' : 'text-gray-500';
|
|
374
|
-
|
|
375
|
-
return (
|
|
376
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
377
|
-
<div className={`w-full max-w-md mx-4 rounded-lg shadow-xl ${modalBg} border ${borderColor}`}>
|
|
378
|
-
{/* Header */}
|
|
379
|
-
<div className={`px-6 py-4 border-b ${borderColor} flex items-center justify-between`}>
|
|
380
|
-
<div>
|
|
381
|
-
<h3 className={`text-lg font-semibold ${textPrimary}`}>Edit Roles</h3>
|
|
382
|
-
<p className={`text-sm ${textMuted}`}>{user.full_name || user.email}</p>
|
|
383
|
-
</div>
|
|
384
|
-
<button onClick={onClose} className={textMuted}>
|
|
385
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
386
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
387
|
-
</svg>
|
|
388
|
-
</button>
|
|
389
|
-
</div>
|
|
390
|
-
|
|
391
|
-
{/* Form */}
|
|
392
|
-
<form onSubmit={handleSubmit}>
|
|
393
|
-
<div className="px-6 py-4 space-y-4">
|
|
394
|
-
{/* IDP Roles (read-only) */}
|
|
395
|
-
{user.roles.idp.length > 0 && (
|
|
396
|
-
<div>
|
|
397
|
-
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>
|
|
398
|
-
IDP Roles (read-only)
|
|
399
|
-
<svg className="inline w-4 h-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
400
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
401
|
-
</svg>
|
|
402
|
-
</label>
|
|
403
|
-
<div className="space-y-1">
|
|
404
|
-
{user.roles.idp.map((role) => (
|
|
405
|
-
<div key={role} className={`flex items-center px-3 py-2 rounded ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
|
406
|
-
<span className={textMuted}>{role}</span>
|
|
407
|
-
<span className={`ml-auto text-xs ${textMuted}`}>assigned by IDP</span>
|
|
408
|
-
</div>
|
|
409
|
-
))}
|
|
410
|
-
</div>
|
|
411
|
-
</div>
|
|
412
|
-
)}
|
|
413
|
-
|
|
414
|
-
{/* Application Roles */}
|
|
415
|
-
<div>
|
|
416
|
-
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>Application Roles</label>
|
|
417
|
-
<div className={`border ${borderColor} rounded-md`}>
|
|
418
|
-
{availableRoles.map((role) => (
|
|
419
|
-
<label
|
|
420
|
-
key={role.role_name}
|
|
421
|
-
className={`flex items-start px-3 py-2 border-b last:border-b-0 ${borderColor} cursor-pointer hover:${isDark ? 'bg-slate-700' : 'bg-gray-50'}`}
|
|
422
|
-
>
|
|
423
|
-
<input
|
|
424
|
-
type="checkbox"
|
|
425
|
-
checked={selectedRoles.includes(role.role_name)}
|
|
426
|
-
onChange={() => toggleRole(role.role_name)}
|
|
427
|
-
className="mt-1 mr-3"
|
|
428
|
-
/>
|
|
429
|
-
<div>
|
|
430
|
-
<span className={textPrimary}>{role.display_name || role.role_name}</span>
|
|
431
|
-
{role.description && (
|
|
432
|
-
<span className={`block text-xs ${textMuted}`}>{role.description}</span>
|
|
433
|
-
)}
|
|
434
|
-
</div>
|
|
435
|
-
</label>
|
|
436
|
-
))}
|
|
437
|
-
</div>
|
|
438
|
-
</div>
|
|
439
|
-
|
|
440
|
-
{/* Error */}
|
|
441
|
-
{error && (
|
|
442
|
-
<div className="bg-red-900/30 border border-red-600 rounded-lg p-3">
|
|
443
|
-
<p className="text-red-400 text-sm">{error}</p>
|
|
444
|
-
</div>
|
|
445
|
-
)}
|
|
446
|
-
</div>
|
|
447
|
-
|
|
448
|
-
{/* Footer */}
|
|
449
|
-
<div className={`px-6 py-4 border-t ${borderColor} flex justify-end gap-3`}>
|
|
450
|
-
<button
|
|
451
|
-
type="button"
|
|
452
|
-
onClick={onClose}
|
|
453
|
-
className={`px-4 py-2 rounded-md border ${borderColor} ${textMuted}`}
|
|
454
|
-
>
|
|
455
|
-
Cancel
|
|
456
|
-
</button>
|
|
457
|
-
<button
|
|
458
|
-
type="submit"
|
|
459
|
-
disabled={saving}
|
|
460
|
-
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
461
|
-
>
|
|
462
|
-
{saving ? 'Saving...' : 'Save Changes'}
|
|
463
|
-
</button>
|
|
464
|
-
</div>
|
|
465
|
-
</form>
|
|
466
|
-
</div>
|
|
467
|
-
</div>
|
|
468
|
-
);
|
|
469
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Admin Role Modals for @payez/next-mvp
|
|
3
|
+
*
|
|
4
|
+
* - RoleEditorModal: Create/edit roles with permissions
|
|
5
|
+
* - UserRoleAssignerModal: Assign roles to users
|
|
6
|
+
*
|
|
7
|
+
* Security Addendum implemented:
|
|
8
|
+
* - Role name validation (3-50 chars, lowercase alphanumeric + underscore, no payez_ prefix)
|
|
9
|
+
* - Last admin warning
|
|
10
|
+
* - IDP roles read-only
|
|
11
|
+
* - Self-assignment warning
|
|
12
|
+
*
|
|
13
|
+
* @see docs/specs/ROLES_MANAGEMENT_SPEC.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import { useState, useEffect } from 'react';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// TYPES
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
interface PagePermission {
|
|
25
|
+
page_permission_id: number;
|
|
26
|
+
route_pattern: string;
|
|
27
|
+
display_name: string;
|
|
28
|
+
requires_2fa: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Role {
|
|
32
|
+
role_id?: number;
|
|
33
|
+
role_name: string;
|
|
34
|
+
display_name?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
page_permissions?: string[];
|
|
37
|
+
requires_2fa?: boolean;
|
|
38
|
+
is_system_role?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface User {
|
|
42
|
+
user_id: number;
|
|
43
|
+
email: string;
|
|
44
|
+
full_name?: string;
|
|
45
|
+
roles: {
|
|
46
|
+
idp: string[];
|
|
47
|
+
app: string[];
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// VALIDATION
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
// Role name validation (Security Addendum)
|
|
56
|
+
function validateRoleName(name: string): { valid: boolean; error?: string } {
|
|
57
|
+
if (!name) return { valid: false, error: 'Role name is required' };
|
|
58
|
+
if (name.length < 3) return { valid: false, error: 'Role name must be at least 3 characters' };
|
|
59
|
+
if (name.length > 50) return { valid: false, error: 'Role name must be at most 50 characters' };
|
|
60
|
+
if (!/^[a-z0-9_]+$/.test(name)) return { valid: false, error: 'Only lowercase letters, numbers, and underscores allowed' };
|
|
61
|
+
if (name.startsWith('payez_')) return { valid: false, error: 'Role name cannot start with "payez_"' };
|
|
62
|
+
return { valid: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// ROLE EDITOR MODAL
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* RoleEditorModal - Create or edit a role
|
|
71
|
+
*/
|
|
72
|
+
export function RoleEditorModal({
|
|
73
|
+
role,
|
|
74
|
+
pagePermissions,
|
|
75
|
+
adminAccessiblePages,
|
|
76
|
+
onSave,
|
|
77
|
+
onClose,
|
|
78
|
+
isDark = true,
|
|
79
|
+
}: {
|
|
80
|
+
role?: Role;
|
|
81
|
+
pagePermissions: PagePermission[];
|
|
82
|
+
adminAccessiblePages?: string[];
|
|
83
|
+
onSave: (data: {
|
|
84
|
+
role_name: string;
|
|
85
|
+
display_name?: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
page_permissions: string[];
|
|
88
|
+
requires_2fa: boolean;
|
|
89
|
+
}) => Promise<void>;
|
|
90
|
+
onClose: () => void;
|
|
91
|
+
isDark?: boolean;
|
|
92
|
+
}) {
|
|
93
|
+
const isEditing = !!role?.role_id;
|
|
94
|
+
|
|
95
|
+
// Form state
|
|
96
|
+
const [roleName, setRoleName] = useState(role?.role_name || '');
|
|
97
|
+
const [displayName, setDisplayName] = useState(role?.display_name || '');
|
|
98
|
+
const [description, setDescription] = useState(role?.description || '');
|
|
99
|
+
const [selectedPermissions, setSelectedPermissions] = useState<string[]>(role?.page_permissions || []);
|
|
100
|
+
const [requires2FA, setRequires2FA] = useState(role?.requires_2fa || false);
|
|
101
|
+
const [error, setError] = useState('');
|
|
102
|
+
const [saving, setSaving] = useState(false);
|
|
103
|
+
|
|
104
|
+
// Role name validation
|
|
105
|
+
const [nameValidation, setNameValidation] = useState<{ valid: boolean; error?: string }>({ valid: true });
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!isEditing && roleName) {
|
|
109
|
+
setNameValidation(validateRoleName(roleName));
|
|
110
|
+
}
|
|
111
|
+
}, [roleName, isEditing]);
|
|
112
|
+
|
|
113
|
+
// Filter permissions based on admin access (Security Addendum)
|
|
114
|
+
const availablePermissions = adminAccessiblePages
|
|
115
|
+
? pagePermissions.filter((p) =>
|
|
116
|
+
adminAccessiblePages.some((ap) => p.route_pattern.startsWith(ap.replace('*', '')))
|
|
117
|
+
)
|
|
118
|
+
: pagePermissions;
|
|
119
|
+
|
|
120
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
setError('');
|
|
123
|
+
|
|
124
|
+
if (!isEditing) {
|
|
125
|
+
const validation = validateRoleName(roleName);
|
|
126
|
+
if (!validation.valid) {
|
|
127
|
+
setError(validation.error || 'Invalid role name');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
setSaving(true);
|
|
134
|
+
await onSave({
|
|
135
|
+
role_name: roleName,
|
|
136
|
+
display_name: displayName || undefined,
|
|
137
|
+
description: description || undefined,
|
|
138
|
+
page_permissions: selectedPermissions,
|
|
139
|
+
requires_2fa: requires2FA,
|
|
140
|
+
});
|
|
141
|
+
onClose();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
setError(err instanceof Error ? err.message : 'Failed to save role');
|
|
144
|
+
} finally {
|
|
145
|
+
setSaving(false);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const togglePermission = (pattern: string) => {
|
|
150
|
+
setSelectedPermissions((prev) =>
|
|
151
|
+
prev.includes(pattern) ? prev.filter((p) => p !== pattern) : [...prev, pattern]
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Theme colors
|
|
156
|
+
const modalBg = isDark ? 'bg-slate-800' : 'bg-white';
|
|
157
|
+
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
158
|
+
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
159
|
+
const textMuted = isDark ? 'text-slate-400' : 'text-gray-500';
|
|
160
|
+
const inputBg = isDark ? 'bg-slate-700' : 'bg-white';
|
|
161
|
+
const inputBorder = isDark ? 'border-slate-600' : 'border-gray-300';
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
165
|
+
<div className={`w-full max-w-lg mx-4 rounded-lg shadow-xl ${modalBg} border ${borderColor} max-h-[90vh] overflow-hidden flex flex-col`}>
|
|
166
|
+
{/* Header */}
|
|
167
|
+
<div className={`px-6 py-4 border-b ${borderColor} flex items-center justify-between`}>
|
|
168
|
+
<h3 className={`text-lg font-semibold ${textPrimary}`}>
|
|
169
|
+
{isEditing ? `Edit Role: ${role?.role_name}` : 'Create New Role'}
|
|
170
|
+
</h3>
|
|
171
|
+
<button onClick={onClose} className={textMuted}>
|
|
172
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
173
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
174
|
+
</svg>
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Form */}
|
|
179
|
+
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
|
180
|
+
<div className="px-6 py-4 space-y-4">
|
|
181
|
+
{/* Role Name */}
|
|
182
|
+
<div>
|
|
183
|
+
<label className={`block text-sm font-medium mb-1 ${textMuted}`}>
|
|
184
|
+
Role Name <span className="text-red-500">*</span>
|
|
185
|
+
</label>
|
|
186
|
+
<input
|
|
187
|
+
type="text"
|
|
188
|
+
value={roleName}
|
|
189
|
+
onChange={(e) => setRoleName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
|
190
|
+
disabled={isEditing}
|
|
191
|
+
className={`w-full px-3 py-2 rounded-md border ${inputBorder} ${inputBg} ${textPrimary} ${isEditing ? 'opacity-50 cursor-not-allowed' : ''} font-mono`}
|
|
192
|
+
placeholder="e.g., content_editor"
|
|
193
|
+
/>
|
|
194
|
+
{!isEditing && roleName && !nameValidation.valid && (
|
|
195
|
+
<p className="mt-1 text-sm text-red-500">{nameValidation.error}</p>
|
|
196
|
+
)}
|
|
197
|
+
{!isEditing && (
|
|
198
|
+
<p className={`mt-1 text-xs ${textMuted}`}>
|
|
199
|
+
3-50 characters, lowercase letters, numbers, underscores only
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Display Name */}
|
|
205
|
+
<div>
|
|
206
|
+
<label className={`block text-sm font-medium mb-1 ${textMuted}`}>Display Name</label>
|
|
207
|
+
<input
|
|
208
|
+
type="text"
|
|
209
|
+
value={displayName}
|
|
210
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
211
|
+
className={`w-full px-3 py-2 rounded-md border ${inputBorder} ${inputBg} ${textPrimary}`}
|
|
212
|
+
placeholder="e.g., Content Editor"
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Description */}
|
|
217
|
+
<div>
|
|
218
|
+
<label className={`block text-sm font-medium mb-1 ${textMuted}`}>Description</label>
|
|
219
|
+
<textarea
|
|
220
|
+
value={description}
|
|
221
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
222
|
+
rows={2}
|
|
223
|
+
className={`w-full px-3 py-2 rounded-md border ${inputBorder} ${inputBg} ${textPrimary}`}
|
|
224
|
+
placeholder="What does this role do?"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* Page Permissions */}
|
|
229
|
+
<div>
|
|
230
|
+
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>Page Permissions</label>
|
|
231
|
+
<div className={`border ${borderColor} rounded-md max-h-48 overflow-y-auto`}>
|
|
232
|
+
{availablePermissions.length === 0 ? (
|
|
233
|
+
<p className={`p-3 text-sm ${textMuted}`}>No page permissions available</p>
|
|
234
|
+
) : (
|
|
235
|
+
availablePermissions.map((perm) => (
|
|
236
|
+
<label
|
|
237
|
+
key={perm.page_permission_id}
|
|
238
|
+
className={`flex items-center px-3 py-2 border-b last:border-b-0 ${borderColor} cursor-pointer hover:${isDark ? 'bg-slate-700' : 'bg-gray-50'}`}
|
|
239
|
+
>
|
|
240
|
+
<input
|
|
241
|
+
type="checkbox"
|
|
242
|
+
checked={selectedPermissions.includes(perm.route_pattern)}
|
|
243
|
+
onChange={() => togglePermission(perm.route_pattern)}
|
|
244
|
+
className="mr-3"
|
|
245
|
+
/>
|
|
246
|
+
<div>
|
|
247
|
+
<span className={`font-mono text-sm ${textPrimary}`}>{perm.route_pattern}</span>
|
|
248
|
+
{perm.display_name && (
|
|
249
|
+
<span className={`block text-xs ${textMuted}`}>{perm.display_name}</span>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</label>
|
|
253
|
+
))
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{/* Security Options */}
|
|
259
|
+
<div className={`border-t ${borderColor} pt-4`}>
|
|
260
|
+
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>Security Options</label>
|
|
261
|
+
<label className="flex items-center cursor-pointer">
|
|
262
|
+
<input
|
|
263
|
+
type="checkbox"
|
|
264
|
+
checked={requires2FA}
|
|
265
|
+
onChange={(e) => setRequires2FA(e.target.checked)}
|
|
266
|
+
className="mr-3"
|
|
267
|
+
/>
|
|
268
|
+
<span className={textPrimary}>Requires 2FA for all pages</span>
|
|
269
|
+
</label>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Error */}
|
|
273
|
+
{error && (
|
|
274
|
+
<div className="bg-red-900/30 border border-red-600 rounded-lg p-3">
|
|
275
|
+
<p className="text-red-400 text-sm">{error}</p>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* Footer */}
|
|
281
|
+
<div className={`px-6 py-4 border-t ${borderColor} flex justify-end gap-3`}>
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
onClick={onClose}
|
|
285
|
+
className={`px-4 py-2 rounded-md border ${borderColor} ${textMuted}`}
|
|
286
|
+
>
|
|
287
|
+
Cancel
|
|
288
|
+
</button>
|
|
289
|
+
<button
|
|
290
|
+
type="submit"
|
|
291
|
+
disabled={saving || (!isEditing && !nameValidation.valid)}
|
|
292
|
+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
293
|
+
>
|
|
294
|
+
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Role'}
|
|
295
|
+
</button>
|
|
296
|
+
</div>
|
|
297
|
+
</form>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============================================================================
|
|
304
|
+
// USER ROLE ASSIGNER MODAL
|
|
305
|
+
// ============================================================================
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* UserRoleAssignerModal - Assign roles to a user
|
|
309
|
+
*/
|
|
310
|
+
export function UserRoleAssignerModal({
|
|
311
|
+
user,
|
|
312
|
+
availableRoles,
|
|
313
|
+
isLastAdmin,
|
|
314
|
+
currentUserId,
|
|
315
|
+
onSave,
|
|
316
|
+
onClose,
|
|
317
|
+
isDark = true,
|
|
318
|
+
}: {
|
|
319
|
+
user: User;
|
|
320
|
+
availableRoles: { role_name: string; display_name?: string; description?: string }[];
|
|
321
|
+
isLastAdmin?: boolean;
|
|
322
|
+
currentUserId?: number;
|
|
323
|
+
onSave: (userId: number, appRoles: string[]) => Promise<void>;
|
|
324
|
+
onClose: () => void;
|
|
325
|
+
isDark?: boolean;
|
|
326
|
+
}) {
|
|
327
|
+
const [selectedRoles, setSelectedRoles] = useState<string[]>(user.roles.app);
|
|
328
|
+
const [saving, setSaving] = useState(false);
|
|
329
|
+
const [error, setError] = useState('');
|
|
330
|
+
|
|
331
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
setError('');
|
|
334
|
+
|
|
335
|
+
// Security: Self-assignment warning
|
|
336
|
+
if (user.user_id === currentUserId) {
|
|
337
|
+
if (!confirm('You are modifying your own roles. Changes take effect on next login. Continue?')) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Security Addendum: Last admin warning
|
|
343
|
+
const hadAdmin = user.roles.app.some((r) => r.includes('admin'));
|
|
344
|
+
const hasAdmin = selectedRoles.some((r) => r.includes('admin'));
|
|
345
|
+
|
|
346
|
+
if (hadAdmin && !hasAdmin && isLastAdmin) {
|
|
347
|
+
if (!confirm('Warning: This is the last administrator. Removing admin access will lock out admin access. Continue?')) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
setSaving(true);
|
|
354
|
+
await onSave(user.user_id, selectedRoles);
|
|
355
|
+
onClose();
|
|
356
|
+
} catch (err) {
|
|
357
|
+
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
|
358
|
+
} finally {
|
|
359
|
+
setSaving(false);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const toggleRole = (roleName: string) => {
|
|
364
|
+
setSelectedRoles((prev) =>
|
|
365
|
+
prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName]
|
|
366
|
+
);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// Theme colors
|
|
370
|
+
const modalBg = isDark ? 'bg-slate-800' : 'bg-white';
|
|
371
|
+
const borderColor = isDark ? 'border-slate-700' : 'border-gray-200';
|
|
372
|
+
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
|
373
|
+
const textMuted = isDark ? 'text-slate-400' : 'text-gray-500';
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
377
|
+
<div className={`w-full max-w-md mx-4 rounded-lg shadow-xl ${modalBg} border ${borderColor}`}>
|
|
378
|
+
{/* Header */}
|
|
379
|
+
<div className={`px-6 py-4 border-b ${borderColor} flex items-center justify-between`}>
|
|
380
|
+
<div>
|
|
381
|
+
<h3 className={`text-lg font-semibold ${textPrimary}`}>Edit Roles</h3>
|
|
382
|
+
<p className={`text-sm ${textMuted}`}>{user.full_name || user.email}</p>
|
|
383
|
+
</div>
|
|
384
|
+
<button onClick={onClose} className={textMuted}>
|
|
385
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
386
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
387
|
+
</svg>
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
{/* Form */}
|
|
392
|
+
<form onSubmit={handleSubmit}>
|
|
393
|
+
<div className="px-6 py-4 space-y-4">
|
|
394
|
+
{/* IDP Roles (read-only) */}
|
|
395
|
+
{user.roles.idp.length > 0 && (
|
|
396
|
+
<div>
|
|
397
|
+
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>
|
|
398
|
+
IDP Roles (read-only)
|
|
399
|
+
<svg className="inline w-4 h-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
400
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
401
|
+
</svg>
|
|
402
|
+
</label>
|
|
403
|
+
<div className="space-y-1">
|
|
404
|
+
{user.roles.idp.map((role) => (
|
|
405
|
+
<div key={role} className={`flex items-center px-3 py-2 rounded ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
|
406
|
+
<span className={textMuted}>{role}</span>
|
|
407
|
+
<span className={`ml-auto text-xs ${textMuted}`}>assigned by IDP</span>
|
|
408
|
+
</div>
|
|
409
|
+
))}
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{/* Application Roles */}
|
|
415
|
+
<div>
|
|
416
|
+
<label className={`block text-sm font-medium mb-2 ${textMuted}`}>Application Roles</label>
|
|
417
|
+
<div className={`border ${borderColor} rounded-md`}>
|
|
418
|
+
{availableRoles.map((role) => (
|
|
419
|
+
<label
|
|
420
|
+
key={role.role_name}
|
|
421
|
+
className={`flex items-start px-3 py-2 border-b last:border-b-0 ${borderColor} cursor-pointer hover:${isDark ? 'bg-slate-700' : 'bg-gray-50'}`}
|
|
422
|
+
>
|
|
423
|
+
<input
|
|
424
|
+
type="checkbox"
|
|
425
|
+
checked={selectedRoles.includes(role.role_name)}
|
|
426
|
+
onChange={() => toggleRole(role.role_name)}
|
|
427
|
+
className="mt-1 mr-3"
|
|
428
|
+
/>
|
|
429
|
+
<div>
|
|
430
|
+
<span className={textPrimary}>{role.display_name || role.role_name}</span>
|
|
431
|
+
{role.description && (
|
|
432
|
+
<span className={`block text-xs ${textMuted}`}>{role.description}</span>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
</label>
|
|
436
|
+
))}
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{/* Error */}
|
|
441
|
+
{error && (
|
|
442
|
+
<div className="bg-red-900/30 border border-red-600 rounded-lg p-3">
|
|
443
|
+
<p className="text-red-400 text-sm">{error}</p>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
{/* Footer */}
|
|
449
|
+
<div className={`px-6 py-4 border-t ${borderColor} flex justify-end gap-3`}>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
onClick={onClose}
|
|
453
|
+
className={`px-4 py-2 rounded-md border ${borderColor} ${textMuted}`}
|
|
454
|
+
>
|
|
455
|
+
Cancel
|
|
456
|
+
</button>
|
|
457
|
+
<button
|
|
458
|
+
type="submit"
|
|
459
|
+
disabled={saving}
|
|
460
|
+
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
461
|
+
>
|
|
462
|
+
{saving ? 'Saving...' : 'Save Changes'}
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
465
|
+
</form>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|