@qwickapps/server 1.1.9 → 1.3.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 (240) hide show
  1. package/README.md +318 -0
  2. package/dist/core/control-panel.d.ts +7 -2
  3. package/dist/core/control-panel.d.ts.map +1 -1
  4. package/dist/core/control-panel.js +99 -60
  5. package/dist/core/control-panel.js.map +1 -1
  6. package/dist/core/gateway.d.ts +159 -79
  7. package/dist/core/gateway.d.ts.map +1 -1
  8. package/dist/core/gateway.js +683 -315
  9. package/dist/core/gateway.js.map +1 -1
  10. package/dist/core/index.d.ts +3 -1
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +2 -0
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/plugin-registry.d.ts +271 -0
  15. package/dist/core/plugin-registry.d.ts.map +1 -0
  16. package/dist/core/plugin-registry.js +326 -0
  17. package/dist/core/plugin-registry.js.map +1 -0
  18. package/dist/core/types.d.ts +16 -33
  19. package/dist/core/types.d.ts.map +1 -1
  20. package/dist/index.d.ts +8 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +15 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins/auth/adapters/auth0-adapter.d.ts +14 -0
  25. package/dist/plugins/auth/adapters/auth0-adapter.d.ts.map +1 -0
  26. package/dist/plugins/auth/adapters/auth0-adapter.js +179 -0
  27. package/dist/plugins/auth/adapters/auth0-adapter.js.map +1 -0
  28. package/dist/plugins/auth/adapters/basic-adapter.d.ts +13 -0
  29. package/dist/plugins/auth/adapters/basic-adapter.d.ts.map +1 -0
  30. package/dist/plugins/auth/adapters/basic-adapter.js +51 -0
  31. package/dist/plugins/auth/adapters/basic-adapter.js.map +1 -0
  32. package/dist/plugins/auth/adapters/index.d.ts +9 -0
  33. package/dist/plugins/auth/adapters/index.d.ts.map +1 -0
  34. package/dist/plugins/auth/adapters/index.js +9 -0
  35. package/dist/plugins/auth/adapters/index.js.map +1 -0
  36. package/dist/plugins/auth/adapters/supabase-adapter.d.ts +13 -0
  37. package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -0
  38. package/dist/plugins/auth/adapters/supabase-adapter.js +109 -0
  39. package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -0
  40. package/dist/plugins/auth/auth-plugin.d.ts +40 -0
  41. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -0
  42. package/dist/plugins/auth/auth-plugin.js +255 -0
  43. package/dist/plugins/auth/auth-plugin.js.map +1 -0
  44. package/dist/plugins/auth/auth-plugin.test.d.ts +9 -0
  45. package/dist/plugins/auth/auth-plugin.test.d.ts.map +1 -0
  46. package/dist/plugins/auth/auth-plugin.test.js +147 -0
  47. package/dist/plugins/auth/auth-plugin.test.js.map +1 -0
  48. package/dist/plugins/auth/index.d.ts +12 -0
  49. package/dist/plugins/auth/index.d.ts.map +1 -0
  50. package/dist/plugins/auth/index.js +13 -0
  51. package/dist/plugins/auth/index.js.map +1 -0
  52. package/dist/plugins/auth/types.d.ts +148 -0
  53. package/dist/plugins/auth/types.d.ts.map +1 -0
  54. package/dist/plugins/auth/types.js +14 -0
  55. package/dist/plugins/auth/types.js.map +1 -0
  56. package/dist/plugins/bans/bans-plugin.d.ts +59 -0
  57. package/dist/plugins/bans/bans-plugin.d.ts.map +1 -0
  58. package/dist/plugins/bans/bans-plugin.js +428 -0
  59. package/dist/plugins/bans/bans-plugin.js.map +1 -0
  60. package/dist/plugins/bans/index.d.ts +9 -0
  61. package/dist/plugins/bans/index.d.ts.map +1 -0
  62. package/dist/plugins/bans/index.js +10 -0
  63. package/dist/plugins/bans/index.js.map +1 -0
  64. package/dist/plugins/bans/stores/index.d.ts +7 -0
  65. package/dist/plugins/bans/stores/index.d.ts.map +1 -0
  66. package/dist/plugins/bans/stores/index.js +7 -0
  67. package/dist/plugins/bans/stores/index.js.map +1 -0
  68. package/dist/plugins/bans/stores/postgres-store.d.ts +29 -0
  69. package/dist/plugins/bans/stores/postgres-store.d.ts.map +1 -0
  70. package/dist/plugins/bans/stores/postgres-store.js +132 -0
  71. package/dist/plugins/bans/stores/postgres-store.js.map +1 -0
  72. package/dist/plugins/bans/types.d.ts +128 -0
  73. package/dist/plugins/bans/types.d.ts.map +1 -0
  74. package/dist/plugins/bans/types.js +11 -0
  75. package/dist/plugins/bans/types.js.map +1 -0
  76. package/dist/plugins/cache-plugin.d.ts +14 -3
  77. package/dist/plugins/cache-plugin.d.ts.map +1 -1
  78. package/dist/plugins/cache-plugin.js +27 -7
  79. package/dist/plugins/cache-plugin.js.map +1 -1
  80. package/dist/plugins/cache-plugin.test.js +96 -32
  81. package/dist/plugins/cache-plugin.test.js.map +1 -1
  82. package/dist/plugins/config-plugin.d.ts +3 -2
  83. package/dist/plugins/config-plugin.d.ts.map +1 -1
  84. package/dist/plugins/config-plugin.js +17 -10
  85. package/dist/plugins/config-plugin.js.map +1 -1
  86. package/dist/plugins/diagnostics-plugin.d.ts +2 -2
  87. package/dist/plugins/diagnostics-plugin.d.ts.map +1 -1
  88. package/dist/plugins/diagnostics-plugin.js +17 -10
  89. package/dist/plugins/diagnostics-plugin.js.map +1 -1
  90. package/dist/plugins/entitlements/entitlements-plugin.d.ts +95 -0
  91. package/dist/plugins/entitlements/entitlements-plugin.d.ts.map +1 -0
  92. package/dist/plugins/entitlements/entitlements-plugin.js +707 -0
  93. package/dist/plugins/entitlements/entitlements-plugin.js.map +1 -0
  94. package/dist/plugins/entitlements/index.d.ts +12 -0
  95. package/dist/plugins/entitlements/index.d.ts.map +1 -0
  96. package/dist/plugins/entitlements/index.js +16 -0
  97. package/dist/plugins/entitlements/index.js.map +1 -0
  98. package/dist/plugins/entitlements/sources/index.d.ts +9 -0
  99. package/dist/plugins/entitlements/sources/index.d.ts.map +1 -0
  100. package/dist/plugins/entitlements/sources/index.js +9 -0
  101. package/dist/plugins/entitlements/sources/index.js.map +1 -0
  102. package/dist/plugins/entitlements/sources/postgres-source.d.ts +29 -0
  103. package/dist/plugins/entitlements/sources/postgres-source.d.ts.map +1 -0
  104. package/dist/plugins/entitlements/sources/postgres-source.js +169 -0
  105. package/dist/plugins/entitlements/sources/postgres-source.js.map +1 -0
  106. package/dist/plugins/entitlements/types.d.ts +232 -0
  107. package/dist/plugins/entitlements/types.d.ts.map +1 -0
  108. package/dist/plugins/entitlements/types.js +11 -0
  109. package/dist/plugins/entitlements/types.js.map +1 -0
  110. package/dist/plugins/frontend-app-plugin.d.ts +9 -3
  111. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  112. package/dist/plugins/frontend-app-plugin.js +14 -9
  113. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  114. package/dist/plugins/health-plugin.d.ts +5 -2
  115. package/dist/plugins/health-plugin.d.ts.map +1 -1
  116. package/dist/plugins/health-plugin.js +20 -5
  117. package/dist/plugins/health-plugin.js.map +1 -1
  118. package/dist/plugins/index.d.ts +8 -2
  119. package/dist/plugins/index.d.ts.map +1 -1
  120. package/dist/plugins/index.js +8 -2
  121. package/dist/plugins/index.js.map +1 -1
  122. package/dist/plugins/logs-plugin.d.ts +3 -2
  123. package/dist/plugins/logs-plugin.d.ts.map +1 -1
  124. package/dist/plugins/logs-plugin.js +21 -12
  125. package/dist/plugins/logs-plugin.js.map +1 -1
  126. package/dist/plugins/postgres-plugin.d.ts +3 -3
  127. package/dist/plugins/postgres-plugin.d.ts.map +1 -1
  128. package/dist/plugins/postgres-plugin.js +9 -7
  129. package/dist/plugins/postgres-plugin.js.map +1 -1
  130. package/dist/plugins/postgres-plugin.test.js +47 -29
  131. package/dist/plugins/postgres-plugin.test.js.map +1 -1
  132. package/dist/plugins/users/index.d.ts +12 -0
  133. package/dist/plugins/users/index.d.ts.map +1 -0
  134. package/dist/plugins/users/index.js +13 -0
  135. package/dist/plugins/users/index.js.map +1 -0
  136. package/dist/plugins/users/stores/index.d.ts +7 -0
  137. package/dist/plugins/users/stores/index.d.ts.map +1 -0
  138. package/dist/plugins/users/stores/index.js +7 -0
  139. package/dist/plugins/users/stores/index.js.map +1 -0
  140. package/dist/plugins/users/stores/postgres-store.d.ts +28 -0
  141. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -0
  142. package/dist/plugins/users/stores/postgres-store.js +157 -0
  143. package/dist/plugins/users/stores/postgres-store.js.map +1 -0
  144. package/dist/plugins/users/types.d.ts +189 -0
  145. package/dist/plugins/users/types.d.ts.map +1 -0
  146. package/dist/plugins/users/types.js +12 -0
  147. package/dist/plugins/users/types.js.map +1 -0
  148. package/dist/plugins/users/users-plugin.d.ts +39 -0
  149. package/dist/plugins/users/users-plugin.d.ts.map +1 -0
  150. package/dist/plugins/users/users-plugin.js +242 -0
  151. package/dist/plugins/users/users-plugin.js.map +1 -0
  152. package/dist-ui/assets/index-Bsp2ntcw.js +465 -0
  153. package/dist-ui/assets/index-Bsp2ntcw.js.map +1 -0
  154. package/dist-ui/index.html +1 -1
  155. package/dist-ui-lib/api/controlPanelApi.d.ts +232 -0
  156. package/dist-ui-lib/components/ControlPanelApp.d.ts +61 -0
  157. package/dist-ui-lib/components/index.d.ts +18 -0
  158. package/dist-ui-lib/config/AppConfig.d.ts +7 -0
  159. package/dist-ui-lib/dashboard/DashboardWidgetRegistry.d.ts +62 -0
  160. package/dist-ui-lib/dashboard/DashboardWidgetRenderer.d.ts +8 -0
  161. package/dist-ui-lib/dashboard/PluginWidgetRenderer.d.ts +19 -0
  162. package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +44 -0
  163. package/dist-ui-lib/dashboard/builtInWidgets.d.ts +19 -0
  164. package/dist-ui-lib/dashboard/index.d.ts +13 -0
  165. package/dist-ui-lib/dashboard/widgets/ServiceHealthWidget.d.ts +12 -0
  166. package/dist-ui-lib/dashboard/widgets/index.d.ts +6 -0
  167. package/dist-ui-lib/index.js +6441 -0
  168. package/dist-ui-lib/index.js.map +1 -0
  169. package/dist-ui-lib/pages/ConfigPage.d.ts +1 -0
  170. package/dist-ui-lib/pages/DashboardPage.d.ts +1 -0
  171. package/dist-ui-lib/pages/DiagnosticsPage.d.ts +1 -0
  172. package/dist-ui-lib/pages/EntitlementsPage.d.ts +17 -0
  173. package/dist-ui-lib/pages/LogsPage.d.ts +1 -0
  174. package/dist-ui-lib/pages/NotFoundPage.d.ts +1 -0
  175. package/dist-ui-lib/pages/PluginPage.d.ts +15 -0
  176. package/dist-ui-lib/pages/SystemPage.d.ts +1 -0
  177. package/dist-ui-lib/pages/UsersPage.d.ts +22 -0
  178. package/package.json +18 -6
  179. package/src/core/control-panel.ts +122 -68
  180. package/src/core/gateway.ts +870 -399
  181. package/src/core/index.ts +21 -2
  182. package/src/core/plugin-registry.ts +653 -0
  183. package/src/core/types.ts +31 -37
  184. package/src/index.ts +118 -19
  185. package/src/plugins/auth/adapters/auth0-adapter.ts +214 -0
  186. package/src/plugins/auth/adapters/basic-adapter.ts +61 -0
  187. package/src/plugins/auth/adapters/index.ts +9 -0
  188. package/src/plugins/auth/adapters/supabase-adapter.ts +141 -0
  189. package/src/plugins/auth/auth-plugin.test.ts +176 -0
  190. package/src/plugins/auth/auth-plugin.ts +303 -0
  191. package/src/plugins/auth/index.ts +33 -0
  192. package/src/plugins/auth/types.ts +165 -0
  193. package/src/plugins/bans/bans-plugin.ts +485 -0
  194. package/src/plugins/bans/index.ts +31 -0
  195. package/src/plugins/bans/stores/index.ts +7 -0
  196. package/src/plugins/bans/stores/postgres-store.ts +195 -0
  197. package/src/plugins/bans/types.ts +141 -0
  198. package/src/plugins/cache-plugin.test.ts +105 -32
  199. package/src/plugins/cache-plugin.ts +40 -9
  200. package/src/plugins/config-plugin.ts +23 -12
  201. package/src/plugins/diagnostics-plugin.ts +22 -12
  202. package/src/plugins/entitlements/entitlements-plugin.ts +820 -0
  203. package/src/plugins/entitlements/index.ts +51 -0
  204. package/src/plugins/entitlements/sources/index.ts +9 -0
  205. package/src/plugins/entitlements/sources/postgres-source.ts +253 -0
  206. package/src/plugins/entitlements/types.ts +256 -0
  207. package/src/plugins/frontend-app-plugin.ts +24 -12
  208. package/src/plugins/health-plugin.ts +27 -7
  209. package/src/plugins/index.ts +106 -4
  210. package/src/plugins/logs-plugin.ts +28 -14
  211. package/src/plugins/postgres-plugin.test.ts +49 -29
  212. package/src/plugins/postgres-plugin.ts +11 -9
  213. package/src/plugins/users/index.ts +35 -0
  214. package/src/plugins/users/stores/index.ts +7 -0
  215. package/src/plugins/users/stores/postgres-store.ts +225 -0
  216. package/src/plugins/users/types.ts +209 -0
  217. package/src/plugins/users/users-plugin.ts +281 -0
  218. package/ui/src/App.tsx +185 -31
  219. package/ui/src/api/controlPanelApi.ts +354 -1
  220. package/ui/src/components/ControlPanelApp.tsx +209 -0
  221. package/ui/src/components/index.ts +62 -0
  222. package/ui/src/dashboard/DashboardWidgetRegistry.tsx +129 -0
  223. package/ui/src/dashboard/DashboardWidgetRenderer.tsx +34 -0
  224. package/ui/src/dashboard/PluginWidgetRenderer.tsx +115 -0
  225. package/ui/src/dashboard/WidgetComponentRegistry.tsx +116 -0
  226. package/ui/src/dashboard/builtInWidgets.tsx +29 -0
  227. package/ui/src/dashboard/index.ts +35 -0
  228. package/ui/src/dashboard/widgets/ServiceHealthWidget.tsx +140 -0
  229. package/ui/src/dashboard/widgets/index.ts +7 -0
  230. package/ui/src/pages/DashboardPage.tsx +28 -149
  231. package/ui/src/pages/EntitlementsPage.tsx +557 -0
  232. package/ui/src/pages/LogsPage.tsx +174 -8
  233. package/ui/src/pages/PluginPage.tsx +148 -0
  234. package/ui/src/pages/SystemPage.tsx +445 -0
  235. package/ui/src/pages/UsersPage.tsx +837 -0
  236. package/ui/tsconfig.lib.json +11 -0
  237. package/ui/vite.lib.config.ts +51 -0
  238. package/dist-ui/assets/index-CW1BviRn.js +0 -465
  239. package/dist-ui/assets/index-CW1BviRn.js.map +0 -1
  240. package/ui/src/pages/HealthPage.tsx +0 -204
@@ -0,0 +1,837 @@
1
+ /**
2
+ * UsersPage Component
3
+ *
4
+ * Generic user management page that works with Users, Bans, and Entitlements plugins.
5
+ * All features are optional and auto-detected based on available plugins.
6
+ *
7
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
8
+ */
9
+
10
+ import { useState, useEffect, useCallback } from 'react';
11
+ import {
12
+ Box,
13
+ Card,
14
+ CardContent,
15
+ TextField,
16
+ Table,
17
+ TableBody,
18
+ TableCell,
19
+ TableContainer,
20
+ TableHead,
21
+ TableRow,
22
+ Chip,
23
+ Alert,
24
+ LinearProgress,
25
+ InputAdornment,
26
+ Tabs,
27
+ Tab,
28
+ TablePagination,
29
+ Tooltip,
30
+ IconButton,
31
+ CircularProgress,
32
+ Autocomplete,
33
+ } from '@mui/material';
34
+ import { Text, Button, Dialog, DialogTitle, DialogContent, DialogActions, GridLayout } from '@qwickapps/react-framework';
35
+ import SearchIcon from '@mui/icons-material/Search';
36
+ import PersonIcon from '@mui/icons-material/Person';
37
+ import LocalOfferIcon from '@mui/icons-material/LocalOffer';
38
+ import BlockIcon from '@mui/icons-material/Block';
39
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
40
+ import DeleteIcon from '@mui/icons-material/Delete';
41
+ import {
42
+ api,
43
+ type User,
44
+ type Ban,
45
+ type EntitlementResult,
46
+ type EntitlementDefinition,
47
+ type PluginFeatures,
48
+ } from '../api/controlPanelApi';
49
+
50
+ export interface UsersPageProps {
51
+ /** Page title */
52
+ title?: string;
53
+ /** Page subtitle */
54
+ subtitle?: string;
55
+ /** Override automatic feature detection */
56
+ features?: Partial<PluginFeatures>;
57
+ /** Custom actions to render in the header */
58
+ headerActions?: React.ReactNode;
59
+ /** Callback when a user is selected */
60
+ onUserSelect?: (user: User) => void;
61
+ }
62
+
63
+ export function UsersPage({
64
+ title = 'User Management',
65
+ subtitle = 'Manage users, bans, and entitlements',
66
+ features: featureOverrides,
67
+ headerActions,
68
+ onUserSelect,
69
+ }: UsersPageProps) {
70
+ // Feature detection
71
+ const [features, setFeatures] = useState<PluginFeatures>({
72
+ users: featureOverrides?.users ?? true,
73
+ bans: featureOverrides?.bans ?? false,
74
+ entitlements: featureOverrides?.entitlements ?? false,
75
+ entitlementsReadonly: featureOverrides?.entitlementsReadonly ?? true,
76
+ });
77
+ const [featuresLoaded, setFeaturesLoaded] = useState(!!featureOverrides);
78
+
79
+ // Tab state
80
+ const [activeTab, setActiveTab] = useState(0);
81
+
82
+ // Users state
83
+ const [users, setUsers] = useState<User[]>([]);
84
+ const [usersTotal, setUsersTotal] = useState(0);
85
+ const [usersPage, setUsersPage] = useState(0);
86
+ const [usersPerPage, setUsersPerPage] = useState(25);
87
+ const [usersSearch, setUsersSearch] = useState('');
88
+
89
+ // User entitlements cache (email -> count)
90
+ const [userEntitlementCounts, setUserEntitlementCounts] = useState<Record<string, number>>({});
91
+
92
+ // Banned users state
93
+ const [bans, setBans] = useState<Ban[]>([]);
94
+ const [bansTotal, setBansTotal] = useState(0);
95
+
96
+ // Shared state
97
+ const [loading, setLoading] = useState(true);
98
+ const [error, setError] = useState<string | null>(null);
99
+ const [success, setSuccess] = useState<string | null>(null);
100
+
101
+ // Ban dialog state
102
+ const [banDialogOpen, setBanDialogOpen] = useState(false);
103
+ const [newBan, setNewBan] = useState({
104
+ email: '',
105
+ reason: '',
106
+ expiresAt: '',
107
+ });
108
+
109
+ // Entitlements lookup state
110
+ const [entitlementsDialogOpen, setEntitlementsDialogOpen] = useState(false);
111
+ const [entitlementsSearch, setEntitlementsSearch] = useState('');
112
+ const [entitlementsLoading, setEntitlementsLoading] = useState(false);
113
+ const [entitlementsRefreshing, setEntitlementsRefreshing] = useState(false);
114
+ const [entitlementsData, setEntitlementsData] = useState<EntitlementResult | null>(null);
115
+ const [entitlementsError, setEntitlementsError] = useState<string | null>(null);
116
+
117
+ // Available entitlements (for grant dropdown)
118
+ const [availableEntitlements, setAvailableEntitlements] = useState<EntitlementDefinition[]>([]);
119
+ const [selectedEntitlement, setSelectedEntitlement] = useState<string>('');
120
+ const [grantingEntitlement, setGrantingEntitlement] = useState(false);
121
+
122
+ // Detect features on mount
123
+ useEffect(() => {
124
+ if (featureOverrides) return;
125
+
126
+ api.detectFeatures().then((detected) => {
127
+ setFeatures(detected);
128
+ setFeaturesLoaded(true);
129
+ }).catch(() => {
130
+ setFeaturesLoaded(true);
131
+ });
132
+ }, [featureOverrides]);
133
+
134
+ // Fetch available entitlements when features are loaded
135
+ useEffect(() => {
136
+ if (featuresLoaded && features.entitlements && !features.entitlementsReadonly) {
137
+ api.getAvailableEntitlements().then(setAvailableEntitlements).catch(() => {});
138
+ }
139
+ }, [featuresLoaded, features.entitlements, features.entitlementsReadonly]);
140
+
141
+ // Fetch users
142
+ const fetchUsers = useCallback(async () => {
143
+ if (!features.users) return;
144
+
145
+ setLoading(true);
146
+ try {
147
+ const data = await api.getUsers({
148
+ limit: usersPerPage,
149
+ page: usersPage,
150
+ search: usersSearch || undefined,
151
+ });
152
+ setUsers(data.users || []);
153
+ setUsersTotal(data.total);
154
+ setError(null);
155
+
156
+ // Fetch entitlement counts for visible users if entitlements plugin is enabled
157
+ if (features.entitlements && data.users?.length) {
158
+ const counts: Record<string, number> = {};
159
+ await Promise.all(
160
+ data.users.map(async (user) => {
161
+ try {
162
+ const ent = await api.getEntitlements(user.email);
163
+ counts[user.email] = ent.entitlements.length;
164
+ } catch {
165
+ counts[user.email] = 0;
166
+ }
167
+ })
168
+ );
169
+ setUserEntitlementCounts((prev) => ({ ...prev, ...counts }));
170
+ }
171
+ } catch (err) {
172
+ setError(err instanceof Error ? err.message : 'Failed to fetch users');
173
+ } finally {
174
+ setLoading(false);
175
+ }
176
+ }, [features.users, features.entitlements, usersPage, usersPerPage, usersSearch]);
177
+
178
+ // Fetch bans
179
+ const fetchBans = useCallback(async () => {
180
+ if (!features.bans) return;
181
+
182
+ setLoading(true);
183
+ try {
184
+ const data = await api.getBans();
185
+ setBans(data.bans || []);
186
+ setBansTotal(data.total);
187
+ setError(null);
188
+ } catch (err) {
189
+ setError(err instanceof Error ? err.message : 'Failed to fetch bans');
190
+ } finally {
191
+ setLoading(false);
192
+ }
193
+ }, [features.bans]);
194
+
195
+ // Initial fetch and tab-based fetching
196
+ useEffect(() => {
197
+ if (!featuresLoaded) return;
198
+
199
+ if (activeTab === 0 && features.users) {
200
+ fetchUsers();
201
+ } else if (activeTab === 1 && features.bans) {
202
+ fetchBans();
203
+ }
204
+ }, [activeTab, featuresLoaded, features.users, features.bans, fetchUsers, fetchBans]);
205
+
206
+ // Fetch bans count for stats (only on initial load)
207
+ useEffect(() => {
208
+ if (featuresLoaded && features.bans) {
209
+ fetchBans();
210
+ }
211
+ }, [featuresLoaded, features.bans, fetchBans]);
212
+
213
+ // Debounced search
214
+ useEffect(() => {
215
+ if (!featuresLoaded) return;
216
+
217
+ const timeout = setTimeout(() => {
218
+ if (activeTab === 0 && features.users) {
219
+ setUsersPage(0);
220
+ fetchUsers();
221
+ }
222
+ }, 300);
223
+ return () => clearTimeout(timeout);
224
+ }, [usersSearch, activeTab, featuresLoaded, features.users, fetchUsers]);
225
+
226
+ // Ban handlers
227
+ const handleBanUser = async () => {
228
+ try {
229
+ await api.banUser(newBan.email, newBan.reason, newBan.expiresAt || undefined);
230
+ setSuccess('User banned successfully');
231
+ setBanDialogOpen(false);
232
+ setNewBan({ email: '', reason: '', expiresAt: '' });
233
+ fetchBans();
234
+ } catch (err) {
235
+ setError(err instanceof Error ? err.message : 'Failed to ban user');
236
+ }
237
+ };
238
+
239
+ const handleUnbanUser = async (email: string) => {
240
+ if (!confirm('Unban this user?')) return;
241
+
242
+ try {
243
+ await api.unbanUser(email);
244
+ setSuccess('User unbanned successfully');
245
+ fetchBans();
246
+ } catch (err) {
247
+ setError('Failed to unban user');
248
+ }
249
+ };
250
+
251
+ // Entitlements handlers
252
+ const handleEntitlementsSearch = async () => {
253
+ if (!entitlementsSearch.trim()) {
254
+ setEntitlementsError('Please enter an email address');
255
+ return;
256
+ }
257
+
258
+ setEntitlementsLoading(true);
259
+ setEntitlementsError(null);
260
+ setEntitlementsData(null);
261
+
262
+ try {
263
+ const data = await api.getEntitlements(entitlementsSearch);
264
+ setEntitlementsData(data);
265
+ } catch (err) {
266
+ setEntitlementsError(err instanceof Error ? err.message : 'Failed to lookup entitlements');
267
+ } finally {
268
+ setEntitlementsLoading(false);
269
+ }
270
+ };
271
+
272
+ const handleEntitlementsRefresh = async () => {
273
+ if (!entitlementsData) return;
274
+
275
+ setEntitlementsRefreshing(true);
276
+ try {
277
+ const data = await api.refreshEntitlements(entitlementsSearch);
278
+ setEntitlementsData(data);
279
+ } catch (err) {
280
+ setEntitlementsError('Failed to refresh entitlements');
281
+ } finally {
282
+ setEntitlementsRefreshing(false);
283
+ }
284
+ };
285
+
286
+ const handleGrantEntitlement = async () => {
287
+ if (!selectedEntitlement || !entitlementsData) return;
288
+
289
+ setGrantingEntitlement(true);
290
+ try {
291
+ await api.grantEntitlement(entitlementsData.identifier, selectedEntitlement);
292
+ setSuccess(`Entitlement "${selectedEntitlement}" granted`);
293
+ setSelectedEntitlement('');
294
+ // Refresh to show new entitlement
295
+ const data = await api.refreshEntitlements(entitlementsData.identifier);
296
+ setEntitlementsData(data);
297
+ // Update count cache
298
+ setUserEntitlementCounts((prev) => ({
299
+ ...prev,
300
+ [entitlementsData.identifier]: data.entitlements.length,
301
+ }));
302
+ } catch (err) {
303
+ setError(err instanceof Error ? err.message : 'Failed to grant entitlement');
304
+ } finally {
305
+ setGrantingEntitlement(false);
306
+ }
307
+ };
308
+
309
+ const handleRevokeEntitlement = async (entitlement: string) => {
310
+ if (!entitlementsData) return;
311
+ if (!confirm(`Revoke "${entitlement}" from ${entitlementsData.identifier}?`)) return;
312
+
313
+ try {
314
+ await api.revokeEntitlement(entitlementsData.identifier, entitlement);
315
+ setSuccess(`Entitlement "${entitlement}" revoked`);
316
+ // Refresh to show updated entitlements
317
+ const data = await api.refreshEntitlements(entitlementsData.identifier);
318
+ setEntitlementsData(data);
319
+ // Update count cache
320
+ setUserEntitlementCounts((prev) => ({
321
+ ...prev,
322
+ [entitlementsData.identifier]: data.entitlements.length,
323
+ }));
324
+ } catch (err) {
325
+ setError(err instanceof Error ? err.message : 'Failed to revoke entitlement');
326
+ }
327
+ };
328
+
329
+ const openEntitlementsDialog = (email?: string) => {
330
+ if (email) {
331
+ setEntitlementsSearch(email);
332
+ // Auto-search when opened with email
333
+ setEntitlementsLoading(true);
334
+ setEntitlementsError(null);
335
+ setEntitlementsData(null);
336
+ api.getEntitlements(email)
337
+ .then(setEntitlementsData)
338
+ .catch((err) => setEntitlementsError(err instanceof Error ? err.message : 'Failed to lookup entitlements'))
339
+ .finally(() => setEntitlementsLoading(false));
340
+ }
341
+ setEntitlementsDialogOpen(true);
342
+ };
343
+
344
+ // Utility functions
345
+ const formatDate = (date: string | null | undefined) => {
346
+ if (!date) return 'Never';
347
+ return new Date(date).toLocaleDateString('en-US', {
348
+ year: 'numeric',
349
+ month: 'short',
350
+ day: 'numeric',
351
+ hour: '2-digit',
352
+ minute: '2-digit',
353
+ });
354
+ };
355
+
356
+ // Get entitlements that can be granted (not already assigned)
357
+ const grantableEntitlements = availableEntitlements.filter(
358
+ (e) => !entitlementsData?.entitlements.includes(e.name)
359
+ );
360
+
361
+ // Build tabs based on available features
362
+ const tabs: { label: string; count?: number }[] = [];
363
+ if (features.users) tabs.push({ label: 'Users', count: usersTotal });
364
+ if (features.bans) tabs.push({ label: 'Banned', count: bansTotal });
365
+
366
+ if (!featuresLoaded) {
367
+ return (
368
+ <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
369
+ <CircularProgress />
370
+ </Box>
371
+ );
372
+ }
373
+
374
+ return (
375
+ <Box>
376
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
377
+ <Box>
378
+ <Text variant="h4" content={title} customColor="var(--theme-text-primary)" />
379
+ <Text variant="body2" content={subtitle} customColor="var(--theme-text-secondary)" />
380
+ </Box>
381
+ <Box sx={{ display: 'flex', gap: 1 }}>
382
+ {headerActions}
383
+ {features.entitlements && (
384
+ <Button
385
+ variant="outlined"
386
+ icon="person_search"
387
+ label="Lookup Entitlements"
388
+ onClick={() => openEntitlementsDialog()}
389
+ />
390
+ )}
391
+ {features.bans && (
392
+ <Button
393
+ variant="primary"
394
+ color="error"
395
+ icon="block"
396
+ label="Ban User"
397
+ onClick={() => setBanDialogOpen(true)}
398
+ />
399
+ )}
400
+ </Box>
401
+ </Box>
402
+
403
+ {loading && <LinearProgress sx={{ mb: 2 }} />}
404
+
405
+ {error && (
406
+ <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
407
+ {error}
408
+ </Alert>
409
+ )}
410
+
411
+ {success && (
412
+ <Alert severity="success" onClose={() => setSuccess(null)} sx={{ mb: 2 }}>
413
+ {success}
414
+ </Alert>
415
+ )}
416
+
417
+ {/* Stats Cards */}
418
+ {features.users && (
419
+ <GridLayout columns={features.bans ? 3 : 2} spacing="medium" sx={{ mb: 3 }} equalHeight>
420
+ <Card sx={{ bgcolor: 'var(--theme-surface)' }}>
421
+ <CardContent>
422
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
423
+ <PersonIcon sx={{ fontSize: 40, color: 'var(--theme-primary)' }} />
424
+ <Box>
425
+ <Text variant="h4" content={usersTotal.toLocaleString()} customColor="var(--theme-text-primary)" />
426
+ <Text variant="body2" content="Total Users" customColor="var(--theme-text-secondary)" />
427
+ </Box>
428
+ </Box>
429
+ </CardContent>
430
+ </Card>
431
+
432
+ {features.entitlements && (
433
+ <Card sx={{ bgcolor: 'var(--theme-surface)' }}>
434
+ <CardContent>
435
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
436
+ <LocalOfferIcon sx={{ fontSize: 40, color: 'var(--theme-success)' }} />
437
+ <Box>
438
+ <Text variant="body1" fontWeight="500" content="Entitlements" customColor="var(--theme-text-primary)" />
439
+ <Text
440
+ variant="body2"
441
+ content={features.entitlementsReadonly ? 'Read-only Mode' : 'Plugin Active'}
442
+ customColor={features.entitlementsReadonly ? 'var(--theme-warning)' : 'var(--theme-success)'}
443
+ />
444
+ </Box>
445
+ </Box>
446
+ </CardContent>
447
+ </Card>
448
+ )}
449
+
450
+ {features.bans && (
451
+ <Card sx={{ bgcolor: 'var(--theme-surface)' }}>
452
+ <CardContent>
453
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
454
+ <BlockIcon sx={{ fontSize: 40, color: bansTotal > 0 ? 'var(--theme-error)' : 'var(--theme-text-secondary)' }} />
455
+ <Box>
456
+ <Text variant="h4" content={bansTotal.toString()} customColor={bansTotal > 0 ? 'var(--theme-error)' : 'var(--theme-text-primary)'} />
457
+ <Text variant="body2" content="Banned Users" customColor="var(--theme-text-secondary)" />
458
+ </Box>
459
+ </Box>
460
+ </CardContent>
461
+ </Card>
462
+ )}
463
+ </GridLayout>
464
+ )}
465
+
466
+ {/* Main Content */}
467
+ <Card sx={{ bgcolor: 'var(--theme-surface)' }}>
468
+ {tabs.length > 1 && (
469
+ <Tabs
470
+ value={activeTab}
471
+ onChange={(_, v) => setActiveTab(v)}
472
+ sx={{ borderBottom: 1, borderColor: 'var(--theme-border)', px: 2 }}
473
+ >
474
+ {tabs.map((tab, idx) => (
475
+ <Tab key={idx} label={`${tab.label}${tab.count !== undefined ? ` (${tab.count})` : ''}`} />
476
+ ))}
477
+ </Tabs>
478
+ )}
479
+
480
+ <CardContent sx={{ p: 0 }}>
481
+ {/* Search Bar */}
482
+ <Box sx={{ p: 2, borderBottom: 1, borderColor: 'var(--theme-border)' }}>
483
+ <TextField
484
+ size="small"
485
+ placeholder="Search by email or name..."
486
+ value={usersSearch}
487
+ onChange={(e) => setUsersSearch(e.target.value)}
488
+ InputProps={{
489
+ startAdornment: (
490
+ <InputAdornment position="start">
491
+ <SearchIcon sx={{ color: 'var(--theme-text-secondary)' }} />
492
+ </InputAdornment>
493
+ ),
494
+ }}
495
+ sx={{ minWidth: 300 }}
496
+ />
497
+ </Box>
498
+
499
+ {/* Users Tab */}
500
+ {activeTab === 0 && features.users && (
501
+ <>
502
+ <TableContainer>
503
+ <Table>
504
+ <TableHead>
505
+ <TableRow>
506
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>ID</TableCell>
507
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Name</TableCell>
508
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Email</TableCell>
509
+ {features.entitlements && (
510
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }} align="center">Entitlements</TableCell>
511
+ )}
512
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Created</TableCell>
513
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }} align="right">Actions</TableCell>
514
+ </TableRow>
515
+ </TableHead>
516
+ <TableBody>
517
+ {users.map((user) => (
518
+ <TableRow
519
+ key={user.id}
520
+ hover
521
+ sx={{ cursor: onUserSelect ? 'pointer' : 'default' }}
522
+ onClick={() => onUserSelect?.(user)}
523
+ >
524
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)', fontFamily: 'monospace', fontSize: '0.75rem' }}>
525
+ {user.id.substring(0, 8)}...
526
+ </TableCell>
527
+ <TableCell sx={{ color: 'var(--theme-text-primary)', borderColor: 'var(--theme-border)' }}>
528
+ <Text variant="body1" content={user.name || '--'} fontWeight="500" />
529
+ </TableCell>
530
+ <TableCell sx={{ color: 'var(--theme-text-primary)', borderColor: 'var(--theme-border)' }}>
531
+ {user.email}
532
+ </TableCell>
533
+ {features.entitlements && (
534
+ <TableCell sx={{ borderColor: 'var(--theme-border)' }} align="center">
535
+ <Chip
536
+ size="small"
537
+ icon={<LocalOfferIcon sx={{ fontSize: 14 }} />}
538
+ label={userEntitlementCounts[user.email] ?? '...'}
539
+ sx={{
540
+ bgcolor: 'var(--theme-primary)20',
541
+ color: 'var(--theme-primary)',
542
+ }}
543
+ />
544
+ </TableCell>
545
+ )}
546
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
547
+ {formatDate(user.created_at)}
548
+ </TableCell>
549
+ <TableCell sx={{ borderColor: 'var(--theme-border)' }} align="right">
550
+ {features.entitlements && (
551
+ <Tooltip title="View entitlements">
552
+ <IconButton size="small" onClick={(e) => { e.stopPropagation(); openEntitlementsDialog(user.email); }}>
553
+ <LocalOfferIcon fontSize="small" />
554
+ </IconButton>
555
+ </Tooltip>
556
+ )}
557
+ </TableCell>
558
+ </TableRow>
559
+ ))}
560
+ {users.length === 0 && !loading && (
561
+ <TableRow>
562
+ <TableCell colSpan={features.entitlements ? 6 : 5} align="center" sx={{ py: 4, color: 'var(--theme-text-secondary)' }}>
563
+ {usersSearch ? 'No users match your search' : 'No users found'}
564
+ </TableCell>
565
+ </TableRow>
566
+ )}
567
+ </TableBody>
568
+ </Table>
569
+ </TableContainer>
570
+ <TablePagination
571
+ component="div"
572
+ count={usersTotal}
573
+ page={usersPage}
574
+ onPageChange={(_, page) => setUsersPage(page)}
575
+ rowsPerPage={usersPerPage}
576
+ onRowsPerPageChange={(e) => {
577
+ setUsersPerPage(parseInt(e.target.value, 10));
578
+ setUsersPage(0);
579
+ }}
580
+ rowsPerPageOptions={[10, 25, 50, 100]}
581
+ sx={{ borderTop: 1, borderColor: 'var(--theme-border)' }}
582
+ />
583
+ </>
584
+ )}
585
+
586
+ {/* Banned Users Tab */}
587
+ {activeTab === 1 && features.bans && (
588
+ <TableContainer>
589
+ <Table>
590
+ <TableHead>
591
+ <TableRow>
592
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Email</TableCell>
593
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Reason</TableCell>
594
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Banned At</TableCell>
595
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Expires</TableCell>
596
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>Banned By</TableCell>
597
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }} align="right">Actions</TableCell>
598
+ </TableRow>
599
+ </TableHead>
600
+ <TableBody>
601
+ {bans.map((ban) => (
602
+ <TableRow key={ban.id}>
603
+ <TableCell sx={{ color: 'var(--theme-text-primary)', borderColor: 'var(--theme-border)' }}>
604
+ <Text variant="body1" content={ban.email} fontWeight="500" />
605
+ </TableCell>
606
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)', maxWidth: 200 }}>
607
+ <Text variant="body2" content={ban.reason} noWrap />
608
+ </TableCell>
609
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
610
+ {formatDate(ban.banned_at)}
611
+ </TableCell>
612
+ <TableCell sx={{ borderColor: 'var(--theme-border)' }}>
613
+ <Chip
614
+ size="small"
615
+ label={ban.expires_at ? formatDate(ban.expires_at) : 'Permanent'}
616
+ sx={{
617
+ bgcolor: ban.expires_at ? 'var(--theme-warning)20' : 'var(--theme-error)20',
618
+ color: ban.expires_at ? 'var(--theme-warning)' : 'var(--theme-error)',
619
+ }}
620
+ />
621
+ </TableCell>
622
+ <TableCell sx={{ color: 'var(--theme-text-secondary)', borderColor: 'var(--theme-border)' }}>
623
+ {ban.banned_by}
624
+ </TableCell>
625
+ <TableCell sx={{ borderColor: 'var(--theme-border)' }} align="right">
626
+ <Button
627
+ buttonSize="small"
628
+ variant="text"
629
+ color="success"
630
+ icon="check_circle"
631
+ label="Unban"
632
+ onClick={() => handleUnbanUser(ban.email)}
633
+ />
634
+ </TableCell>
635
+ </TableRow>
636
+ ))}
637
+ {bans.length === 0 && !loading && (
638
+ <TableRow>
639
+ <TableCell colSpan={6} align="center" sx={{ py: 4, color: 'var(--theme-text-secondary)' }}>
640
+ No users are currently banned
641
+ </TableCell>
642
+ </TableRow>
643
+ )}
644
+ </TableBody>
645
+ </Table>
646
+ </TableContainer>
647
+ )}
648
+ </CardContent>
649
+ </Card>
650
+
651
+ {/* Ban User Dialog */}
652
+ {features.bans && (
653
+ <Dialog
654
+ open={banDialogOpen}
655
+ onClose={() => setBanDialogOpen(false)}
656
+ maxWidth="sm"
657
+ fullWidth
658
+ >
659
+ <DialogTitle>Ban User</DialogTitle>
660
+ <DialogContent>
661
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
662
+ <TextField
663
+ label="Email"
664
+ fullWidth
665
+ value={newBan.email}
666
+ onChange={(e) => setNewBan({ ...newBan, email: e.target.value })}
667
+ placeholder="Enter user email"
668
+ />
669
+ <TextField
670
+ label="Reason"
671
+ fullWidth
672
+ multiline
673
+ rows={3}
674
+ value={newBan.reason}
675
+ onChange={(e) => setNewBan({ ...newBan, reason: e.target.value })}
676
+ placeholder="Enter reason for ban"
677
+ />
678
+ <TextField
679
+ label="Expiration (Optional)"
680
+ type="datetime-local"
681
+ fullWidth
682
+ value={newBan.expiresAt}
683
+ onChange={(e) => setNewBan({ ...newBan, expiresAt: e.target.value })}
684
+ InputLabelProps={{ shrink: true }}
685
+ helperText="Leave empty for permanent ban"
686
+ />
687
+ </Box>
688
+ </DialogContent>
689
+ <DialogActions>
690
+ <Button
691
+ variant="text"
692
+ label="Cancel"
693
+ onClick={() => {
694
+ setBanDialogOpen(false);
695
+ setNewBan({ email: '', reason: '', expiresAt: '' });
696
+ }}
697
+ />
698
+ <Button
699
+ variant="primary"
700
+ color="error"
701
+ label="Ban User"
702
+ onClick={handleBanUser}
703
+ disabled={!newBan.email || !newBan.reason}
704
+ />
705
+ </DialogActions>
706
+ </Dialog>
707
+ )}
708
+
709
+ {/* Entitlements Lookup Dialog */}
710
+ {features.entitlements && (
711
+ <Dialog
712
+ open={entitlementsDialogOpen}
713
+ onClose={() => setEntitlementsDialogOpen(false)}
714
+ maxWidth="md"
715
+ fullWidth
716
+ >
717
+ <DialogTitle>User Entitlements</DialogTitle>
718
+ <DialogContent>
719
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
720
+ <Box sx={{ display: 'flex', gap: 1 }}>
721
+ <TextField
722
+ label="Email"
723
+ fullWidth
724
+ value={entitlementsSearch}
725
+ onChange={(e) => setEntitlementsSearch(e.target.value)}
726
+ placeholder="Enter user email"
727
+ onKeyDown={(e) => e.key === 'Enter' && handleEntitlementsSearch()}
728
+ />
729
+ <Button
730
+ variant="primary"
731
+ icon="search"
732
+ label="Lookup"
733
+ onClick={handleEntitlementsSearch}
734
+ disabled={entitlementsLoading}
735
+ />
736
+ </Box>
737
+
738
+ {entitlementsLoading && (
739
+ <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
740
+ <CircularProgress />
741
+ </Box>
742
+ )}
743
+
744
+ {entitlementsError && (
745
+ <Alert severity="error">{entitlementsError}</Alert>
746
+ )}
747
+
748
+ {entitlementsData && (
749
+ <Box>
750
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
751
+ <Box>
752
+ <Text variant="h6" content={entitlementsData.identifier} customColor="var(--theme-text-primary)" />
753
+ <Text variant="body2" content={`Source: ${entitlementsData.source}`} customColor="var(--theme-text-secondary)" />
754
+ </Box>
755
+ <Button
756
+ variant="outlined"
757
+ icon="refresh"
758
+ label={entitlementsRefreshing ? 'Refreshing...' : 'Refresh'}
759
+ onClick={handleEntitlementsRefresh}
760
+ disabled={entitlementsRefreshing}
761
+ buttonSize="small"
762
+ />
763
+ </Box>
764
+
765
+ {/* Grant Entitlement Section (only if not readonly) */}
766
+ {!features.entitlementsReadonly && grantableEntitlements.length > 0 && (
767
+ <Box sx={{ display: 'flex', gap: 1, mb: 2, p: 2, bgcolor: 'var(--theme-background)', borderRadius: 1 }}>
768
+ <Autocomplete
769
+ size="small"
770
+ options={grantableEntitlements}
771
+ getOptionLabel={(option) => option.name}
772
+ value={grantableEntitlements.find((e) => e.name === selectedEntitlement) || null}
773
+ onChange={(_, newValue) => setSelectedEntitlement(newValue?.name || '')}
774
+ renderInput={(params) => (
775
+ <TextField {...params} label="Grant Entitlement" placeholder="Select entitlement" />
776
+ )}
777
+ sx={{ flex: 1 }}
778
+ />
779
+ <Button
780
+ variant="primary"
781
+ icon="add"
782
+ label="Grant"
783
+ onClick={handleGrantEntitlement}
784
+ disabled={!selectedEntitlement || grantingEntitlement}
785
+ buttonSize="small"
786
+ />
787
+ </Box>
788
+ )}
789
+
790
+ <Text variant="subtitle2" content="Current Entitlements" customColor="var(--theme-text-secondary)" style={{ marginBottom: '8px' }} />
791
+ {entitlementsData.entitlements.length === 0 ? (
792
+ <Text variant="body2" content="No entitlements found" customColor="var(--theme-text-secondary)" />
793
+ ) : (
794
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
795
+ {entitlementsData.entitlements.map((ent, idx) => (
796
+ <Chip
797
+ key={idx}
798
+ icon={<CheckCircleIcon sx={{ fontSize: 16 }} />}
799
+ label={ent}
800
+ onDelete={!features.entitlementsReadonly ? () => handleRevokeEntitlement(ent) : undefined}
801
+ deleteIcon={<DeleteIcon sx={{ fontSize: 16 }} />}
802
+ sx={{
803
+ bgcolor: 'var(--theme-success)20',
804
+ color: 'var(--theme-success)',
805
+ '& .MuiChip-deleteIcon': {
806
+ color: 'var(--theme-error)',
807
+ '&:hover': {
808
+ color: 'var(--theme-error)',
809
+ },
810
+ },
811
+ }}
812
+ />
813
+ ))}
814
+ </Box>
815
+ )}
816
+
817
+ <Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'var(--theme-border)' }}>
818
+ <Text variant="caption" content={`Data from: ${entitlementsData.source === 'cache' ? 'Cache' : 'Source'}`} customColor="var(--theme-text-secondary)" />
819
+ {entitlementsData.cachedAt && (
820
+ <Text variant="caption" content={` | Cached: ${formatDate(entitlementsData.cachedAt)}`} customColor="var(--theme-text-secondary)" />
821
+ )}
822
+ {features.entitlementsReadonly && (
823
+ <Text variant="caption" content=" | Read-only mode (modifications disabled)" customColor="var(--theme-warning)" />
824
+ )}
825
+ </Box>
826
+ </Box>
827
+ )}
828
+ </Box>
829
+ </DialogContent>
830
+ <DialogActions>
831
+ <Button variant="text" label="Close" onClick={() => setEntitlementsDialogOpen(false)} />
832
+ </DialogActions>
833
+ </Dialog>
834
+ )}
835
+ </Box>
836
+ );
837
+ }