@rebasepro/auth 0.0.1-canary.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/LICENSE +6 -0
- package/dist/api.d.ts +119 -0
- package/dist/components/AdminViews.d.ts +20 -0
- package/dist/components/RebaseLoginView.d.ts +52 -0
- package/dist/hooks/useBackendUserManagement.d.ts +41 -0
- package/dist/hooks/useRebaseAuthController.d.ts +9 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.es.js +1883 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +1883 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/types.d.ts +95 -0
- package/package.json +48 -0
- package/src/api.ts +328 -0
- package/src/components/AdminViews.tsx +795 -0
- package/src/components/RebaseLoginView.tsx +570 -0
- package/src/hooks/useBackendUserManagement.ts +407 -0
- package/src/hooks/useRebaseAuthController.ts +692 -0
- package/src/index.ts +28 -0
- package/src/types.ts +102 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { CMSView, EntityCollection, FieldCaption, Role, SecurityRule, User, useSnackbarController, ConfirmationDialog, useAuthController, useCollectionRegistryController } from "@rebasepro/core";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AddIcon,
|
|
6
|
+
Button,
|
|
7
|
+
Chip,
|
|
8
|
+
Container,
|
|
9
|
+
DeleteIcon,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogActions,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
IconButton,
|
|
15
|
+
Paper,
|
|
16
|
+
Table,
|
|
17
|
+
TableBody,
|
|
18
|
+
TableCell,
|
|
19
|
+
TableHeader,
|
|
20
|
+
TableRow,
|
|
21
|
+
TextField,
|
|
22
|
+
Typography,
|
|
23
|
+
CircularProgress,
|
|
24
|
+
CenteredView,
|
|
25
|
+
Tooltip,
|
|
26
|
+
Checkbox,
|
|
27
|
+
MultiSelect,
|
|
28
|
+
MultiSelectItem,
|
|
29
|
+
LoadingButton,
|
|
30
|
+
getColorSchemeForSeed,
|
|
31
|
+
ChipColorScheme,
|
|
32
|
+
ChipColorKey
|
|
33
|
+
} from "@rebasepro/ui";
|
|
34
|
+
import { UserManagement } from "../hooks/useBackendUserManagement";
|
|
35
|
+
|
|
36
|
+
interface AdminViewsProps {
|
|
37
|
+
userManagement: UserManagement;
|
|
38
|
+
apiUrl: string;
|
|
39
|
+
getAuthToken: () => Promise<string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create admin views for user and role management
|
|
44
|
+
*/
|
|
45
|
+
export function createUserManagementAdminViews({ userManagement, apiUrl, getAuthToken }: AdminViewsProps): CMSView[] {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
slug: "dev/users",
|
|
49
|
+
name: "CMS Users",
|
|
50
|
+
group: "Admin",
|
|
51
|
+
icon: "face",
|
|
52
|
+
view: <UsersView userManagement={userManagement} apiUrl={apiUrl} getAuthToken={getAuthToken} />
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
slug: "dev/roles",
|
|
56
|
+
name: "Roles",
|
|
57
|
+
group: "Admin",
|
|
58
|
+
icon: "gpp_good",
|
|
59
|
+
view: <RolesView userManagement={userManagement} />
|
|
60
|
+
}
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================
|
|
65
|
+
// RoleChip Component (matches original)
|
|
66
|
+
// ============================================
|
|
67
|
+
function RoleChip({ role }: { role: Role }) {
|
|
68
|
+
let colorScheme: ChipColorScheme | ChipColorKey;
|
|
69
|
+
if (role.isAdmin) {
|
|
70
|
+
colorScheme = "blueDarker";
|
|
71
|
+
} else if (role.id === "editor") {
|
|
72
|
+
colorScheme = "yellowLight";
|
|
73
|
+
} else if (role.id === "viewer") {
|
|
74
|
+
colorScheme = "grayLight";
|
|
75
|
+
} else {
|
|
76
|
+
colorScheme = getColorSchemeForSeed(role.id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Chip colorScheme={colorScheme} key={role.id}>
|
|
81
|
+
{role.name}
|
|
82
|
+
</Chip>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================
|
|
87
|
+
// UsersView Component
|
|
88
|
+
// ============================================
|
|
89
|
+
export function UsersView({ userManagement, apiUrl, getAuthToken }: {
|
|
90
|
+
userManagement: UserManagement;
|
|
91
|
+
apiUrl: string;
|
|
92
|
+
getAuthToken: () => Promise<string>;
|
|
93
|
+
}) {
|
|
94
|
+
const { users, roles, saveUser, deleteUser, loading } = userManagement;
|
|
95
|
+
const snackbarController = useSnackbarController();
|
|
96
|
+
const { user: loggedInUser } = useAuthController();
|
|
97
|
+
|
|
98
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
99
|
+
const [selectedUser, setSelectedUser] = useState<User | undefined>();
|
|
100
|
+
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
101
|
+
const [userToDelete, setUserToDelete] = useState<User | undefined>();
|
|
102
|
+
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
|
103
|
+
const [formKey, setFormKey] = useState(0);
|
|
104
|
+
const [bootstrapping, setBootstrapping] = useState(false);
|
|
105
|
+
|
|
106
|
+
// Check if any admin exists
|
|
107
|
+
const hasAdmin = users.some(u => u.roles?.includes("admin"));
|
|
108
|
+
|
|
109
|
+
const handleBootstrap = async () => {
|
|
110
|
+
setBootstrapping(true);
|
|
111
|
+
try {
|
|
112
|
+
const token = await getAuthToken();
|
|
113
|
+
const response = await fetch(`${apiUrl}/api/admin/bootstrap`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"Content-Type": "application/json",
|
|
117
|
+
"Authorization": `Bearer ${token}`
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const data = await response.json();
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(data.error?.message || "Bootstrap failed");
|
|
123
|
+
}
|
|
124
|
+
snackbarController.open({ type: "success", message: "You are now an admin! Refreshing..." });
|
|
125
|
+
// Reload to get new roles
|
|
126
|
+
window.location.reload();
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
snackbarController.open({ type: "error", message: error instanceof Error ? error.message : "Failed to bootstrap admin" });
|
|
129
|
+
} finally {
|
|
130
|
+
setBootstrapping(false);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleAddUser = () => {
|
|
135
|
+
setSelectedUser(undefined);
|
|
136
|
+
setFormKey(k => k + 1);
|
|
137
|
+
setDialogOpen(true);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleEditUser = (user: User) => {
|
|
141
|
+
setSelectedUser(user);
|
|
142
|
+
setDialogOpen(true);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleClose = () => {
|
|
146
|
+
setDialogOpen(false);
|
|
147
|
+
setSelectedUser(undefined);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleDelete = async () => {
|
|
151
|
+
if (!userToDelete) return;
|
|
152
|
+
setDeleteInProgress(true);
|
|
153
|
+
try {
|
|
154
|
+
await deleteUser(userToDelete);
|
|
155
|
+
snackbarController.open({ type: "success", message: "User deleted successfully" });
|
|
156
|
+
setDeleteConfirmOpen(false);
|
|
157
|
+
setUserToDelete(undefined);
|
|
158
|
+
} catch (error: unknown) {
|
|
159
|
+
snackbarController.open({ type: "error", message: error instanceof Error ? error.message : "Error deleting user" });
|
|
160
|
+
} finally {
|
|
161
|
+
setDeleteInProgress(false);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (loading) {
|
|
166
|
+
return <CenteredView><CircularProgress /></CenteredView>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Container className="w-full flex flex-col py-4 gap-4" maxWidth={"6xl"}>
|
|
171
|
+
{/* Bootstrap warning when no admins */}
|
|
172
|
+
{!hasAdmin && loggedInUser && (
|
|
173
|
+
<div className="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 rounded p-4 flex items-center justify-between">
|
|
174
|
+
<div>
|
|
175
|
+
<Typography variant="label" className="text-yellow-800 dark:text-yellow-200">
|
|
176
|
+
No admin users exist. You can make yourself an admin.
|
|
177
|
+
</Typography>
|
|
178
|
+
</div>
|
|
179
|
+
<Button
|
|
180
|
+
onClick={handleBootstrap}
|
|
181
|
+
disabled={bootstrapping}
|
|
182
|
+
>
|
|
183
|
+
{bootstrapping ? <CircularProgress size="small" /> : "Make me admin"}
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
<div className="flex items-center mt-12">
|
|
189
|
+
<Typography gutterBottom variant="h4" className="grow" component="h4">
|
|
190
|
+
Users
|
|
191
|
+
</Typography>
|
|
192
|
+
<Button startIcon={<AddIcon />} onClick={handleAddUser}>
|
|
193
|
+
Add user
|
|
194
|
+
</Button>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="overflow-auto">
|
|
198
|
+
<Table className="w-full">
|
|
199
|
+
<TableHeader>
|
|
200
|
+
<TableCell header className="truncate w-16"></TableCell>
|
|
201
|
+
<TableCell header>Email</TableCell>
|
|
202
|
+
<TableCell header>Name</TableCell>
|
|
203
|
+
<TableCell header>Roles</TableCell>
|
|
204
|
+
</TableHeader>
|
|
205
|
+
<TableBody>
|
|
206
|
+
{users.map(user => (
|
|
207
|
+
<TableRow key={user.uid} onClick={() => handleEditUser(user)}>
|
|
208
|
+
<TableCell style={{ width: "64px" }}>
|
|
209
|
+
<Tooltip asChild title="Delete this user">
|
|
210
|
+
<IconButton
|
|
211
|
+
size="small"
|
|
212
|
+
onClick={(e) => {
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
setUserToDelete(user);
|
|
215
|
+
setDeleteConfirmOpen(true);
|
|
216
|
+
}}>
|
|
217
|
+
<DeleteIcon />
|
|
218
|
+
</IconButton>
|
|
219
|
+
</Tooltip>
|
|
220
|
+
</TableCell>
|
|
221
|
+
<TableCell>{user.email}</TableCell>
|
|
222
|
+
<TableCell className="font-medium">{user.displayName}</TableCell>
|
|
223
|
+
<TableCell>
|
|
224
|
+
<div className="flex flex-wrap gap-2">
|
|
225
|
+
{user.roles?.map((roleId: string) => {
|
|
226
|
+
const role = roles.find(r => r.id === roleId);
|
|
227
|
+
return role ? <RoleChip key={roleId} role={role} /> : <span key={roleId}>{roleId}</span>;
|
|
228
|
+
})}
|
|
229
|
+
</div>
|
|
230
|
+
</TableCell>
|
|
231
|
+
</TableRow>
|
|
232
|
+
))}
|
|
233
|
+
|
|
234
|
+
{users.length === 0 && (
|
|
235
|
+
<TableRow>
|
|
236
|
+
<TableCell colspan={4}>
|
|
237
|
+
<CenteredView className="flex flex-col gap-4 my-8 items-center">
|
|
238
|
+
<Typography variant="label">
|
|
239
|
+
There are no users yet
|
|
240
|
+
</Typography>
|
|
241
|
+
</CenteredView>
|
|
242
|
+
</TableCell>
|
|
243
|
+
</TableRow>
|
|
244
|
+
)}
|
|
245
|
+
</TableBody>
|
|
246
|
+
</Table>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* User Edit Dialog */}
|
|
250
|
+
<UserDetailsForm
|
|
251
|
+
key={selectedUser?.uid ?? `new-${formKey}`}
|
|
252
|
+
open={dialogOpen}
|
|
253
|
+
user={selectedUser}
|
|
254
|
+
roles={roles}
|
|
255
|
+
saveUser={saveUser}
|
|
256
|
+
handleClose={handleClose}
|
|
257
|
+
/>
|
|
258
|
+
|
|
259
|
+
{/* Delete Confirmation */}
|
|
260
|
+
<ConfirmationDialog
|
|
261
|
+
open={deleteConfirmOpen}
|
|
262
|
+
loading={deleteInProgress}
|
|
263
|
+
onAccept={handleDelete}
|
|
264
|
+
onCancel={() => { setDeleteConfirmOpen(false); setUserToDelete(undefined); }}
|
|
265
|
+
title={<>Delete?</>}
|
|
266
|
+
body={<>Are you sure you want to delete this user?</>}
|
|
267
|
+
/>
|
|
268
|
+
</Container>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================
|
|
273
|
+
// UserDetailsForm Component (matches original)
|
|
274
|
+
// ============================================
|
|
275
|
+
function UserDetailsForm({
|
|
276
|
+
open,
|
|
277
|
+
user: userProp,
|
|
278
|
+
roles,
|
|
279
|
+
saveUser,
|
|
280
|
+
handleClose
|
|
281
|
+
}: {
|
|
282
|
+
open: boolean;
|
|
283
|
+
user?: User;
|
|
284
|
+
roles: Role[];
|
|
285
|
+
saveUser: (user: User) => Promise<User>;
|
|
286
|
+
handleClose: () => void;
|
|
287
|
+
}) {
|
|
288
|
+
const snackbarController = useSnackbarController();
|
|
289
|
+
const isNewUser = !userProp;
|
|
290
|
+
|
|
291
|
+
const [displayName, setDisplayName] = useState(userProp?.displayName || "");
|
|
292
|
+
const [email, setEmail] = useState(userProp?.email || "");
|
|
293
|
+
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(
|
|
294
|
+
userProp?.roles || ["editor"]
|
|
295
|
+
);
|
|
296
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
297
|
+
const [errors, setErrors] = useState<{ displayName?: string; email?: string; roles?: string }>({});
|
|
298
|
+
const [submitCount, setSubmitCount] = useState(0);
|
|
299
|
+
|
|
300
|
+
const validate = () => {
|
|
301
|
+
const newErrors: typeof errors = {};
|
|
302
|
+
if (!displayName) newErrors.displayName = "Required";
|
|
303
|
+
if (!email) newErrors.email = "Required";
|
|
304
|
+
else if (!/\S+@\S+\.\S+/.test(email)) newErrors.email = "Invalid email";
|
|
305
|
+
if (selectedRoleIds.length === 0) newErrors.roles = "At least one role is required";
|
|
306
|
+
setErrors(newErrors);
|
|
307
|
+
return Object.keys(newErrors).length === 0;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
setSubmitCount(c => c + 1);
|
|
313
|
+
|
|
314
|
+
if (!validate()) return;
|
|
315
|
+
|
|
316
|
+
setIsSubmitting(true);
|
|
317
|
+
try {
|
|
318
|
+
const userToSave: User = {
|
|
319
|
+
uid: userProp?.uid || crypto.randomUUID(),
|
|
320
|
+
email,
|
|
321
|
+
displayName: displayName || null,
|
|
322
|
+
photoURL: userProp?.photoURL || null,
|
|
323
|
+
providerId: "custom",
|
|
324
|
+
isAnonymous: false,
|
|
325
|
+
roles: selectedRoleIds
|
|
326
|
+
};
|
|
327
|
+
await saveUser(userToSave);
|
|
328
|
+
handleClose();
|
|
329
|
+
} catch (error: unknown) {
|
|
330
|
+
snackbarController.open({ type: "error", message: error instanceof Error ? error.message : "Failed to save user" });
|
|
331
|
+
} finally {
|
|
332
|
+
setIsSubmitting(false);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const dirty = isNewUser ||
|
|
337
|
+
displayName !== (userProp?.displayName || "") ||
|
|
338
|
+
email !== (userProp?.email || "") ||
|
|
339
|
+
JSON.stringify(selectedRoleIds.sort()) !== JSON.stringify((userProp?.roles || []).sort());
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<Dialog open={open} onOpenChange={(open) => !open ? handleClose() : undefined} maxWidth="4xl">
|
|
343
|
+
<form onSubmit={handleSubmit} autoComplete="off" noValidate
|
|
344
|
+
style={{ display: "flex", flexDirection: "column", position: "relative", height: "100%" }}>
|
|
345
|
+
|
|
346
|
+
<DialogTitle variant="h4" gutterBottom={false}>
|
|
347
|
+
User
|
|
348
|
+
</DialogTitle>
|
|
349
|
+
|
|
350
|
+
<DialogContent className="h-full grow">
|
|
351
|
+
<div className="grid grid-cols-12 gap-4">
|
|
352
|
+
<div className="col-span-12">
|
|
353
|
+
<TextField
|
|
354
|
+
name="displayName"
|
|
355
|
+
required
|
|
356
|
+
error={submitCount > 0 && Boolean(errors.displayName)}
|
|
357
|
+
value={displayName}
|
|
358
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
359
|
+
label="Name"
|
|
360
|
+
/>
|
|
361
|
+
<FieldCaption>
|
|
362
|
+
{submitCount > 0 && errors.displayName ? errors.displayName : "Name of this user"}
|
|
363
|
+
</FieldCaption>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div className="col-span-12">
|
|
367
|
+
<TextField
|
|
368
|
+
required
|
|
369
|
+
error={submitCount > 0 && Boolean(errors.email)}
|
|
370
|
+
name="email"
|
|
371
|
+
value={email}
|
|
372
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
373
|
+
label="Email"
|
|
374
|
+
disabled={!isNewUser}
|
|
375
|
+
/>
|
|
376
|
+
<FieldCaption>
|
|
377
|
+
{submitCount > 0 && errors.email ? errors.email : "Email of this user"}
|
|
378
|
+
</FieldCaption>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div className="col-span-12">
|
|
382
|
+
<MultiSelect
|
|
383
|
+
className="w-full"
|
|
384
|
+
label="Roles"
|
|
385
|
+
value={selectedRoleIds}
|
|
386
|
+
onValueChange={(value: string[]) => setSelectedRoleIds(value)}
|
|
387
|
+
>
|
|
388
|
+
{roles.map(role => (
|
|
389
|
+
<MultiSelectItem key={role.id} value={role.id}>
|
|
390
|
+
<RoleChip role={role} />
|
|
391
|
+
</MultiSelectItem>
|
|
392
|
+
))}
|
|
393
|
+
</MultiSelect>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</DialogContent>
|
|
397
|
+
|
|
398
|
+
<DialogActions>
|
|
399
|
+
<Button variant="text" onClick={handleClose}>
|
|
400
|
+
Cancel
|
|
401
|
+
</Button>
|
|
402
|
+
<LoadingButton
|
|
403
|
+
variant="filled"
|
|
404
|
+
type="submit"
|
|
405
|
+
disabled={!dirty}
|
|
406
|
+
loading={isSubmitting}
|
|
407
|
+
>
|
|
408
|
+
{isNewUser ? "Create user" : "Update"}
|
|
409
|
+
</LoadingButton>
|
|
410
|
+
</DialogActions>
|
|
411
|
+
</form>
|
|
412
|
+
</Dialog>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ============================================
|
|
417
|
+
// RolesView Component
|
|
418
|
+
// ============================================
|
|
419
|
+
export function RolesView({ userManagement }: { userManagement: UserManagement }) {
|
|
420
|
+
const { roles, saveRole, deleteRole, loading, allowDefaultRolesCreation } = userManagement;
|
|
421
|
+
const snackbarController = useSnackbarController();
|
|
422
|
+
|
|
423
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
424
|
+
const [selectedRole, setSelectedRole] = useState<Role | undefined>();
|
|
425
|
+
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
426
|
+
const [roleToDelete, setRoleToDelete] = useState<Role | undefined>();
|
|
427
|
+
const [deleteInProgress, setDeleteInProgress] = useState(false);
|
|
428
|
+
|
|
429
|
+
const handleAddRole = () => {
|
|
430
|
+
setSelectedRole(undefined);
|
|
431
|
+
setDialogOpen(true);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const handleEditRole = (role: Role) => {
|
|
435
|
+
setSelectedRole(role);
|
|
436
|
+
setDialogOpen(true);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const handleClose = () => {
|
|
440
|
+
setDialogOpen(false);
|
|
441
|
+
setSelectedRole(undefined);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const handleDelete = async () => {
|
|
445
|
+
if (!roleToDelete) return;
|
|
446
|
+
setDeleteInProgress(true);
|
|
447
|
+
try {
|
|
448
|
+
await deleteRole(roleToDelete);
|
|
449
|
+
snackbarController.open({ type: "success", message: "Role deleted successfully" });
|
|
450
|
+
setDeleteConfirmOpen(false);
|
|
451
|
+
setRoleToDelete(undefined);
|
|
452
|
+
} catch (error: unknown) {
|
|
453
|
+
snackbarController.open({ type: "error", message: error instanceof Error ? error.message : "Error deleting role" });
|
|
454
|
+
} finally {
|
|
455
|
+
setDeleteInProgress(false);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const createDefaultRoles = () => {
|
|
460
|
+
const defaultRoles: Role[] = [
|
|
461
|
+
{ id: "admin", name: "Admin", isAdmin: true },
|
|
462
|
+
{ id: "editor", name: "Editor", isAdmin: false },
|
|
463
|
+
{ id: "viewer", name: "Viewer", isAdmin: false }
|
|
464
|
+
];
|
|
465
|
+
defaultRoles.forEach(role => saveRole(role));
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
if (loading) {
|
|
469
|
+
return <CenteredView><CircularProgress /></CenteredView>;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
<Container className="w-full flex flex-col py-4 gap-4" maxWidth={"6xl"}>
|
|
474
|
+
<div className="flex items-center mt-12">
|
|
475
|
+
<Typography gutterBottom variant="h4" className="grow" component="h4">
|
|
476
|
+
Roles
|
|
477
|
+
</Typography>
|
|
478
|
+
<Button startIcon={<AddIcon />} onClick={handleAddRole}>
|
|
479
|
+
Add role
|
|
480
|
+
</Button>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<div className="w-full overflow-auto">
|
|
484
|
+
<Table className="w-full">
|
|
485
|
+
<TableHeader>
|
|
486
|
+
<TableCell header className="w-16"></TableCell>
|
|
487
|
+
<TableCell header>Role</TableCell>
|
|
488
|
+
<TableCell header className="items-center">Is Admin</TableCell>
|
|
489
|
+
</TableHeader>
|
|
490
|
+
<TableBody>
|
|
491
|
+
{roles.map(role => {
|
|
492
|
+
return (
|
|
493
|
+
<TableRow key={role.id} onClick={() => handleEditRole(role)}>
|
|
494
|
+
<TableCell style={{ width: "64px" }}>
|
|
495
|
+
{!role.isAdmin && (
|
|
496
|
+
<Tooltip asChild title="Delete this role">
|
|
497
|
+
<IconButton
|
|
498
|
+
size="small"
|
|
499
|
+
onClick={(e) => {
|
|
500
|
+
e.stopPropagation();
|
|
501
|
+
setRoleToDelete(role);
|
|
502
|
+
setDeleteConfirmOpen(true);
|
|
503
|
+
}}>
|
|
504
|
+
<DeleteIcon />
|
|
505
|
+
</IconButton>
|
|
506
|
+
</Tooltip>
|
|
507
|
+
)}
|
|
508
|
+
</TableCell>
|
|
509
|
+
<TableCell>
|
|
510
|
+
<RoleChip role={role} />
|
|
511
|
+
</TableCell>
|
|
512
|
+
<TableCell className="items-center">
|
|
513
|
+
<Checkbox checked={role.isAdmin ?? false} />
|
|
514
|
+
</TableCell>
|
|
515
|
+
</TableRow>
|
|
516
|
+
);
|
|
517
|
+
})}
|
|
518
|
+
|
|
519
|
+
{roles.length === 0 && (
|
|
520
|
+
<TableRow>
|
|
521
|
+
<TableCell colspan={4}>
|
|
522
|
+
<CenteredView className="flex flex-col gap-4 my-8 items-center">
|
|
523
|
+
<Typography variant="label">
|
|
524
|
+
You don't have any roles yet.
|
|
525
|
+
</Typography>
|
|
526
|
+
{allowDefaultRolesCreation && (
|
|
527
|
+
<Button onClick={createDefaultRoles}>
|
|
528
|
+
Create default roles
|
|
529
|
+
</Button>
|
|
530
|
+
)}
|
|
531
|
+
</CenteredView>
|
|
532
|
+
</TableCell>
|
|
533
|
+
</TableRow>
|
|
534
|
+
)}
|
|
535
|
+
</TableBody>
|
|
536
|
+
</Table>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
{/* Role Edit Dialog */}
|
|
540
|
+
<RoleDetailsForm
|
|
541
|
+
key={selectedRole?.id ?? "new"}
|
|
542
|
+
open={dialogOpen}
|
|
543
|
+
role={selectedRole}
|
|
544
|
+
saveRole={saveRole}
|
|
545
|
+
handleClose={handleClose}
|
|
546
|
+
/>
|
|
547
|
+
|
|
548
|
+
{/* Delete Confirmation */}
|
|
549
|
+
<ConfirmationDialog
|
|
550
|
+
open={deleteConfirmOpen}
|
|
551
|
+
loading={deleteInProgress}
|
|
552
|
+
onAccept={handleDelete}
|
|
553
|
+
onCancel={() => { setDeleteConfirmOpen(false); setRoleToDelete(undefined); }}
|
|
554
|
+
title={<>Delete?</>}
|
|
555
|
+
body={<>Are you sure you want to delete this role?</>}
|
|
556
|
+
/>
|
|
557
|
+
</Container>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ============================================
|
|
562
|
+
// RoleDetailsForm Component
|
|
563
|
+
// ============================================
|
|
564
|
+
function RoleDetailsForm({
|
|
565
|
+
open,
|
|
566
|
+
role: roleProp,
|
|
567
|
+
saveRole,
|
|
568
|
+
handleClose
|
|
569
|
+
}: {
|
|
570
|
+
open: boolean;
|
|
571
|
+
role?: Role;
|
|
572
|
+
saveRole: (role: Role) => Promise<void>;
|
|
573
|
+
handleClose: () => void;
|
|
574
|
+
}) {
|
|
575
|
+
const snackbarController = useSnackbarController();
|
|
576
|
+
const isNewRole = !roleProp;
|
|
577
|
+
|
|
578
|
+
const [roleId, setRoleId] = useState(roleProp?.id || "");
|
|
579
|
+
const [roleName, setRoleName] = useState(roleProp?.name || "");
|
|
580
|
+
const [isAdmin, setIsAdmin] = useState(roleProp?.isAdmin ?? false);
|
|
581
|
+
|
|
582
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
583
|
+
const [errors, setErrors] = useState<{ id?: string; name?: string }>({});
|
|
584
|
+
const [submitCount, setSubmitCount] = useState(0);
|
|
585
|
+
|
|
586
|
+
const validate = () => {
|
|
587
|
+
const newErrors: typeof errors = {};
|
|
588
|
+
if (!roleId) newErrors.id = "Required";
|
|
589
|
+
if (!roleName) newErrors.name = "Required";
|
|
590
|
+
setErrors(newErrors);
|
|
591
|
+
return Object.keys(newErrors).length === 0;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
595
|
+
e.preventDefault();
|
|
596
|
+
setSubmitCount(c => c + 1);
|
|
597
|
+
|
|
598
|
+
if (!validate()) return;
|
|
599
|
+
|
|
600
|
+
setIsSubmitting(true);
|
|
601
|
+
try {
|
|
602
|
+
await saveRole({
|
|
603
|
+
id: roleId,
|
|
604
|
+
name: roleName,
|
|
605
|
+
isAdmin
|
|
606
|
+
});
|
|
607
|
+
handleClose();
|
|
608
|
+
} catch (error: unknown) {
|
|
609
|
+
snackbarController.open({ type: "error", message: error instanceof Error ? error.message : "Failed to save role" });
|
|
610
|
+
} finally {
|
|
611
|
+
setIsSubmitting(false);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<Dialog open={open} onOpenChange={(open) => !open ? handleClose() : undefined} maxWidth="6xl">
|
|
617
|
+
<form onSubmit={handleSubmit} autoComplete="off" noValidate
|
|
618
|
+
style={{ display: "flex", flexDirection: "column", position: "relative", height: "100%" }}>
|
|
619
|
+
|
|
620
|
+
<DialogTitle variant="h4" gutterBottom={false}>
|
|
621
|
+
Role
|
|
622
|
+
</DialogTitle>
|
|
623
|
+
|
|
624
|
+
<DialogContent className="h-full grow overflow-y-auto">
|
|
625
|
+
<div className="grid grid-cols-12 gap-4">
|
|
626
|
+
<div className="col-span-12 sm:col-span-4">
|
|
627
|
+
<TextField
|
|
628
|
+
name="id"
|
|
629
|
+
required
|
|
630
|
+
error={submitCount > 0 && Boolean(errors.id)}
|
|
631
|
+
value={roleId}
|
|
632
|
+
onChange={(e) => setRoleId(e.target.value)}
|
|
633
|
+
label="Role ID"
|
|
634
|
+
disabled={!isNewRole}
|
|
635
|
+
/>
|
|
636
|
+
<FieldCaption>
|
|
637
|
+
{submitCount > 0 && errors.id ? errors.id : "Unique identifier for this role"}
|
|
638
|
+
</FieldCaption>
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
<div className="col-span-12 sm:col-span-4">
|
|
642
|
+
<TextField
|
|
643
|
+
name="name"
|
|
644
|
+
required
|
|
645
|
+
error={submitCount > 0 && Boolean(errors.name)}
|
|
646
|
+
value={roleName}
|
|
647
|
+
onChange={(e) => setRoleName(e.target.value)}
|
|
648
|
+
label="Role Name"
|
|
649
|
+
/>
|
|
650
|
+
<FieldCaption>
|
|
651
|
+
{submitCount > 0 && errors.name ? errors.name : "Display name for this role"}
|
|
652
|
+
</FieldCaption>
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<div className="col-span-12 sm:col-span-4 flex items-start pt-2">
|
|
656
|
+
<label className="flex items-center gap-2 cursor-pointer mt-3">
|
|
657
|
+
<Checkbox
|
|
658
|
+
checked={isAdmin}
|
|
659
|
+
onCheckedChange={(checked) => setIsAdmin(Boolean(checked))}
|
|
660
|
+
/>
|
|
661
|
+
<span className="font-medium">Is Admin</span>
|
|
662
|
+
</label>
|
|
663
|
+
</div>
|
|
664
|
+
|
|
665
|
+
{/* Permissions matrix */}
|
|
666
|
+
<div className="col-span-12">
|
|
667
|
+
<CollectionPermissionsMatrix roleId={roleId} isAdmin={isAdmin} />
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
</DialogContent>
|
|
671
|
+
|
|
672
|
+
<DialogActions>
|
|
673
|
+
<Button variant="text" onClick={handleClose}>
|
|
674
|
+
Cancel
|
|
675
|
+
</Button>
|
|
676
|
+
<LoadingButton
|
|
677
|
+
variant="filled"
|
|
678
|
+
type="submit"
|
|
679
|
+
loading={isSubmitting}
|
|
680
|
+
>
|
|
681
|
+
{isNewRole ? "Create role" : "Update"}
|
|
682
|
+
</LoadingButton>
|
|
683
|
+
</DialogActions>
|
|
684
|
+
</form>
|
|
685
|
+
</Dialog>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ============================================
|
|
690
|
+
// CollectionPermissionsMatrix Component
|
|
691
|
+
// ============================================
|
|
692
|
+
const CRUD_OPS = [
|
|
693
|
+
{ op: "select" as const, label: "Read" },
|
|
694
|
+
{ op: "insert" as const, label: "Create" },
|
|
695
|
+
{ op: "update" as const, label: "Edit" },
|
|
696
|
+
{ op: "delete" as const, label: "Delete" },
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
/** Inline check: does roleId have access for this operation on these securityRules? */
|
|
700
|
+
function hasRoleAccess(
|
|
701
|
+
rules: SecurityRule[] | undefined,
|
|
702
|
+
roleId: string,
|
|
703
|
+
op: "select" | "insert" | "update" | "delete"
|
|
704
|
+
): boolean {
|
|
705
|
+
if (!rules || rules.length === 0) return true; // no rules = unrestricted
|
|
706
|
+
const applicable = rules.filter(r =>
|
|
707
|
+
r.operation === op || r.operation === "all" ||
|
|
708
|
+
r.operations?.includes(op) || r.operations?.includes("all")
|
|
709
|
+
);
|
|
710
|
+
if (applicable.length === 0) return false;
|
|
711
|
+
const forRole = applicable.filter(r =>
|
|
712
|
+
!r.roles || r.roles.length === 0 || r.roles.includes(roleId) || r.roles.includes("public")
|
|
713
|
+
);
|
|
714
|
+
if (forRole.length === 0) return false;
|
|
715
|
+
// Restrictive rules: any failing one denies immediately
|
|
716
|
+
for (const r of forRole) {
|
|
717
|
+
if ((r.mode ?? "permissive") === "restrictive") return false;
|
|
718
|
+
}
|
|
719
|
+
return forRole.some(r => (r.mode ?? "permissive") === "permissive");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function PermCell({ granted }: { granted: boolean }) {
|
|
723
|
+
return (
|
|
724
|
+
<span className={granted
|
|
725
|
+
? "text-green-500 dark:text-green-400 text-base select-none"
|
|
726
|
+
: "text-surface-300 dark:text-surface-600 text-base select-none"}
|
|
727
|
+
>
|
|
728
|
+
{granted ? "✓" : "✗"}
|
|
729
|
+
</span>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function CollectionPermissionsMatrix({ roleId, isAdmin }: { roleId: string; isAdmin: boolean }) {
|
|
734
|
+
const { collections } = useCollectionRegistryController();
|
|
735
|
+
|
|
736
|
+
if (!collections || collections.length === 0) {
|
|
737
|
+
return (
|
|
738
|
+
<div className="mt-4">
|
|
739
|
+
<Typography variant="label" className="text-surface-400">No collections configured</Typography>
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const topLevel = collections.filter(c => !c.collectionGroup);
|
|
745
|
+
|
|
746
|
+
return (
|
|
747
|
+
<div className="mt-6">
|
|
748
|
+
<Typography variant="label" className="mb-2 block text-surface-600 dark:text-surface-400 uppercase tracking-wide text-xs">
|
|
749
|
+
Collection permissions
|
|
750
|
+
</Typography>
|
|
751
|
+
<div className="rounded-lg border border-surface-200 dark:border-surface-700 overflow-hidden">
|
|
752
|
+
<Table>
|
|
753
|
+
<TableHeader>
|
|
754
|
+
<TableRow>
|
|
755
|
+
<TableCell header>Collection</TableCell>
|
|
756
|
+
{CRUD_OPS.map(({ op, label }) => (
|
|
757
|
+
<TableCell key={op} header className="text-center w-24">{label}</TableCell>
|
|
758
|
+
))}
|
|
759
|
+
</TableRow>
|
|
760
|
+
</TableHeader>
|
|
761
|
+
<TableBody>
|
|
762
|
+
{topLevel.map((collection) => {
|
|
763
|
+
const noRules = !collection.securityRules || collection.securityRules.length === 0;
|
|
764
|
+
return (
|
|
765
|
+
<TableRow key={collection.slug}>
|
|
766
|
+
<TableCell>
|
|
767
|
+
<div className="flex items-center gap-2">
|
|
768
|
+
<span className="font-medium">{collection.name}</span>
|
|
769
|
+
{noRules && !isAdmin && (
|
|
770
|
+
<Tooltip title="No security rules — unrestricted">
|
|
771
|
+
<Chip className="text-xs" colorScheme="yellowLight">No rules</Chip>
|
|
772
|
+
</Tooltip>
|
|
773
|
+
)}
|
|
774
|
+
</div>
|
|
775
|
+
<span className="text-xs text-surface-400 font-mono">{collection.slug}</span>
|
|
776
|
+
</TableCell>
|
|
777
|
+
{CRUD_OPS.map(({ op }) => (
|
|
778
|
+
<TableCell key={op} className="text-center">
|
|
779
|
+
<PermCell granted={isAdmin || hasRoleAccess(collection.securityRules, roleId, op)} />
|
|
780
|
+
</TableCell>
|
|
781
|
+
))}
|
|
782
|
+
</TableRow>
|
|
783
|
+
);
|
|
784
|
+
})}
|
|
785
|
+
</TableBody>
|
|
786
|
+
</Table>
|
|
787
|
+
</div>
|
|
788
|
+
{!roleId && (
|
|
789
|
+
<Typography variant="caption" className="mt-2 text-surface-400 italic">
|
|
790
|
+
Enter a role ID above to preview permissions
|
|
791
|
+
</Typography>
|
|
792
|
+
)}
|
|
793
|
+
</div>
|
|
794
|
+
);
|
|
795
|
+
}
|