@nsxbet/admin-sdk 0.8.1 → 0.9.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.
Files changed (45) hide show
  1. package/README.md +16 -0
  2. package/dist/auth/client/bff.js +19 -14
  3. package/dist/auth/client/in-memory.js +11 -14
  4. package/dist/auth/client/interface.d.ts +5 -0
  5. package/dist/auth/client/permission-match.d.ts +1 -0
  6. package/dist/auth/client/permission-match.js +13 -0
  7. package/dist/auth/client/rbac-resolution.d.ts +29 -0
  8. package/dist/auth/client/rbac-resolution.js +22 -0
  9. package/dist/auth/client/resolved-role-cache.d.ts +11 -0
  10. package/dist/auth/client/resolved-role-cache.js +54 -0
  11. package/dist/components/AuthProvider.d.ts +3 -1
  12. package/dist/components/AuthProvider.js +95 -23
  13. package/dist/i18n/locales/en-US.json +66 -1
  14. package/dist/i18n/locales/es.json +66 -1
  15. package/dist/i18n/locales/pt-BR.json +66 -1
  16. package/dist/i18n/locales/ro.json +66 -1
  17. package/dist/sdk-version.js +1 -1
  18. package/dist/shell/AdminShell.js +13 -8
  19. package/dist/shell/components/HomePage.js +1 -1
  20. package/dist/shell/components/LeftNav.js +46 -4
  21. package/dist/shell/components/MainContent.js +25 -0
  22. package/dist/shell/components/RegistryPage.js +3 -3
  23. package/dist/shell/components/access-control/AccessControlAuditPage.d.ts +1 -0
  24. package/dist/shell/components/access-control/AccessControlAuditPage.js +135 -0
  25. package/dist/shell/components/access-control/AccessControlGroupDetailPage.d.ts +1 -0
  26. package/dist/shell/components/access-control/AccessControlGroupDetailPage.js +224 -0
  27. package/dist/shell/components/access-control/AccessControlGroupsPage.d.ts +1 -0
  28. package/dist/shell/components/access-control/AccessControlGroupsPage.js +183 -0
  29. package/dist/shell/components/access-control/AccessControlLayout.d.ts +8 -0
  30. package/dist/shell/components/access-control/AccessControlLayout.js +23 -0
  31. package/dist/shell/components/access-control/AccessControlMemberPicker.d.ts +10 -0
  32. package/dist/shell/components/access-control/AccessControlMemberPicker.js +44 -0
  33. package/dist/shell/components/access-control/AccessControlPermissionPicker.d.ts +8 -0
  34. package/dist/shell/components/access-control/AccessControlPermissionPicker.js +38 -0
  35. package/dist/shell/components/access-control/AccessControlUserPage.d.ts +1 -0
  36. package/dist/shell/components/access-control/AccessControlUserPage.js +42 -0
  37. package/dist/shell/components/access-control/AccessControlUsersListPage.d.ts +1 -0
  38. package/dist/shell/components/access-control/AccessControlUsersListPage.js +111 -0
  39. package/dist/shell/components/access-control/api.d.ts +111 -0
  40. package/dist/shell/components/access-control/api.js +119 -0
  41. package/dist/shell/components/access-control/index.d.ts +8 -0
  42. package/dist/shell/components/access-control/index.js +8 -0
  43. package/dist/shell/components/index.d.ts +1 -0
  44. package/dist/shell/components/index.js +1 -0
  45. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useOutletContext, useParams } from "react-router-dom";
4
+ import { Button, DataTable, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, EmptyState, Input, LoadingState, Badge, StatusBadge, } from "@nsxbet/admin-ui";
5
+ import { useFetch } from "../../../hooks/useFetch";
6
+ import { useAuth } from "../../../hooks/useAuth";
7
+ import { useI18n } from "../../../hooks/useI18n";
8
+ import { createAccessControlApi, } from "./api";
9
+ import { AccessControlMemberPicker } from "./AccessControlMemberPicker";
10
+ import { AccessControlPermissionPicker } from "./AccessControlPermissionPicker";
11
+ export function AccessControlGroupDetailPage() {
12
+ const { groupId: groupIdParam } = useParams();
13
+ const groupId = groupIdParam ? parseInt(groupIdParam, 10) : undefined;
14
+ const { t } = useI18n();
15
+ const fetcher = useFetch();
16
+ const auth = useAuth();
17
+ const { apiUrl } = useOutletContext();
18
+ const api = useMemo(() => createAccessControlApi(fetcher, apiUrl), [fetcher, apiUrl]);
19
+ const [group, setGroup] = useState(null);
20
+ const [catalogItems, setCatalogItems] = useState([]);
21
+ const [knownUsers, setKnownUsers] = useState([]);
22
+ const [isLoadingUsers, setIsLoadingUsers] = useState(false);
23
+ const [isLoading, setIsLoading] = useState(true);
24
+ const [error, setError] = useState(null);
25
+ const canManage = auth.hasPermission("admin.platform.access-control.groups.manage");
26
+ const mountedRef = useRef(true);
27
+ const currentUser = auth.getUser();
28
+ const [selfRemoveTarget, setSelfRemoveTarget] = useState(null);
29
+ const [selfRemoveConfirmText, setSelfRemoveConfirmText] = useState("");
30
+ useEffect(() => {
31
+ mountedRef.current = true;
32
+ return () => { mountedRef.current = false; };
33
+ }, []);
34
+ const loadGroup = useCallback(async () => {
35
+ if (!groupId)
36
+ return;
37
+ try {
38
+ setIsLoading(true);
39
+ setError(null);
40
+ const [nextGroup, catalog] = await Promise.all([
41
+ api.getGroupDetail(groupId),
42
+ api.getPermissionCatalog(),
43
+ ]);
44
+ setGroup(nextGroup);
45
+ setCatalogItems(catalog.items);
46
+ }
47
+ catch (nextError) {
48
+ setError(nextError instanceof Error ? nextError.message : t("errors.generic"));
49
+ }
50
+ finally {
51
+ setIsLoading(false);
52
+ }
53
+ }, [api, groupId, t]);
54
+ const revalidateGroup = useCallback(async () => {
55
+ if (!groupId)
56
+ return;
57
+ try {
58
+ const [nextGroup, catalog] = await Promise.all([
59
+ api.getGroupDetail(groupId),
60
+ api.getPermissionCatalog(),
61
+ ]);
62
+ if (mountedRef.current) {
63
+ setGroup(nextGroup);
64
+ setCatalogItems(catalog.items);
65
+ }
66
+ }
67
+ catch {
68
+ // Silent revalidation failure is acceptable
69
+ }
70
+ }, [api, groupId]);
71
+ useEffect(() => {
72
+ loadGroup();
73
+ }, [loadGroup]);
74
+ const loadKnownUsers = useCallback(async (search) => {
75
+ setIsLoadingUsers(true);
76
+ try {
77
+ const page = await api.listKnownUsers({ search: search || undefined, limit: 50 });
78
+ setKnownUsers(page.items);
79
+ }
80
+ finally {
81
+ setIsLoadingUsers(false);
82
+ }
83
+ }, [api]);
84
+ const handleAddMembers = useCallback(async (userIds) => {
85
+ if (!groupId || !group)
86
+ return;
87
+ const newMembers = knownUsers.filter((u) => userIds.includes(u.id));
88
+ setGroup((prev) => prev
89
+ ? { ...prev, members: [...prev.members, ...newMembers.map((u) => ({ ...u }))] }
90
+ : prev);
91
+ try {
92
+ await api.addGroupMembers(groupId, userIds);
93
+ revalidateGroup();
94
+ }
95
+ catch {
96
+ setGroup(group);
97
+ }
98
+ }, [api, groupId, group, knownUsers, revalidateGroup]);
99
+ const executeRemoveMember = useCallback(async (userId) => {
100
+ if (!groupId || !group)
101
+ return;
102
+ const previousMembers = group.members;
103
+ setGroup((prev) => prev ? { ...prev, members: prev.members.filter((m) => m.id !== userId) } : prev);
104
+ try {
105
+ await api.removeGroupMember(groupId, userId);
106
+ revalidateGroup();
107
+ }
108
+ catch {
109
+ setGroup((prev) => prev ? { ...prev, members: previousMembers } : prev);
110
+ }
111
+ }, [api, groupId, group, revalidateGroup]);
112
+ const handleRemoveMember = useCallback((userId) => {
113
+ if (!group)
114
+ return;
115
+ const member = group.members.find((m) => m.id === userId);
116
+ if (member && group.isProtected && member.subject === currentUser.id) {
117
+ setSelfRemoveTarget(member);
118
+ setSelfRemoveConfirmText("");
119
+ return;
120
+ }
121
+ executeRemoveMember(userId);
122
+ }, [group, currentUser.id, executeRemoveMember]);
123
+ const handleConfirmSelfRemove = useCallback(async () => {
124
+ if (!selfRemoveTarget)
125
+ return;
126
+ setSelfRemoveTarget(null);
127
+ setSelfRemoveConfirmText("");
128
+ await executeRemoveMember(selfRemoveTarget.id);
129
+ }, [selfRemoveTarget, executeRemoveMember]);
130
+ const handleAssignPermissions = useCallback(async (permissions) => {
131
+ if (!groupId || !group)
132
+ return;
133
+ setGroup((prev) => prev
134
+ ? { ...prev, permissions: [...prev.permissions, ...permissions] }
135
+ : prev);
136
+ try {
137
+ await api.assignGroupPermissions(groupId, permissions);
138
+ revalidateGroup();
139
+ }
140
+ catch {
141
+ setGroup(group);
142
+ }
143
+ }, [api, groupId, group, revalidateGroup]);
144
+ const handleRemovePermission = useCallback(async (permission) => {
145
+ if (!groupId || !group)
146
+ return;
147
+ const previousPermissions = group.permissions;
148
+ setGroup((prev) => prev
149
+ ? { ...prev, permissions: prev.permissions.filter((p) => p !== permission) }
150
+ : prev);
151
+ try {
152
+ await api.removeGroupPermission(groupId, permission);
153
+ revalidateGroup();
154
+ }
155
+ catch {
156
+ setGroup((prev) => prev ? { ...prev, permissions: previousPermissions } : prev);
157
+ }
158
+ }, [api, groupId, group, revalidateGroup]);
159
+ const memberColumns = useMemo(() => [
160
+ {
161
+ accessor: "displayName",
162
+ header: t("accessControlPage.columnName"),
163
+ },
164
+ {
165
+ accessor: "email",
166
+ header: t("accessControlPage.columnEmail"),
167
+ },
168
+ {
169
+ accessor: "subject",
170
+ header: t("accessControlPage.columnSubject"),
171
+ cell: (_value, row) => (_jsx(Badge, { variant: "outline", className: "font-mono text-[10px]", children: row.subject })),
172
+ },
173
+ ...(canManage && group
174
+ ? [
175
+ {
176
+ accessor: "id",
177
+ header: "",
178
+ cell: (_value, row) => (_jsx(Button, { variant: "ghost", size: "sm", className: "text-destructive hover:text-destructive", onClick: () => handleRemoveMember(row.id), children: t("common.delete") })),
179
+ },
180
+ ]
181
+ : []),
182
+ ], [t, canManage, group, handleRemoveMember]);
183
+ const permissionRows = useMemo(() => {
184
+ if (!group)
185
+ return [];
186
+ return group.permissions.map((permission) => {
187
+ const catalogItem = catalogItems.find((item) => item.permission === permission);
188
+ return { permission, orphaned: catalogItem?.orphaned ?? false };
189
+ });
190
+ }, [group, catalogItems]);
191
+ const permissionColumns = useMemo(() => [
192
+ {
193
+ accessor: "permission",
194
+ header: t("accessControlPage.permissionsTab"),
195
+ cell: (_value, row) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-mono text-sm", children: row.permission }), row.orphaned && (_jsx(Badge, { variant: "secondary", className: "text-[10px]", children: "Orphaned" }))] })),
196
+ },
197
+ ...(canManage && group && !group.isProtected
198
+ ? [
199
+ {
200
+ accessor: "orphaned",
201
+ header: "",
202
+ cell: (_value, row) => (_jsx(Button, { variant: "ghost", size: "sm", className: "text-destructive hover:text-destructive", onClick: () => handleRemovePermission(row.permission), children: t("common.delete") })),
203
+ },
204
+ ]
205
+ : []),
206
+ ], [t, canManage, group, handleRemovePermission]);
207
+ if (isLoading) {
208
+ return _jsx(LoadingState, { text: t("common.loading") });
209
+ }
210
+ if (error || !group) {
211
+ return (_jsx(EmptyState, { title: t("common.error"), description: error ?? t("errors.notFound") }));
212
+ }
213
+ return (_jsxs("section", { "data-testid": "access-control-group-detail-page", className: "space-y-6", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-xl font-semibold", children: group.displayName }), _jsx("p", { className: "text-sm text-muted-foreground font-mono", children: group.id })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(StatusBadge, { status: group.deactivatedAt ? "inactive" : "active", label: group.deactivatedAt ? t("common.disabled") : t("common.enabled") }), group.isProtected && (_jsx(Badge, { variant: "secondary", children: t("accessControlPage.protectedGroup") }))] })] }), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("h3", { className: "text-lg font-semibold", children: [t("accessControlPage.membersTab"), _jsxs("span", { className: "ml-2 text-sm font-normal text-muted-foreground", children: ["(", group.members.length, ")"] })] }), canManage && (_jsx(AccessControlMemberPicker, { existingMemberIds: group.members.map((m) => m.id), isLoading: isLoadingUsers, users: knownUsers, onSearchChange: loadKnownUsers, onAddMembers: handleAddMembers }))] }), _jsx(DataTable, { data: group.members, columns: memberColumns, emptyState: _jsx(EmptyState, { title: t("accessControlPage.noMembers"), className: "min-h-[120px]" }) })] }), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("h3", { className: "text-lg font-semibold", children: [t("accessControlPage.permissionsTab"), _jsxs("span", { className: "ml-2 text-sm font-normal text-muted-foreground", children: ["(", group.permissions.length, ")"] })] }), canManage && !group.isProtected && (_jsx(AccessControlPermissionPicker, { items: catalogItems, assignedPermissions: group.permissions, onAssignPermissions: handleAssignPermissions }))] }), _jsx(DataTable, { data: permissionRows, columns: permissionColumns, emptyState: _jsx(EmptyState, { title: t("accessControlPage.noPermissions"), className: "min-h-[120px]" }) })] }), _jsx(Dialog, { open: selfRemoveTarget !== null, onOpenChange: (nextOpen) => {
214
+ if (!nextOpen) {
215
+ setSelfRemoveTarget(null);
216
+ setSelfRemoveConfirmText("");
217
+ }
218
+ }, children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { className: "text-destructive", children: t("accessControlPage.selfRemoveTitle") }), _jsx(DialogDescription, { children: t("accessControlPage.selfRemoveDescription", {
219
+ group: group?.displayName ?? "",
220
+ }) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("accessControlPage.selfRemovePrompt") }), _jsx(Input, { value: selfRemoveConfirmText, onChange: (event) => setSelfRemoveConfirmText(event.target.value), placeholder: "CONFIRM", autoFocus: true })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => {
221
+ setSelfRemoveTarget(null);
222
+ setSelfRemoveConfirmText("");
223
+ }, children: t("common.cancel") }), _jsx(Button, { variant: "destructive", disabled: selfRemoveConfirmText !== "CONFIRM", onClick: handleConfirmSelfRemove, children: t("accessControlPage.selfRemoveConfirmButton") })] })] }) })] }));
224
+ }
@@ -0,0 +1 @@
1
+ export declare function AccessControlGroupsPage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,183 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useNavigate, useOutletContext } from "react-router-dom";
4
+ import { Button, DataTable, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, EmptyState, Input, LoadingState, Badge, StatusBadge, } from "@nsxbet/admin-ui";
5
+ import { useFetch } from "../../../hooks/useFetch";
6
+ import { useAuth } from "../../../hooks/useAuth";
7
+ import { useI18n } from "../../../hooks/useI18n";
8
+ import { createAccessControlApi } from "./api";
9
+ const PAGE_SIZE = 20;
10
+ export function AccessControlGroupsPage() {
11
+ const { t } = useI18n();
12
+ const fetcher = useFetch();
13
+ const auth = useAuth();
14
+ const navigate = useNavigate();
15
+ const { apiUrl } = useOutletContext();
16
+ const api = useMemo(() => createAccessControlApi(fetcher, apiUrl), [fetcher, apiUrl]);
17
+ const apiRef = useRef(api);
18
+ apiRef.current = api;
19
+ const [statusFilter, setStatusFilter] = useState("all");
20
+ const [search, setSearch] = useState("");
21
+ const [committedSearch, setCommittedSearch] = useState("");
22
+ const [groups, setGroups] = useState([]);
23
+ const [hasLoaded, setHasLoaded] = useState(false);
24
+ const [isFiltering, setIsFiltering] = useState(false);
25
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
26
+ const [error, setError] = useState(null);
27
+ const [nextCursor, setNextCursor] = useState(null);
28
+ const sentinelRef = useRef(null);
29
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
30
+ const [createName, setCreateName] = useState("");
31
+ const [editTarget, setEditTarget] = useState(null);
32
+ const [editName, setEditName] = useState("");
33
+ const [toggleTarget, setToggleTarget] = useState(null);
34
+ const lastToggleTarget = useRef(null);
35
+ if (toggleTarget)
36
+ lastToggleTarget.current = toggleTarget;
37
+ const toggleDisplay = toggleTarget ?? lastToggleTarget.current;
38
+ const canManage = auth.hasPermission("admin.platform.access-control.groups.manage");
39
+ const commitSearch = useCallback(() => setCommittedSearch(search), [search]);
40
+ const filterParams = useMemo(() => ({
41
+ status: statusFilter === "all" ? undefined : statusFilter,
42
+ search: committedSearch || undefined,
43
+ limit: PAGE_SIZE,
44
+ }), [statusFilter, committedSearch]);
45
+ useEffect(() => {
46
+ let cancelled = false;
47
+ const load = async () => {
48
+ try {
49
+ setIsFiltering(true);
50
+ setError(null);
51
+ const page = await apiRef.current.listGroups(filterParams);
52
+ if (cancelled)
53
+ return;
54
+ setGroups(page.items);
55
+ setNextCursor(page.nextCursor);
56
+ setHasLoaded(true);
57
+ }
58
+ catch (nextError) {
59
+ if (cancelled)
60
+ return;
61
+ setError(nextError instanceof Error ? nextError.message : "Unknown error");
62
+ }
63
+ finally {
64
+ if (!cancelled)
65
+ setIsFiltering(false);
66
+ }
67
+ };
68
+ load();
69
+ return () => { cancelled = true; };
70
+ }, [filterParams]);
71
+ const loadMoreRef = useRef();
72
+ const loadMore = useCallback(async () => {
73
+ if (!nextCursor || isLoadingMore)
74
+ return;
75
+ try {
76
+ setIsLoadingMore(true);
77
+ const page = await apiRef.current.listGroups({ ...filterParams, cursor: nextCursor });
78
+ setGroups((prev) => [...prev, ...page.items]);
79
+ setNextCursor(page.nextCursor);
80
+ }
81
+ catch {
82
+ // silent
83
+ }
84
+ finally {
85
+ setIsLoadingMore(false);
86
+ }
87
+ }, [filterParams, nextCursor, isLoadingMore]);
88
+ loadMoreRef.current = loadMore;
89
+ useEffect(() => {
90
+ const sentinel = sentinelRef.current;
91
+ if (!sentinel || !nextCursor)
92
+ return;
93
+ const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting)
94
+ loadMoreRef.current?.(); }, { rootMargin: "200px" });
95
+ observer.observe(sentinel);
96
+ return () => observer.disconnect();
97
+ }, [nextCursor]);
98
+ const reload = useCallback(async () => {
99
+ try {
100
+ setIsFiltering(true);
101
+ const page = await apiRef.current.listGroups(filterParams);
102
+ setGroups(page.items);
103
+ setNextCursor(page.nextCursor);
104
+ }
105
+ catch {
106
+ // silent
107
+ }
108
+ finally {
109
+ setIsFiltering(false);
110
+ }
111
+ }, [filterParams]);
112
+ const handleCreate = async () => {
113
+ if (!createName.trim())
114
+ return;
115
+ await apiRef.current.createGroup(createName.trim());
116
+ setCreateName("");
117
+ setCreateDialogOpen(false);
118
+ await reload();
119
+ };
120
+ const openEdit = (group) => {
121
+ setEditTarget(group);
122
+ setEditName(group.displayName);
123
+ };
124
+ const handleEdit = async () => {
125
+ if (!editTarget || !editName.trim())
126
+ return;
127
+ await apiRef.current.updateGroup(editTarget.id, editName.trim());
128
+ setEditTarget(null);
129
+ setEditName("");
130
+ await reload();
131
+ };
132
+ const handleConfirmToggleActive = async () => {
133
+ if (!toggleTarget)
134
+ return;
135
+ if (toggleTarget.deactivatedAt) {
136
+ await apiRef.current.reactivateGroup(toggleTarget.id);
137
+ }
138
+ else {
139
+ await apiRef.current.deactivateGroup(toggleTarget.id);
140
+ }
141
+ setToggleTarget(null);
142
+ await reload();
143
+ };
144
+ const columns = useMemo(() => [
145
+ {
146
+ accessor: "displayName",
147
+ header: t("accessControlPage.columnName"),
148
+ cell: (_value, row) => (_jsx("button", { className: "font-medium text-primary hover:underline", onClick: () => navigate(`/_access-control/groups/${row.id}`), children: row.displayName })),
149
+ },
150
+ {
151
+ accessor: "deactivatedAt",
152
+ header: t("common.status"),
153
+ cell: (_value, row) => (_jsx(StatusBadge, { status: row.deactivatedAt ? "inactive" : "active", label: row.deactivatedAt ? t("common.disabled") : t("common.enabled") })),
154
+ },
155
+ {
156
+ accessor: "isProtected",
157
+ header: t("accessControlPage.columnProtected"),
158
+ cell: (_value, row) => row.isProtected ? (_jsx(Badge, { variant: "secondary", children: t("accessControlPage.protectedGroup") })) : null,
159
+ },
160
+ {
161
+ accessor: "id",
162
+ header: t("common.actions"),
163
+ sticky: "right",
164
+ cell: (_value, row) => (_jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", onClick: () => navigate(`/_access-control/groups/${row.id}`), children: t("accessControlPage.viewGroup") }), canManage && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outline", size: "sm", disabled: row.isProtected, onClick: () => openEdit(row), children: t("common.edit") }), _jsx(Button, { variant: "outline", size: "sm", disabled: row.isProtected, onClick: () => setToggleTarget(row), children: row.deactivatedAt ? t("accessControlPage.reactivate") : t("accessControlPage.deactivate") })] }))] })),
165
+ },
166
+ ], [t, navigate, canManage]);
167
+ const showInitialLoading = !hasLoaded && isFiltering;
168
+ const showTable = hasLoaded && !error;
169
+ return (_jsxs("section", { "data-testid": "access-control-groups-page", className: "space-y-4", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsx("h2", { className: "text-xl font-semibold", children: t("accessControlPage.groupsTitle") }), canManage && (_jsx(Button, { onClick: () => { setCreateName(""); setCreateDialogOpen(true); }, "data-testid": "access-control-create-group", children: t("common.create") }))] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(Input, { placeholder: t("common.search"), value: search, onChange: (event) => setSearch(event.target.value), onKeyDown: (event) => { if (event.key === "Enter")
170
+ commitSearch(); }, onBlur: commitSearch, className: "max-w-xs" }), _jsx("div", { className: "flex gap-2", children: ["all", "active", "inactive"].map((status) => (_jsx(Button, { variant: statusFilter === status ? "default" : "outline", size: "sm", onClick: () => setStatusFilter(status), "data-testid": `access-control-status-${status}`, children: t(`accessControlPage.status${status.charAt(0).toUpperCase()}${status.slice(1)}`) }, status))) })] }), showInitialLoading ? (_jsx(LoadingState, { text: t("common.loading") })) : error ? (_jsx(EmptyState, { title: t("common.error"), description: error, action: { label: t("common.retry"), onClick: () => reload() } })) : showTable ? (_jsxs("div", { children: [_jsx("div", { className: isFiltering
171
+ ? "pointer-events-none opacity-50 transition-opacity duration-200"
172
+ : "transition-opacity duration-200", children: _jsx(DataTable, { data: groups, columns: columns, emptyState: _jsx(EmptyState, { title: t("accessControlPage.noGroupsTitle"), description: t("accessControlPage.noGroupsDescription") }) }) }), nextCursor !== null && (_jsx("div", { ref: sentinelRef, className: "flex justify-center py-4", children: isLoadingMore && (_jsx("div", { className: "min-h-0 py-2", children: _jsx(LoadingState, { text: t("common.loading") }) })) }))] })) : null, _jsx(Dialog, { open: createDialogOpen, onOpenChange: setCreateDialogOpen, children: _jsxs(DialogContent, { className: "max-w-sm", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t("common.create") }), _jsx(DialogDescription, { children: t("accessControlPage.promptCreateGroup") })] }), _jsx(Input, { placeholder: t("accessControlPage.columnName"), value: createName, onChange: (event) => setCreateName(event.target.value), autoFocus: true, onKeyDown: (event) => { if (event.key === "Enter")
173
+ handleCreate(); } }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setCreateDialogOpen(false), children: t("common.cancel") }), _jsx(Button, { onClick: handleCreate, disabled: !createName.trim(), children: t("common.create") })] })] }) }), _jsx(Dialog, { open: editTarget !== null, onOpenChange: (open) => { if (!open)
174
+ setEditTarget(null); }, children: _jsxs(DialogContent, { className: "max-w-sm", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t("common.edit") }), _jsx(DialogDescription, { children: t("accessControlPage.promptEditGroup") })] }), _jsx(Input, { placeholder: t("accessControlPage.columnName"), value: editName, onChange: (event) => setEditName(event.target.value), autoFocus: true, onKeyDown: (event) => { if (event.key === "Enter")
175
+ handleEdit(); } }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setEditTarget(null), children: t("common.cancel") }), _jsx(Button, { onClick: handleEdit, disabled: !editName.trim(), children: t("common.save") })] })] }) }), _jsx(Dialog, { open: toggleTarget !== null, onOpenChange: (open) => { if (!open)
176
+ setToggleTarget(null); }, children: _jsxs(DialogContent, { className: "max-w-sm", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: toggleDisplay?.deactivatedAt
177
+ ? t("accessControlPage.reactivate")
178
+ : t("accessControlPage.deactivate") }), _jsx(DialogDescription, { children: toggleDisplay?.deactivatedAt
179
+ ? t("accessControlPage.confirmReactivate", { group: toggleDisplay?.displayName ?? "" })
180
+ : t("accessControlPage.confirmDeactivate", { group: toggleDisplay?.displayName ?? "" }) })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setToggleTarget(null), children: t("common.cancel") }), _jsx(Button, { variant: toggleDisplay?.deactivatedAt ? "default" : "destructive", onClick: handleConfirmToggleActive, children: toggleDisplay?.deactivatedAt
181
+ ? t("accessControlPage.reactivate")
182
+ : t("accessControlPage.deactivate") })] })] }) })] }));
183
+ }
@@ -0,0 +1,8 @@
1
+ export interface AccessControlOutletContext {
2
+ apiUrl?: string;
3
+ }
4
+ interface AccessControlLayoutProps {
5
+ apiUrl?: string;
6
+ }
7
+ export declare function AccessControlLayout({ apiUrl }: AccessControlLayoutProps): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Navigate, Outlet, useLocation } from "react-router-dom";
3
+ import { useAuth } from "../../../hooks/useAuth";
4
+ import { useI18n } from "../../../hooks/useI18n";
5
+ export function AccessControlLayout({ apiUrl }) {
6
+ const auth = useAuth();
7
+ const { t } = useI18n();
8
+ const location = useLocation();
9
+ const AC_SUB_PERMISSIONS = [
10
+ "admin.platform.access-control.groups.view",
11
+ "admin.platform.access-control.groups.manage",
12
+ "admin.platform.access-control.users.view",
13
+ "admin.platform.access-control.audit.view",
14
+ ];
15
+ const hasAnyAcPermission = AC_SUB_PERMISSIONS.some((p) => auth.hasPermission(p));
16
+ if (!hasAnyAcPermission) {
17
+ return (_jsxs("div", { "data-testid": "access-control-forbidden", children: [_jsx("h1", { className: "text-2xl font-semibold", children: t("errors.accessDenied") }), _jsx("p", { className: "mt-2 text-muted-foreground", children: t("accessControlPage.noAccess") })] }));
18
+ }
19
+ if (location.pathname === "/_access-control" || location.pathname === "/_access-control/") {
20
+ return _jsx(Navigate, { to: "/_access-control/groups", replace: true });
21
+ }
22
+ return (_jsx("div", { "data-testid": "access-control-layout", children: _jsx(Outlet, { context: { apiUrl } }) }));
23
+ }
@@ -0,0 +1,10 @@
1
+ import type { AccessControlUserSummary } from "./api";
2
+ interface AccessControlMemberPickerProps {
3
+ existingMemberIds: number[];
4
+ isLoading: boolean;
5
+ users: AccessControlUserSummary[];
6
+ onSearchChange: (value: string) => void;
7
+ onAddMembers: (userIds: number[]) => Promise<void>;
8
+ }
9
+ export declare function AccessControlMemberPicker({ existingMemberIds, isLoading, users, onSearchChange, onAddMembers, }: AccessControlMemberPickerProps): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { Badge, Button, Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, Input, Separator, } from "@nsxbet/admin-ui";
4
+ export function AccessControlMemberPicker({ existingMemberIds, isLoading, users, onSearchChange, onAddMembers, }) {
5
+ const [open, setOpen] = useState(false);
6
+ const [search, setSearch] = useState("");
7
+ const [selectedUsers, setSelectedUsers] = useState([]);
8
+ const debouncedSearch = useCallback((value) => {
9
+ onSearchChange(value);
10
+ }, [onSearchChange]);
11
+ useEffect(() => {
12
+ if (!open)
13
+ return;
14
+ const timer = window.setTimeout(() => {
15
+ debouncedSearch(search);
16
+ }, 200);
17
+ return () => window.clearTimeout(timer);
18
+ }, [debouncedSearch, search, open]);
19
+ useEffect(() => {
20
+ if (open) {
21
+ setSearch("");
22
+ setSelectedUsers([]);
23
+ debouncedSearch("");
24
+ }
25
+ }, [open, debouncedSearch]);
26
+ const selectedIds = useMemo(() => new Set(selectedUsers.map((u) => u.id)), [selectedUsers]);
27
+ const selectableUsers = useMemo(() => {
28
+ const excludedIds = new Set(existingMemberIds);
29
+ return users.filter((user) => !excludedIds.has(user.id) && !selectedIds.has(user.id));
30
+ }, [existingMemberIds, selectedIds, users]);
31
+ const toggleUser = (user, checked) => {
32
+ setSelectedUsers((current) => checked ? [...current, user] : current.filter((u) => u.id !== user.id));
33
+ };
34
+ const handleAdd = async () => {
35
+ if (selectedUsers.length === 0)
36
+ return;
37
+ await onAddMembers(selectedUsers.map((u) => u.id));
38
+ setSelectedUsers([]);
39
+ setOpen(false);
40
+ };
41
+ const hasResults = !isLoading && selectableUsers.length > 0;
42
+ const showEmpty = !isLoading && selectableUsers.length === 0 && selectedUsers.length === 0;
43
+ return (_jsxs(Dialog, { open: open, onOpenChange: setOpen, children: [_jsx(DialogTrigger, { asChild: true, children: _jsx(Button, { size: "sm", "data-testid": "access-control-add-members-trigger", children: "Add Members" }) }), _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Add Members" }), _jsx(DialogDescription, { children: "Search and select known local users to add to this group." })] }), _jsx(Input, { placeholder: "Search by name or email", value: search, onChange: (event) => setSearch(event.target.value), autoFocus: true }), _jsxs("div", { className: "max-h-72 space-y-1 overflow-auto rounded border p-2", children: [selectedUsers.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 px-1 pb-1", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: "Selected" }), _jsx(Badge, { variant: "secondary", className: "text-[10px]", children: selectedUsers.length })] }), selectedUsers.map((user) => (_jsxs("label", { className: "flex cursor-pointer items-center gap-3 rounded bg-muted/40 px-2 py-1.5 hover:bg-muted", children: [_jsx(Checkbox, { checked: true, onCheckedChange: () => toggleUser(user, false) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium", children: user.displayName }), _jsx("div", { className: "truncate text-xs text-muted-foreground", children: user.email })] })] }, user.id))), (hasResults || isLoading) && _jsx(Separator, { className: "my-2" })] })), isLoading ? (_jsx("div", { className: "py-4 text-center text-sm text-muted-foreground", children: "Loading..." })) : showEmpty ? (_jsx("div", { className: "py-4 text-center text-sm text-muted-foreground", children: "No users found." })) : (selectableUsers.map((user) => (_jsxs("label", { className: "flex cursor-pointer items-center gap-3 rounded px-2 py-1.5 hover:bg-muted", children: [_jsx(Checkbox, { checked: false, onCheckedChange: (checked) => toggleUser(user, checked === true) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium", children: user.displayName }), _jsx("div", { className: "truncate text-xs text-muted-foreground", children: user.email })] })] }, user.id))))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setOpen(false), children: "Cancel" }), _jsxs(Button, { onClick: handleAdd, disabled: selectedUsers.length === 0, "data-testid": "access-control-add-members", children: ["Add ", selectedUsers.length > 0 ? `(${selectedUsers.length})` : ""] })] })] })] }));
44
+ }
@@ -0,0 +1,8 @@
1
+ import type { AccessControlCatalogItem } from "./api";
2
+ interface AccessControlPermissionPickerProps {
3
+ items: AccessControlCatalogItem[];
4
+ assignedPermissions: string[];
5
+ onAssignPermissions: (permissions: string[]) => Promise<void>;
6
+ }
7
+ export declare function AccessControlPermissionPicker({ items, assignedPermissions, onAssignPermissions, }: AccessControlPermissionPickerProps): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ import { Badge, Button, Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, Input, Separator, } from "@nsxbet/admin-ui";
4
+ export function AccessControlPermissionPicker({ items, assignedPermissions, onAssignPermissions, }) {
5
+ const [open, setOpen] = useState(false);
6
+ const [search, setSearch] = useState("");
7
+ const [selectedPermissions, setSelectedPermissions] = useState([]);
8
+ const visibleItems = useMemo(() => items.filter((item) => {
9
+ const matchesSearch = search.trim() === "" || item.permission.toLowerCase().includes(search.toLowerCase());
10
+ return matchesSearch && !assignedPermissions.includes(item.permission);
11
+ }), [assignedPermissions, items, search]);
12
+ const groupedItems = useMemo(() => visibleItems.reduce((groups, item) => {
13
+ const groupKey = item.source === "platform" ? "Platform" : item.sourceKey;
14
+ groups[groupKey] = groups[groupKey] ?? [];
15
+ groups[groupKey].push(item);
16
+ return groups;
17
+ }, {}), [visibleItems]);
18
+ const togglePermission = (permission, checked) => {
19
+ setSelectedPermissions((current) => checked
20
+ ? [...current, permission]
21
+ : current.filter((p) => p !== permission));
22
+ };
23
+ const handleAssign = async () => {
24
+ if (selectedPermissions.length === 0)
25
+ return;
26
+ await onAssignPermissions(selectedPermissions);
27
+ setSelectedPermissions([]);
28
+ setOpen(false);
29
+ };
30
+ const handleOpenChange = (nextOpen) => {
31
+ setOpen(nextOpen);
32
+ if (nextOpen) {
33
+ setSearch("");
34
+ setSelectedPermissions([]);
35
+ }
36
+ };
37
+ return (_jsxs(Dialog, { open: open, onOpenChange: handleOpenChange, children: [_jsx(DialogTrigger, { asChild: true, children: _jsx(Button, { size: "sm", "data-testid": "access-control-assign-permissions-trigger", children: "Add Permissions" }) }), _jsxs(DialogContent, { className: "max-w-lg", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Assign Permissions" }), _jsx(DialogDescription, { children: "Select permissions from the catalog to assign to this group." })] }), _jsx(Input, { placeholder: "Search permissions", value: search, onChange: (event) => setSearch(event.target.value), autoFocus: true }), _jsx("div", { className: "max-h-72 space-y-2 overflow-auto rounded border p-2", children: Object.entries(groupedItems).length === 0 ? (_jsx("div", { className: "py-4 text-center text-sm text-muted-foreground", children: "No assignable permissions found." })) : (Object.entries(groupedItems).map(([groupName, groupItems], index) => (_jsxs("div", { className: "space-y-1", children: [index > 0 && _jsx(Separator, { className: "my-2" }), _jsx("div", { className: "px-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: groupName }), groupItems.map((item) => (_jsxs("label", { className: "flex cursor-pointer items-start gap-3 rounded px-2 py-1.5 hover:bg-muted", children: [_jsx(Checkbox, { className: "mt-0.5", checked: selectedPermissions.includes(item.permission), disabled: !item.assignable, onCheckedChange: (checked) => togglePermission(item.permission, checked === true) }), _jsxs("div", { className: "min-w-0 flex items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium", children: item.permission }), item.orphaned && (_jsx(Badge, { variant: "secondary", className: "text-[10px]", children: "Orphaned" }))] })] }, item.permission)))] }, groupName)))) }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setOpen(false), children: "Cancel" }), _jsxs(Button, { onClick: handleAssign, disabled: selectedPermissions.length === 0, "data-testid": "access-control-assign-permissions", children: ["Assign ", selectedPermissions.length > 0 ? `(${selectedPermissions.length})` : ""] })] })] })] }));
38
+ }
@@ -0,0 +1 @@
1
+ export declare function AccessControlUserPage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { useOutletContext, useParams } from "react-router-dom";
4
+ import { Card, CardContent, CardHeader, CardTitle, EmptyState, LoadingState, Badge, } from "@nsxbet/admin-ui";
5
+ import { useFetch } from "../../../hooks/useFetch";
6
+ import { useI18n } from "../../../hooks/useI18n";
7
+ import { createAccessControlApi, } from "./api";
8
+ export function AccessControlUserPage() {
9
+ const { userId } = useParams();
10
+ const { t } = useI18n();
11
+ const fetcher = useFetch();
12
+ const { apiUrl } = useOutletContext();
13
+ const api = useMemo(() => createAccessControlApi(fetcher, apiUrl), [fetcher, apiUrl]);
14
+ const [result, setResult] = useState(null);
15
+ const [isLoading, setIsLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ useEffect(() => {
18
+ const loadUser = async () => {
19
+ if (!userId)
20
+ return;
21
+ try {
22
+ setIsLoading(true);
23
+ setError(null);
24
+ setResult(await api.getResolvedRoles(userId));
25
+ }
26
+ catch (nextError) {
27
+ setError(nextError instanceof Error ? nextError.message : t("errors.generic"));
28
+ }
29
+ finally {
30
+ setIsLoading(false);
31
+ }
32
+ };
33
+ loadUser();
34
+ }, [api, t, userId]);
35
+ if (isLoading) {
36
+ return _jsx(LoadingState, { text: t("common.loading") });
37
+ }
38
+ if (error || !result) {
39
+ return (_jsx(EmptyState, { title: t("common.error"), description: error ?? t("errors.notFound") }));
40
+ }
41
+ return (_jsxs("section", { "data-testid": "access-control-user-page", className: "space-y-4", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-xl font-semibold", children: result.user.displayName }), _jsx("p", { className: "text-muted-foreground", children: t("accessControlPage.userDescription", { userId: result.user.id }) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t("accessControlPage.userRolesTitle") }) }), _jsx(CardContent, { className: "space-y-2", children: result.roles.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t("accessControlPage.noRoles") })) : (result.roles.map((role) => (_jsx(Badge, { variant: "secondary", className: "mr-2 mb-2", children: role }, role)))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t("accessControlPage.userGroupsTitle") }) }), _jsx(CardContent, { className: "space-y-3", children: result.groups.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t("accessControlPage.noGroupsDescription") })) : (result.groups.map((group) => (_jsxs("div", { className: "rounded border p-3", children: [_jsx("div", { className: "font-medium", children: group.displayName }), _jsx("div", { className: "mt-2 flex flex-wrap gap-2", children: group.permissions.map((permission) => (_jsx(Badge, { variant: "outline", children: permission }, permission))) })] }, group.id)))) })] })] }));
42
+ }
@@ -0,0 +1 @@
1
+ export declare function AccessControlUsersListPage(): import("react/jsx-runtime").JSX.Element;