UserManagementPage.vala 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. using Spry;
  2. using Inversion;
  3. using Astralis;
  4. using Invercargill;
  5. using Invercargill.DataStructures;
  6. namespace Spry.Users.Components {
  7. /**
  8. * UserManagementPage - PageComponent orchestrating all user management components.
  9. *
  10. * This page provides a complete user management interface that includes:
  11. * - Permission check: Only accessible with "user-management" permission
  12. * - User list with search, filter, and pagination
  13. * - User form modal for create/edit operations
  14. * - Status messages (success/error alerts)
  15. *
  16. * Cross-Component Communication:
  17. * - Child components trigger actions via "UserManagementPage:ActionName" pattern
  18. * - The page handles these actions and updates child components accordingly
  19. * - Uses add_globals_from() to share globals with child components
  20. *
  21. * Required Permission: "user-management"
  22. *
  23. * Usage:
  24. * // Register as a page:
  25. * spry_cfg.add_page<UserManagementPage>(new EndpointRoute("/admin/users"));
  26. */
  27. public class UserManagementPage : PageComponent {
  28. private PermissionService permission_service = inject<PermissionService>();
  29. private UserService user_service = inject<UserService>();
  30. private SessionService session_service = inject<SessionService>();
  31. private ComponentFactory factory = inject<ComponentFactory>();
  32. private HttpContext http_context = inject<HttpContext>();
  33. // =========================================================================
  34. // State Properties
  35. // =========================================================================
  36. private string? _success_message = null;
  37. private string? _error_message = null;
  38. private bool _access_denied = false;
  39. // =========================================================================
  40. // Component Implementation
  41. // =========================================================================
  42. public override string markup { get {
  43. return """
  44. <!DOCTYPE html>
  45. <html lang="en">
  46. <head>
  47. <meta charset="UTF-8"/>
  48. <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  49. <title>User Management - Admin</title>
  50. <script spry-res="htmx.js"></script>
  51. <style>
  52. /* Basic admin styles */
  53. * { box-sizing: border-box; }
  54. body {
  55. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  56. margin: 0;
  57. padding: 0;
  58. background: #f5f5f5;
  59. color: #333;
  60. }
  61. .admin-container {
  62. max-width: 1200px;
  63. margin: 0 auto;
  64. padding: 2rem;
  65. }
  66. .admin-header {
  67. display: flex;
  68. justify-content: space-between;
  69. align-items: center;
  70. margin-bottom: 2rem;
  71. padding-bottom: 1rem;
  72. border-bottom: 1px solid #e0e0e0;
  73. }
  74. .admin-header h1 {
  75. margin: 0;
  76. font-size: 1.75rem;
  77. color: #333;
  78. }
  79. .btn {
  80. display: inline-block;
  81. padding: 0.5rem 1rem;
  82. border: none;
  83. border-radius: 4px;
  84. cursor: pointer;
  85. font-size: 0.875rem;
  86. font-weight: 500;
  87. text-decoration: none;
  88. transition: background-color 0.2s;
  89. }
  90. .btn-primary { background: #007bff; color: white; }
  91. .btn-primary:hover { background: #0056b3; }
  92. .btn-secondary { background: #6c757d; color: white; }
  93. .btn-secondary:hover { background: #545b62; }
  94. .btn-edit { background: #17a2b8; color: white; }
  95. .btn-delete { background: #dc3545; color: white; }
  96. .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
  97. .btn:disabled { opacity: 0.5; cursor: not-allowed; }
  98. /* Alerts */
  99. .alert {
  100. padding: 1rem;
  101. border-radius: 4px;
  102. margin-bottom: 1rem;
  103. }
  104. .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
  105. .alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
  106. /* Access Denied */
  107. .access-denied {
  108. text-align: center;
  109. padding: 4rem 2rem;
  110. }
  111. .access-denied h2 { color: #dc3545; }
  112. /* Modal */
  113. .modal-overlay {
  114. position: fixed;
  115. top: 0;
  116. left: 0;
  117. right: 0;
  118. bottom: 0;
  119. background: rgba(0,0,0,0.5);
  120. display: flex;
  121. align-items: center;
  122. justify-content: center;
  123. z-index: 1000;
  124. }
  125. .modal-content {
  126. background: white;
  127. padding: 2rem;
  128. border-radius: 8px;
  129. max-width: 500px;
  130. width: 90%;
  131. max-height: 90vh;
  132. overflow-y: auto;
  133. box-shadow: 0 4px 6px rgba(0,0,0,0.1);
  134. }
  135. .modal-header {
  136. display: flex;
  137. justify-content: space-between;
  138. align-items: center;
  139. margin-bottom: 1.5rem;
  140. padding-bottom: 1rem;
  141. border-bottom: 1px solid #e0e0e0;
  142. }
  143. .modal-header h3 { margin: 0; }
  144. .close-btn {
  145. background: none;
  146. border: none;
  147. font-size: 1.5rem;
  148. cursor: pointer;
  149. color: #666;
  150. }
  151. .close-btn:hover { color: #333; }
  152. /* Form */
  153. .form-group { margin-bottom: 1rem; }
  154. .form-group label {
  155. display: block;
  156. margin-bottom: 0.25rem;
  157. font-weight: 500;
  158. }
  159. .form-input {
  160. width: 100%;
  161. padding: 0.5rem;
  162. border: 1px solid #ccc;
  163. border-radius: 4px;
  164. font-size: 1rem;
  165. }
  166. .form-input:focus { outline: none; border-color: #007bff; }
  167. .form-hint { font-size: 0.75rem; color: #666; margin-top: 0.25rem; }
  168. .form-actions {
  169. display: flex;
  170. gap: 0.5rem;
  171. margin-top: 1.5rem;
  172. padding-top: 1rem;
  173. border-top: 1px solid #e0e0e0;
  174. }
  175. /* Badges */
  176. .badge {
  177. display: inline-block;
  178. padding: 0.25rem 0.5rem;
  179. border-radius: 4px;
  180. font-size: 0.75rem;
  181. margin-right: 0.25rem;
  182. margin-bottom: 0.25rem;
  183. }
  184. .badge-active { background: #d4edda; color: #155724; }
  185. .badge-inactive { background: #f8d7da; color: #721c24; }
  186. .badge-permission { background: #e9ecef; color: #495057; }
  187. /* Table */
  188. .table-container { overflow-x: auto; }
  189. .user-table {
  190. width: 100%;
  191. border-collapse: collapse;
  192. background: white;
  193. border-radius: 4px;
  194. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  195. }
  196. .user-table th,
  197. .user-table td {
  198. padding: 0.75rem;
  199. text-align: left;
  200. border-bottom: 1px solid #e0e0e0;
  201. }
  202. .user-table th {
  203. background: #f8f9fa;
  204. font-weight: 600;
  205. }
  206. .user-table tr:hover { background: #f8f9fa; }
  207. /* Search */
  208. .search-bar { margin-bottom: 1rem; }
  209. .search-form { display: flex; gap: 0.5rem; }
  210. .search-input {
  211. flex: 1;
  212. padding: 0.5rem;
  213. border: 1px solid #ccc;
  214. border-radius: 4px;
  215. }
  216. .btn-search { background: #28a745; color: white; }
  217. .btn-clear { background: #6c757d; color: white; }
  218. /* Pagination */
  219. .pagination {
  220. display: flex;
  221. justify-content: center;
  222. align-items: center;
  223. gap: 1rem;
  224. margin-top: 1rem;
  225. }
  226. .page-info { color: #666; }
  227. /* Empty State */
  228. .empty-state {
  229. text-align: center;
  230. padding: 3rem;
  231. background: white;
  232. border-radius: 4px;
  233. }
  234. .user-count {
  235. text-align: center;
  236. color: #666;
  237. margin-top: 1rem;
  238. font-size: 0.875rem;
  239. }
  240. /* Permission Editor */
  241. .permission-checkbox {
  242. display: inline-flex;
  243. align-items: center;
  244. margin-right: 1rem;
  245. margin-bottom: 0.5rem;
  246. }
  247. .permission-checkbox input { margin-right: 0.5rem; }
  248. .permission-tag {
  249. display: inline-flex;
  250. align-items: center;
  251. background: #e9ecef;
  252. padding: 0.25rem 0.5rem;
  253. border-radius: 4px;
  254. margin: 0.25rem;
  255. }
  256. .permission-tag .remove-tag {
  257. background: none;
  258. border: none;
  259. margin-left: 0.5rem;
  260. cursor: pointer;
  261. color: #666;
  262. font-size: 1rem;
  263. line-height: 1;
  264. }
  265. .permission-tag .remove-tag:hover { color: #dc3545; }
  266. .permission-input {
  267. padding: 0.5rem;
  268. border: 1px solid #ccc;
  269. border-radius: 4px;
  270. }
  271. .btn-add { background: #28a745; color: white; }
  272. .help-text { color: #666; font-size: 0.75rem; margin-top: 0.5rem; display: block; }
  273. .permission-group { margin-bottom: 1rem; }
  274. .permission-group h4 { margin-bottom: 0.5rem; font-size: 0.875rem; }
  275. </style>
  276. </head>
  277. <body>
  278. <div class="admin-container" sid="admin-container">
  279. <!-- Access Denied Message -->
  280. <div spry-if="this._access_denied" class="access-denied" sid="access-denied">
  281. <h2>Access Denied</h2>
  282. <p>You do not have permission to access the user management page.</p>
  283. <p>Please contact an administrator if you believe this is an error.</p>
  284. </div>
  285. <!-- Main Content (shown when authorized) -->
  286. <div spry-if="!this._access_denied">
  287. <header class="admin-header">
  288. <h1>User Management</h1>
  289. <div class="header-actions">
  290. <button sid="create-btn"
  291. spry-action=":CreateUser"
  292. hx-target="#user-form-container"
  293. hx-swap="innerHTML"
  294. class="btn btn-primary">
  295. + Create User
  296. </button>
  297. </div>
  298. </header>
  299. <!-- Success Message -->
  300. <div spry-if="this._success_message != null"
  301. class="alert alert-success"
  302. sid="success-alert"
  303. spry-global="success-alert">
  304. <span content-expr="this._success_message"></span>
  305. </div>
  306. <!-- Error Message -->
  307. <div spry-if="this._error_message != null"
  308. class="alert alert-error"
  309. sid="error-alert"
  310. spry-global="error-alert">
  311. <span content-expr="this._error_message"></span>
  312. </div>
  313. <!-- User List Component -->
  314. <spry-component name="UserListComponent" sid="user-list"/>
  315. <!-- User Form Container (for modal) -->
  316. <div id="user-form-container" sid="user-form-container">
  317. <spry-component name="UserFormComponent" sid="user-form"/>
  318. </div>
  319. </div>
  320. </div>
  321. </body>
  322. </html>
  323. """;
  324. }}
  325. public override async void prepare() throws Error {
  326. // Check permission
  327. var auth_result = session_service.authenticate_request(http_context, user_service);
  328. if (!auth_result.is_authenticated) {
  329. _access_denied = true;
  330. return;
  331. }
  332. var current_user = auth_result.user;
  333. if (current_user == null) {
  334. _access_denied = true;
  335. return;
  336. }
  337. // Check for user-management permission
  338. if (!permission_service.has_permission(current_user, PermissionService.USER_MANAGEMENT)) {
  339. _access_denied = true;
  340. return;
  341. }
  342. _access_denied = false;
  343. // Ensure form is hidden initially
  344. var user_form = get_component_child<UserFormComponent>("user-form");
  345. user_form.hide();
  346. }
  347. public async override void handle_action(string action) throws Error {
  348. // Don't process actions if access is denied
  349. if (_access_denied) {
  350. return;
  351. }
  352. var query = http_context.request.query_params;
  353. switch (action) {
  354. case "CreateUser":
  355. handle_create_user();
  356. break;
  357. case "EditUser":
  358. var user_id = get_query_value(query, "user_id");
  359. handle_edit_user(user_id);
  360. break;
  361. case "ToggleActive":
  362. var user_id = get_query_value(query, "user_id");
  363. yield handle_toggle_active(user_id);
  364. break;
  365. case "DeleteUser":
  366. var user_id = get_query_value(query, "user_id");
  367. yield handle_delete_user(user_id);
  368. break;
  369. }
  370. }
  371. // =========================================================================
  372. // Action Handlers
  373. // =========================================================================
  374. private void handle_create_user() throws Error {
  375. var user_form = get_component_child<UserFormComponent>("user-form");
  376. user_form.show_create();
  377. _success_message = null;
  378. _error_message = null;
  379. }
  380. private void handle_edit_user(string user_id) throws Error {
  381. if (user_id.length == 0) {
  382. _error_message = "Invalid user ID";
  383. return;
  384. }
  385. var user = user_service.get_user(user_id);
  386. if (user == null) {
  387. _error_message = "User not found";
  388. return;
  389. }
  390. var user_form = get_component_child<UserFormComponent>("user-form");
  391. user_form.set_user(user);
  392. _success_message = null;
  393. _error_message = null;
  394. }
  395. private async void handle_toggle_active(string user_id) throws Error {
  396. // Note: Current User model doesn't have is_active field
  397. // This is a placeholder for future implementation
  398. _error_message = "Toggle active functionality not yet implemented";
  399. _success_message = null;
  400. // Refresh the list
  401. var user_list = get_component_child<UserListComponent>("user-list");
  402. add_globals_from(user_list);
  403. }
  404. private async void handle_delete_user(string user_id) throws Error {
  405. if (user_id.length == 0) {
  406. _error_message = "Invalid user ID";
  407. return;
  408. }
  409. // Prevent self-deletion
  410. var auth_result = session_service.authenticate_request(http_context, user_service);
  411. if (auth_result.is_authenticated && auth_result.user != null) {
  412. if (auth_result.user.id == user_id) {
  413. _error_message = "Cannot delete your own account";
  414. _success_message = null;
  415. // Refresh the list
  416. var user_list = get_component_child<UserListComponent>("user-list");
  417. add_globals_from(user_list);
  418. return;
  419. }
  420. }
  421. // Delete the user
  422. string? delete_error;
  423. if (!user_service.delete_user(user_id, out delete_error)) {
  424. _error_message = delete_error ?? "Failed to delete user";
  425. _success_message = null;
  426. } else {
  427. _success_message = "User deleted successfully";
  428. _error_message = null;
  429. }
  430. // Refresh the list
  431. var user_list = get_component_child<UserListComponent>("user-list");
  432. add_globals_from(user_list);
  433. }
  434. // =========================================================================
  435. // Private Helpers
  436. // =========================================================================
  437. private string get_query_value(Catalogue<string, string> query, string key) {
  438. var value = query.get_any_or_default(key);
  439. return value != null ? ((!)value).strip() : "";
  440. }
  441. }
  442. }