@nsxbet/admin-sdk 0.8.1 → 0.9.1

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 (47) hide show
  1. package/README.md +22 -4
  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/router/url-allowlist.d.ts +6 -2
  18. package/dist/router/url-allowlist.js +9 -3
  19. package/dist/sdk-version.js +1 -1
  20. package/dist/shell/AdminShell.js +13 -8
  21. package/dist/shell/components/HomePage.js +1 -1
  22. package/dist/shell/components/LeftNav.js +46 -4
  23. package/dist/shell/components/MainContent.js +25 -0
  24. package/dist/shell/components/RegistryPage.js +3 -3
  25. package/dist/shell/components/access-control/AccessControlAuditPage.d.ts +1 -0
  26. package/dist/shell/components/access-control/AccessControlAuditPage.js +135 -0
  27. package/dist/shell/components/access-control/AccessControlGroupDetailPage.d.ts +1 -0
  28. package/dist/shell/components/access-control/AccessControlGroupDetailPage.js +224 -0
  29. package/dist/shell/components/access-control/AccessControlGroupsPage.d.ts +1 -0
  30. package/dist/shell/components/access-control/AccessControlGroupsPage.js +183 -0
  31. package/dist/shell/components/access-control/AccessControlLayout.d.ts +8 -0
  32. package/dist/shell/components/access-control/AccessControlLayout.js +23 -0
  33. package/dist/shell/components/access-control/AccessControlMemberPicker.d.ts +10 -0
  34. package/dist/shell/components/access-control/AccessControlMemberPicker.js +44 -0
  35. package/dist/shell/components/access-control/AccessControlPermissionPicker.d.ts +8 -0
  36. package/dist/shell/components/access-control/AccessControlPermissionPicker.js +38 -0
  37. package/dist/shell/components/access-control/AccessControlUserPage.d.ts +1 -0
  38. package/dist/shell/components/access-control/AccessControlUserPage.js +42 -0
  39. package/dist/shell/components/access-control/AccessControlUsersListPage.d.ts +1 -0
  40. package/dist/shell/components/access-control/AccessControlUsersListPage.js +111 -0
  41. package/dist/shell/components/access-control/api.d.ts +111 -0
  42. package/dist/shell/components/access-control/api.js +119 -0
  43. package/dist/shell/components/access-control/index.d.ts +8 -0
  44. package/dist/shell/components/access-control/index.js +8 -0
  45. package/dist/shell/components/index.d.ts +1 -0
  46. package/dist/shell/components/index.js +1 -0
  47. package/package.json +1 -1
@@ -0,0 +1,135 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { DataTable, EmptyState, Input, LoadingState, } from "@nsxbet/admin-ui";
4
+ import { useFetch } from "../../../hooks/useFetch";
5
+ import { useI18n } from "../../../hooks/useI18n";
6
+ import { useTimestamp } from "../../../hooks/useTimestamp";
7
+ import { useOutletContext } from "react-router-dom";
8
+ import { createAccessControlApi } from "./api";
9
+ const PAGE_SIZE = 20;
10
+ export function AccessControlAuditPage() {
11
+ const { t } = useI18n();
12
+ const fetcher = useFetch();
13
+ const { apiUrl } = useOutletContext();
14
+ const api = useMemo(() => createAccessControlApi(fetcher, apiUrl), [fetcher, apiUrl]);
15
+ const apiRef = useRef(api);
16
+ apiRef.current = api;
17
+ const { formatDate } = useTimestamp();
18
+ const [filters, setFilters] = useState({
19
+ eventType: "",
20
+ actorEmail: "",
21
+ targetType: "",
22
+ targetId: "",
23
+ });
24
+ const [committedFilters, setCommittedFilters] = useState(filters);
25
+ const [events, setEvents] = useState([]);
26
+ const [hasLoaded, setHasLoaded] = useState(false);
27
+ const [isFiltering, setIsFiltering] = useState(false);
28
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
29
+ const [error, setError] = useState(null);
30
+ const [nextCursor, setNextCursor] = useState(null);
31
+ const sentinelRef = useRef(null);
32
+ const tRef = useRef(t);
33
+ tRef.current = t;
34
+ const commitFilters = useCallback(() => {
35
+ setCommittedFilters(filters);
36
+ }, [filters]);
37
+ const filterParams = useMemo(() => ({
38
+ eventType: committedFilters.eventType || undefined,
39
+ actorEmail: committedFilters.actorEmail || undefined,
40
+ targetType: committedFilters.targetType || undefined,
41
+ targetId: committedFilters.targetId || undefined,
42
+ limit: PAGE_SIZE,
43
+ }), [committedFilters]);
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+ const loadAudit = async () => {
47
+ try {
48
+ setIsFiltering(true);
49
+ setError(null);
50
+ const page = await apiRef.current.listAuditEvents(filterParams);
51
+ if (cancelled)
52
+ return;
53
+ setEvents(page.items);
54
+ setNextCursor(page.nextCursor);
55
+ setHasLoaded(true);
56
+ }
57
+ catch (nextError) {
58
+ if (cancelled)
59
+ return;
60
+ setError(nextError instanceof Error ? nextError.message : tRef.current("errors.generic"));
61
+ }
62
+ finally {
63
+ if (!cancelled)
64
+ setIsFiltering(false);
65
+ }
66
+ };
67
+ loadAudit();
68
+ return () => { cancelled = true; };
69
+ }, [filterParams]);
70
+ const loadMoreRef = useRef();
71
+ const loadMore = useCallback(async () => {
72
+ if (!nextCursor || isLoadingMore)
73
+ return;
74
+ try {
75
+ setIsLoadingMore(true);
76
+ const page = await apiRef.current.listAuditEvents({
77
+ ...filterParams,
78
+ cursor: nextCursor,
79
+ });
80
+ setEvents((prev) => [...prev, ...page.items]);
81
+ setNextCursor(page.nextCursor);
82
+ }
83
+ catch {
84
+ // Silent failure on load-more; user can scroll again to retry
85
+ }
86
+ finally {
87
+ setIsLoadingMore(false);
88
+ }
89
+ }, [filterParams, nextCursor, isLoadingMore]);
90
+ loadMoreRef.current = loadMore;
91
+ useEffect(() => {
92
+ const sentinel = sentinelRef.current;
93
+ if (!sentinel || !nextCursor)
94
+ return;
95
+ const observer = new IntersectionObserver((entries) => {
96
+ if (entries[0].isIntersecting) {
97
+ loadMoreRef.current?.();
98
+ }
99
+ }, { rootMargin: "200px" });
100
+ observer.observe(sentinel);
101
+ return () => observer.disconnect();
102
+ }, [nextCursor]);
103
+ const columns = useMemo(() => [
104
+ {
105
+ accessor: "occurredAt",
106
+ header: t("accessControlPage.columnTimestamp"),
107
+ cell: (_value, row) => formatDate(new Date(row.occurredAt), "datetime"),
108
+ },
109
+ {
110
+ accessor: "eventType",
111
+ header: t("accessControlPage.columnEvent"),
112
+ },
113
+ {
114
+ accessor: "actorEmailSnapshot",
115
+ header: t("accessControlPage.columnActor"),
116
+ },
117
+ {
118
+ accessor: "targetType",
119
+ header: t("accessControlPage.columnTarget"),
120
+ cell: (_value, row) => `${row.targetType} / ${row.targetId}`,
121
+ },
122
+ {
123
+ accessor: "summary",
124
+ header: t("accessControlPage.columnSummary"),
125
+ },
126
+ ], [t, formatDate]);
127
+ const showInitialLoading = !hasLoaded && isFiltering;
128
+ const showTable = hasLoaded && !error;
129
+ return (_jsxs("section", { "data-testid": "access-control-audit-page", className: "space-y-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("h2", { className: "text-xl font-semibold", children: t("accessControlPage.auditTitle") }), _jsx("span", { className: "rounded bg-muted px-2 py-1 text-xs font-medium", children: t("common.readOnly") })] }), _jsxs("div", { className: "grid gap-3 md:grid-cols-4", onKeyDown: (event) => {
130
+ if (event.key === "Enter")
131
+ commitFilters();
132
+ }, children: [_jsx(Input, { placeholder: t("accessControlPage.filterEventType"), value: filters.eventType, onChange: (event) => setFilters((current) => ({ ...current, eventType: event.target.value })), onBlur: commitFilters }), _jsx(Input, { placeholder: t("accessControlPage.filterActor"), value: filters.actorEmail, onChange: (event) => setFilters((current) => ({ ...current, actorEmail: event.target.value })), onBlur: commitFilters }), _jsx(Input, { placeholder: t("accessControlPage.filterTargetType"), value: filters.targetType, onChange: (event) => setFilters((current) => ({ ...current, targetType: event.target.value })), onBlur: commitFilters }), _jsx(Input, { placeholder: t("accessControlPage.filterTargetId"), value: filters.targetId, onChange: (event) => setFilters((current) => ({ ...current, targetId: event.target.value })), onBlur: commitFilters })] }), showInitialLoading ? (_jsx(LoadingState, { text: t("common.loading") })) : error ? (_jsx(EmptyState, { title: t("common.error"), description: error })) : showTable ? (_jsxs("div", { children: [_jsx("div", { className: isFiltering
133
+ ? "pointer-events-none opacity-50 transition-opacity duration-200"
134
+ : "transition-opacity duration-200", children: _jsx(DataTable, { data: events, columns: columns, emptyState: _jsx(EmptyState, { title: t("accessControlPage.noAuditTitle"), description: t("accessControlPage.noAuditDescription") }) }) }), 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] }));
135
+ }
@@ -0,0 +1 @@
1
+ export declare function AccessControlGroupDetailPage(): import("react/jsx-runtime").JSX.Element;
@@ -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
+ }