@arch-cadre/panel 1.0.7 → 1.0.10

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.
Files changed (53) hide show
  1. package/dist/ui/activity-log/pages/log-list.cjs +2 -2
  2. package/dist/ui/components/profile/components.cjs +2 -2
  3. package/dist/ui/components/profile/components.d.ts +1 -1
  4. package/dist/ui/components/profile/components.mjs +1 -1
  5. package/dist/ui/rbac/pages/rbac-admin.cjs +14 -14
  6. package/dist/ui/session-manager/components/sessions-list.cjs +5 -5
  7. package/dist/ui/session-manager/components/sessions-list.mjs +1 -1
  8. package/dist/ui/session-manager/pages/sessions-page.cjs +3 -3
  9. package/dist/ui/session-manager/pages/sessions-page.mjs +1 -1
  10. package/package.json +7 -6
  11. package/src/actions/actions.ts +17 -0
  12. package/src/actions/activity-log/index.ts +17 -0
  13. package/src/actions/index.ts +2 -0
  14. package/src/actions/manager.ts +168 -0
  15. package/src/actions/profile.ts +173 -0
  16. package/src/actions/rbac/index.ts +131 -0
  17. package/src/actions/session-manager/index.ts +87 -0
  18. package/src/actions/settings.ts +34 -0
  19. package/src/index.ts +135 -0
  20. package/src/intl.d.ts +9 -0
  21. package/src/navigation.ts +57 -0
  22. package/src/routes.ts +107 -0
  23. package/src/schema/activity-log.ts +16 -0
  24. package/src/schema.ts +1 -0
  25. package/src/types.ts +18 -0
  26. package/src/ui/activity-log/components/ActivityStatsWidget.tsx +37 -0
  27. package/src/ui/activity-log/components/RecentLogsWidget.tsx +74 -0
  28. package/src/ui/activity-log/pages/log-list.tsx +91 -0
  29. package/src/ui/components/app-content.tsx +51 -0
  30. package/src/ui/components/app-header.tsx +65 -0
  31. package/src/ui/components/app-sidebar.tsx +249 -0
  32. package/src/ui/components/app-user.tsx +126 -0
  33. package/src/ui/components/breadcrumb-slot.tsx +52 -0
  34. package/src/ui/components/manager/module-card.tsx +327 -0
  35. package/src/ui/components/manager/module-list.tsx +59 -0
  36. package/src/ui/components/manager/module-upload.tsx +84 -0
  37. package/src/ui/components/profile/components.tsx +311 -0
  38. package/src/ui/components/profile/link.tsx +36 -0
  39. package/src/ui/components/profile/page.tsx +45 -0
  40. package/src/ui/components/sidebar-slot.tsx +47 -0
  41. package/src/ui/dashboard/page.tsx +17 -0
  42. package/src/ui/dashboard/widgets/WelcomeBackUserWidget.tsx +47 -0
  43. package/src/ui/error.tsx +82 -0
  44. package/src/ui/layout.tsx +54 -0
  45. package/src/ui/modules/docs/page.tsx +105 -0
  46. package/src/ui/modules/page.tsx +30 -0
  47. package/src/ui/page.tsx +15 -0
  48. package/src/ui/rbac/pages/rbac-admin.tsx +551 -0
  49. package/src/ui/router.tsx +69 -0
  50. package/src/ui/session-manager/components/sessions-list.tsx +303 -0
  51. package/src/ui/session-manager/pages/sessions-page.tsx +22 -0
  52. package/src/ui/settings/page.tsx +73 -0
  53. package/src/ui/settings-page.tsx +97 -0
@@ -0,0 +1,105 @@
1
+ import { getTranslation } from "@arch-cadre/intl/server";
2
+ import { Button } from "@arch-cadre/ui/components/button";
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@arch-cadre/ui/components/card";
10
+ import {
11
+ Tabs,
12
+ TabsContent,
13
+ TabsList,
14
+ TabsTrigger,
15
+ } from "@arch-cadre/ui/components/tabs";
16
+ import { Icon } from "@iconify/react";
17
+ import Link from "next/link";
18
+ import { notFound } from "next/navigation";
19
+ import * as React from "react";
20
+ import { getModuleDocumentation } from "../../../actions/manager";
21
+
22
+ export default async function ModuleDocsPage({
23
+ params,
24
+ }: {
25
+ params: { slug: string };
26
+ }) {
27
+ const { slug: moduleId } = await params;
28
+ if (!moduleId) return notFound();
29
+
30
+ const { t } = await getTranslation();
31
+
32
+ const docs = await getModuleDocumentation(moduleId);
33
+ if (!docs) {
34
+ return (
35
+ <div className="flex flex-col items-center justify-center py-20 space-y-4">
36
+ <Icon
37
+ icon="solar:document-text-broken"
38
+ className="size-16 text-muted-foreground"
39
+ />
40
+ <h2 className="text-xl font-bold">{t("Not Found")}</h2>
41
+ <p className="text-muted-foreground text-center max-w-md">
42
+ {t("Not found documentation for path", { path: "docs/" })}
43
+ </p>
44
+ <Button asChild variant="outline">
45
+ <Link href="/module/module-manager">{t("back")}</Link>
46
+ </Button>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="space-y-6">
53
+ <div className="flex items-center justify-between">
54
+ <div className="space-y-1">
55
+ <h2 className="text-3xl font-black tracking-tight">
56
+ {t("docs.title")}
57
+ </h2>
58
+ <p className="text-muted-foreground text-sm">
59
+ {t("docs.browsing")}{" "}
60
+ <span className="font-bold text-primary">{moduleId}</span>
61
+ </p>
62
+ </div>
63
+ <Button asChild variant="outline" size="sm">
64
+ <Link
65
+ href="/module/module-manager"
66
+ className="flex items-center gap-2"
67
+ >
68
+ <Icon icon="solar:arrow-left-broken" className="size-4" />
69
+ {t("docs.back")}
70
+ </Link>
71
+ </Button>
72
+ </div>
73
+
74
+ <Tabs defaultValue={docs[0].filename} className="w-full">
75
+ <TabsList className="bg-muted/50 mb-8">
76
+ {docs.map((doc) => (
77
+ <TabsTrigger key={doc.filename} value={doc.filename}>
78
+ {doc.title}
79
+ </TabsTrigger>
80
+ ))}
81
+ </TabsList>
82
+
83
+ {docs.map((doc) => (
84
+ <TabsContent key={doc.filename} value={doc.filename} className="mt-0">
85
+ <Card className="bg-card shadow-xl border-primary/5">
86
+ <CardHeader className="border-b bg-muted/10">
87
+ <CardTitle className="text-xl font-bold">{doc.title}</CardTitle>
88
+ <CardDescription className="font-mono text-[10px]">
89
+ {t("docs.file")} {doc.filename}
90
+ </CardDescription>
91
+ </CardHeader>
92
+ <CardContent className="pt-8">
93
+ <div className="prose prose-slate dark:prose-invert max-w-none">
94
+ <pre className="whitespace-pre-wrap font-sans text-base leading-relaxed bg-transparent border-none p-0 text-foreground">
95
+ {doc.content}
96
+ </pre>
97
+ </div>
98
+ </CardContent>
99
+ </Card>
100
+ </TabsContent>
101
+ ))}
102
+ </Tabs>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,30 @@
1
+ import { getTranslation } from "@arch-cadre/intl/server";
2
+
3
+ import { Icon } from "@iconify/react";
4
+ import * as React from "react";
5
+ import { getModulesAction } from "../../actions/manager";
6
+ import { ModuleMarketplaceList } from "../components/manager/module-list";
7
+ import { ModuleUpload } from "../components/manager/module-upload";
8
+
9
+ export default async function ModuleAdminPage() {
10
+ const modules = await getModulesAction();
11
+ const { t } = await getTranslation();
12
+
13
+ return (
14
+ <div className="space-y-6">
15
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
16
+ <div>
17
+ <h1 className="text-3xl font-black tracking-tight flex items-center gap-2">
18
+ {t("Modules Marketplace")}
19
+ </h1>
20
+ <p className="text-muted-foreground">
21
+ {t("Manage your installed modules")}
22
+ </p>
23
+ </div>
24
+ <ModuleUpload />
25
+ </div>
26
+
27
+ <ModuleMarketplaceList initialModules={modules} />
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,15 @@
1
+ import { getCurrentSession } from "@arch-cadre/core/server";
2
+ import { redirect } from "next/navigation";
3
+ import * as React from "react";
4
+
5
+ export default async function Page() {
6
+ const { session, user } = await getCurrentSession();
7
+
8
+ // Redirect unauthenticated users to signin
9
+ if (!session || !user) {
10
+ redirect("/signin");
11
+ }
12
+
13
+ // Redirect authenticated users to dashboard
14
+ redirect("/kryo/dashboard");
15
+ }
@@ -0,0 +1,551 @@
1
+ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <explanation> */
2
+ "use client";
3
+ import { useTranslation } from "@arch-cadre/intl";
4
+ import { Icon, toast } from "@arch-cadre/ui";
5
+ import { Button } from "@arch-cadre/ui/components/button";
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from "@arch-cadre/ui/components/card";
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ DialogTrigger,
20
+ } from "@arch-cadre/ui/components/dialog";
21
+ import { Input } from "@arch-cadre/ui/components/input";
22
+ import {
23
+ Table,
24
+ TableBody,
25
+ TableCell,
26
+ TableRow,
27
+ } from "@arch-cadre/ui/components/table";
28
+ import * as React from "react";
29
+ import { useCallback, useEffect, useState } from "react";
30
+ import {
31
+ assignPermissionToRole,
32
+ assignPermissionToUser,
33
+ assignRoleToUser,
34
+ createPermission,
35
+ createRole,
36
+ deletePermission,
37
+ deleteRole,
38
+ getPermissions,
39
+ getRolePermissions,
40
+ getRoles,
41
+ getUserRbacData,
42
+ getUsers,
43
+ revokePermissionFromRole,
44
+ revokePermissionFromUser,
45
+ revokeRoleFromUser,
46
+ } from "../../../actions/rbac/index";
47
+
48
+ export default function RbacAdminPage() {
49
+ const [roles, setRoles] = useState<any[]>([]);
50
+ const [permissions, setPermissions] = useState<any[]>([]);
51
+ const [users, setUsers] = useState<any[]>([]);
52
+ const [loading, setLoading] = useState(true);
53
+
54
+ const { t } = useTranslation();
55
+ // Form states
56
+ const [newRoleName, setNewRoleName] = useState("");
57
+ const [newPermissionName, setNewPermissionName] = useState("");
58
+
59
+ // Role-Permission mapping state
60
+ const [selectedRole, setSelectedRole] = useState<any | null>(null);
61
+ const [rolePermissions, setRolePermissions] = useState<any[]>([]);
62
+
63
+ // User-Role/Permission mapping state
64
+ const [selectedUser, setSelectedUser] = useState<any | null>(null);
65
+ const [userRbacData, setUserRbacData] = useState<{
66
+ roles: any[];
67
+ directPermissions: any[];
68
+ effectivePermissions: any[];
69
+ }>({
70
+ roles: [],
71
+ directPermissions: [],
72
+ effectivePermissions: [],
73
+ });
74
+
75
+ const loadData = useCallback(async () => {
76
+ setLoading(true);
77
+ try {
78
+ const [r, p, u] = await Promise.all([
79
+ getRoles(),
80
+ getPermissions(),
81
+ getUsers(),
82
+ ]);
83
+ setRoles(r);
84
+ setPermissions(p);
85
+ setUsers(u);
86
+ } catch (e) {
87
+ console.error("[RBAC] Failed to load initial data:", e);
88
+ toast.error(t("Failed to load RBAC data."));
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ }, []);
93
+
94
+ const loadRolePermissions = useCallback(async (roleId: string) => {
95
+ try {
96
+ const p = await getRolePermissions(roleId);
97
+ setRolePermissions(p);
98
+ } catch (e) {
99
+ console.error(`[RBAC] Failed to load permissions for role ${roleId}:`, e);
100
+ toast.error(t("Failed to load permissions for role."));
101
+ }
102
+ }, []);
103
+
104
+ const loadUserRbacData = useCallback(async (userId: string) => {
105
+ try {
106
+ const data = await getUserRbacData(userId);
107
+ setUserRbacData(data);
108
+ } catch (e) {
109
+ console.error(`[RBAC] Failed to load RBAC data for user ${userId}:`, e);
110
+ toast.error(t("Failed to load user RBAC data."));
111
+ }
112
+ }, []);
113
+
114
+ useEffect(() => {
115
+ loadData();
116
+ }, [loadData]);
117
+
118
+ useEffect(() => {
119
+ if (selectedRole?.id) {
120
+ loadRolePermissions(selectedRole.id);
121
+ }
122
+ }, [selectedRole?.id, loadRolePermissions]);
123
+
124
+ useEffect(() => {
125
+ if (selectedUser?.id) {
126
+ loadUserRbacData(selectedUser.id);
127
+ }
128
+ }, [selectedUser?.id, loadUserRbacData]);
129
+
130
+ const handleCreateRole = useCallback(async () => {
131
+ if (!newRoleName) return;
132
+ try {
133
+ await createRole(newRoleName);
134
+ setNewRoleName("");
135
+ toast.success(t("Role created."));
136
+ loadData();
137
+ } catch (_e) {
138
+ toast.error(t("Failed to create role."));
139
+ }
140
+ }, [newRoleName, loadData]);
141
+
142
+ const handleCreatePermission = useCallback(async () => {
143
+ if (!newPermissionName) return;
144
+ try {
145
+ await createPermission(newPermissionName);
146
+ setNewPermissionName("");
147
+ toast.success(t("Permission created."));
148
+ loadData();
149
+ } catch (_e) {
150
+ toast.error(t("Failed to create permission."));
151
+ }
152
+ }, [newPermissionName, loadData]);
153
+
154
+ const handleDeleteRole = useCallback(
155
+ async (id: string) => {
156
+ try {
157
+ await deleteRole(id);
158
+ toast.success(t("Role deleted."));
159
+ if (selectedRole?.id === id) setSelectedRole(null);
160
+ loadData();
161
+ } catch (_e) {
162
+ toast.error(t("Failed to delete role."));
163
+ }
164
+ },
165
+ [selectedRole, loadData],
166
+ );
167
+
168
+ const handleDeletePermission = useCallback(
169
+ async (id: string) => {
170
+ try {
171
+ await deletePermission(id);
172
+ toast.success(t("Permission deleted."));
173
+ loadData();
174
+ } catch (_e) {
175
+ toast.error(t("Failed to delete permission."));
176
+ }
177
+ },
178
+ [loadData],
179
+ );
180
+
181
+ const togglePermissionForRole = useCallback(
182
+ async (permId: string, hasIt: boolean) => {
183
+ if (!selectedRole) return;
184
+ try {
185
+ if (hasIt) {
186
+ await revokePermissionFromRole(selectedRole.id, permId);
187
+ } else {
188
+ await assignPermissionToRole(selectedRole.id, permId);
189
+ }
190
+ loadRolePermissions(selectedRole.id);
191
+ } catch (_e) {
192
+ toast.error(t("Action failed."));
193
+ }
194
+ },
195
+ [selectedRole, loadRolePermissions],
196
+ );
197
+
198
+ const toggleRoleForUser = useCallback(
199
+ async (roleId: string, hasIt: boolean) => {
200
+ if (!selectedUser) return;
201
+ try {
202
+ if (hasIt) {
203
+ await revokeRoleFromUser(selectedUser.id, roleId);
204
+ } else {
205
+ await assignRoleToUser(selectedUser.id, roleId);
206
+ }
207
+ loadUserRbacData(selectedUser.id);
208
+ } catch (_e) {
209
+ toast.error(t("Action failed."));
210
+ }
211
+ },
212
+ [selectedUser, loadUserRbacData],
213
+ );
214
+
215
+ const togglePermissionForUser = useCallback(
216
+ async (permId: string, hasIt: boolean) => {
217
+ if (!selectedUser) return;
218
+ try {
219
+ if (hasIt) {
220
+ await revokePermissionFromUser(selectedUser.id, permId);
221
+ } else {
222
+ await assignPermissionToUser(selectedUser.id, permId);
223
+ }
224
+ loadUserRbacData(selectedUser.id);
225
+ } catch (_e) {
226
+ toast.error(t("Action failed."));
227
+ }
228
+ },
229
+ [selectedUser, loadUserRbacData],
230
+ );
231
+
232
+ return (
233
+ <div className="space-y-6">
234
+ <div className="space-y-1">
235
+ <h1 className="text-3xl font-black tracking-tight flex items-center gap-2">
236
+ {/* <Icon
237
+ icon="solar:shield-keyhole-bold-duotone"
238
+ className="size-10 text-primary"
239
+ /> */}
240
+ {t("Role-Based Access Control Manager")}
241
+ </h1>
242
+ <p className="text-muted-foreground">
243
+ {t("Manage roles, permissions, and their relationships.")}
244
+ </p>
245
+ </div>
246
+
247
+ <div className="grid gap-6 lg:grid-cols-3">
248
+ {/* Roles Section */}
249
+ <Card className="border-primary/5 shadow-sm">
250
+ <CardHeader>
251
+ <CardTitle>{t("Roles")}</CardTitle>
252
+ <CardDescription>{t("System roles.")}</CardDescription>
253
+ </CardHeader>
254
+ <CardContent className="space-y-4">
255
+ <div className="flex gap-2">
256
+ <Input
257
+ placeholder={t("New role...")}
258
+ value={newRoleName}
259
+ onChange={(e) => setNewRoleName(e.target.value)}
260
+ />
261
+ <Button onClick={handleCreateRole}>{t("Add")}</Button>
262
+ </div>
263
+ <div className="border rounded-md max-h-[300px] overflow-auto">
264
+ <Table>
265
+ <TableBody>
266
+ {roles.map((role) => (
267
+ <TableRow
268
+ key={role.id}
269
+ className={
270
+ selectedRole?.id === role.id ? "bg-primary/5" : ""
271
+ }
272
+ >
273
+ <TableCell
274
+ className="font-medium cursor-pointer"
275
+ onClick={() => setSelectedRole(role)}
276
+ >
277
+ {role.name}
278
+ </TableCell>
279
+ <TableCell className="text-right">
280
+ <Button
281
+ variant="ghost"
282
+ size="icon"
283
+ onClick={() => handleDeleteRole(role.id)}
284
+ >
285
+ <Icon
286
+ icon="solar:trash-bin-trash-bold"
287
+ className="size-4 text-destructive"
288
+ />
289
+ </Button>
290
+ </TableCell>
291
+ </TableRow>
292
+ ))}
293
+ </TableBody>
294
+ </Table>
295
+ </div>
296
+ </CardContent>
297
+ </Card>
298
+
299
+ {/* Permissions Section */}
300
+ <Card className="border-primary/5 shadow-sm">
301
+ <CardHeader>
302
+ <CardTitle>{t("Permissions")}</CardTitle>
303
+ <CardDescription>{t("System permissions.")}</CardDescription>
304
+ </CardHeader>
305
+ <CardContent className="space-y-4">
306
+ <div className="flex gap-2">
307
+ <Input
308
+ placeholder={t("New permission...")}
309
+ value={newPermissionName}
310
+ onChange={(e) => setNewPermissionName(e.target.value)}
311
+ />
312
+ <Button onClick={handleCreatePermission}>{t("Add")}</Button>
313
+ </div>
314
+ <div className="border rounded-md max-h-[300px] overflow-auto">
315
+ <Table>
316
+ <TableBody>
317
+ {permissions.map((perm) => (
318
+ <TableRow key={perm.id}>
319
+ <TableCell className="font-medium">{perm.name}</TableCell>
320
+ <TableCell className="text-right">
321
+ <Button
322
+ variant="ghost"
323
+ size="icon"
324
+ onClick={() => handleDeletePermission(perm.id)}
325
+ >
326
+ <Icon
327
+ icon="solar:trash-bin-trash-bold"
328
+ className="size-4 text-destructive"
329
+ />
330
+ </Button>
331
+ </TableCell>
332
+ </TableRow>
333
+ ))}
334
+ </TableBody>
335
+ </Table>
336
+ </div>
337
+ </CardContent>
338
+ </Card>
339
+
340
+ {/* Users Section */}
341
+ <Card className="border-primary/5 shadow-sm">
342
+ <CardHeader>
343
+ <CardTitle>{t("Users")}</CardTitle>
344
+ <CardDescription>{t("Manage user roles.")}</CardDescription>
345
+ </CardHeader>
346
+ <CardContent className="space-y-4">
347
+ <div className="border rounded-md max-h-[358px] overflow-auto">
348
+ <Table>
349
+ <TableBody>
350
+ {users.map((user) => (
351
+ <TableRow
352
+ key={user.id}
353
+ className={
354
+ selectedUser?.id === user.id ? "bg-primary/5" : ""
355
+ }
356
+ >
357
+ <TableCell
358
+ className="font-medium cursor-pointer"
359
+ onClick={() => setSelectedUser(user)}
360
+ >
361
+ <div>{user.name}</div>
362
+ <div className="text-[10px] text-muted-foreground">
363
+ {user.email}
364
+ </div>
365
+ </TableCell>
366
+ </TableRow>
367
+ ))}
368
+ </TableBody>
369
+ </Table>
370
+ </div>
371
+ </CardContent>
372
+ </Card>
373
+ </div>
374
+
375
+ {/* Role-Permission Assignment */}
376
+ <Dialog
377
+ open={!!selectedRole}
378
+ onOpenChange={(open) => !open && setSelectedRole(null)}
379
+ >
380
+ <DialogContent>
381
+ <DialogHeader>
382
+ <DialogTitle>
383
+ {t("Edit Role: {roleName}", { roleName: selectedRole?.name })}
384
+ </DialogTitle>
385
+ <DialogDescription>
386
+ {t("Manage permissions for role {roleName}", {
387
+ roleName: selectedRole?.name,
388
+ })}
389
+ </DialogDescription>
390
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
391
+ {permissions.map((perm) => {
392
+ const hasIt = rolePermissions.some((rp) => rp.id === perm.id);
393
+ return (
394
+ <Button
395
+ key={perm.id}
396
+ variant={hasIt ? "default" : "outline"}
397
+ size="sm"
398
+ className="justify-start gap-2 h-auto py-2 px-3 overflow-hidden"
399
+ onClick={() => togglePermissionForRole(perm.id, hasIt)}
400
+ >
401
+ <Icon
402
+ icon={
403
+ hasIt
404
+ ? "solar:check-circle-bold"
405
+ : "solar:circle-linear"
406
+ }
407
+ className={
408
+ hasIt
409
+ ? "text-primary-foreground"
410
+ : "text-muted-foreground"
411
+ }
412
+ />
413
+ <span className="truncate text-xs">{perm.name}</span>
414
+ </Button>
415
+ );
416
+ })}
417
+ </div>
418
+ </DialogHeader>
419
+ </DialogContent>
420
+ </Dialog>
421
+
422
+ {/* User-Role Assignment */}
423
+ <Dialog
424
+ open={!!selectedUser}
425
+ onOpenChange={(open) => !open && setSelectedUser(null)}
426
+ >
427
+ <DialogContent>
428
+ <DialogHeader>
429
+ <DialogTitle>
430
+ {t("Management for {selectedUser}", {
431
+ selectedUser: selectedUser?.name,
432
+ })}
433
+ </DialogTitle>
434
+ <DialogDescription>
435
+ {t("Manage roles and permissions for {selectedUser}", {
436
+ selectedUser: selectedUser?.name,
437
+ })}
438
+ </DialogDescription>
439
+ <div className="space-y-6">
440
+ <div>
441
+ <p className="text-[10px] font-black uppercase text-muted-foreground tracking-widest mb-3">
442
+ {t("User Roles")}
443
+ </p>
444
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
445
+ {roles.map((role) => {
446
+ const hasIt = userRbacData.roles.some(
447
+ (ur) => ur.id === role.id,
448
+ );
449
+ return (
450
+ <Button
451
+ key={role.id}
452
+ variant={hasIt ? "default" : "outline"}
453
+ size="sm"
454
+ className="justify-start gap-2 h-auto py-2 px-3 overflow-hidden"
455
+ onClick={() => toggleRoleForUser(role.id, hasIt)}
456
+ >
457
+ <Icon
458
+ icon={
459
+ hasIt
460
+ ? "solar:check-circle-bold"
461
+ : "solar:circle-linear"
462
+ }
463
+ className={
464
+ hasIt
465
+ ? "text-primary-foreground"
466
+ : "text-muted-foreground"
467
+ }
468
+ />
469
+ <span className="truncate text-xs">{role.name}</span>
470
+ </Button>
471
+ );
472
+ })}
473
+ </div>
474
+ </div>
475
+
476
+ <div>
477
+ <p className="text-[10px] font-black uppercase text-muted-foreground tracking-widest mb-3">
478
+ {t("Direct Permissions (User-specific)")}
479
+ </p>
480
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
481
+ {permissions.map((perm) => {
482
+ // Check if permission is directly assigned to user
483
+ // We need directPermissions from the action
484
+ const isDirect = userRbacData.directPermissions.some(
485
+ (up) => up.id === perm.id,
486
+ );
487
+ return (
488
+ <Button
489
+ key={perm.id}
490
+ variant={isDirect ? "default" : "outline"}
491
+ size="sm"
492
+ className="justify-start gap-2 h-auto py-2 px-3 overflow-hidden"
493
+ onClick={() =>
494
+ togglePermissionForUser(perm.id, isDirect)
495
+ }
496
+ >
497
+ <Icon
498
+ icon={
499
+ isDirect
500
+ ? "solar:check-circle-bold"
501
+ : "solar:circle-linear"
502
+ }
503
+ className={
504
+ isDirect
505
+ ? "text-primary-foreground"
506
+ : "text-muted-foreground"
507
+ }
508
+ />
509
+ <span className="truncate text-xs">{perm.name}</span>
510
+ </Button>
511
+ );
512
+ })}
513
+ </div>
514
+ </div>
515
+
516
+ <div className="pt-4 border-t">
517
+ <p className="text-[10px] font-black uppercase text-muted-foreground tracking-widest mb-2">
518
+ {t("Effective Permissions (inherited + direct)")}
519
+ </p>
520
+ <div className="flex flex-wrap gap-1">
521
+ {userRbacData.effectivePermissions.map((p) => (
522
+ <div
523
+ key={p.id}
524
+ className="bg-muted px-2 py-0.5 rounded text-[9px] font-mono"
525
+ >
526
+ {p.name}
527
+ </div>
528
+ ))}
529
+ {userRbacData.effectivePermissions.length === 0 && (
530
+ <span className="text-[10px] italic">
531
+ {t("No permissions")}
532
+ </span>
533
+ )}
534
+ </div>
535
+ </div>
536
+ </div>
537
+ </DialogHeader>
538
+ </DialogContent>
539
+ </Dialog>
540
+
541
+ {loading && (
542
+ <div className="fixed inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
543
+ <Icon
544
+ icon="svg-spinners:18-dots-indicator"
545
+ className="size-12 text-primary"
546
+ />
547
+ </div>
548
+ )}
549
+ </div>
550
+ );
551
+ }