UserManagementPage.vala 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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. * This component uses the inject<> pattern for dependency injection.
  28. */
  29. public class UserManagementPage : PageComponent {
  30. private PermissionService _permission_service = inject<PermissionService>();
  31. private UserService _user_service = inject<UserService>();
  32. private SessionService _session_service = inject<SessionService>();
  33. private ComponentFactory _factory = inject<ComponentFactory>();
  34. private HttpContext _http_context = inject<HttpContext>();
  35. // =========================================================================
  36. // State Properties (must be public for template expression access)
  37. // =========================================================================
  38. public string? success_message { get; private set; default = null; }
  39. public string? error_message { get; private set; default = null; }
  40. public bool access_denied { get; private set; default = false; }
  41. // =========================================================================
  42. // Component Implementation
  43. // =========================================================================
  44. public override string markup { get {
  45. return """
  46. <!DOCTYPE html>
  47. <html lang="en">
  48. <head>
  49. <meta charset="UTF-8"/>
  50. <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  51. <title>User Management - Admin</title>
  52. <script spry-res="htmx.js"></script>
  53. <style>
  54. /* Basic admin styles */
  55. * { box-sizing: border-box; }
  56. body {
  57. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  58. margin: 0;
  59. padding: 0;
  60. background: #f5f5f5;
  61. color: #333;
  62. }
  63. .admin-container {
  64. max-width: 1200px;
  65. margin: 0 auto;
  66. padding: 2rem;
  67. }
  68. .admin-header {
  69. display: flex;
  70. justify-content: space-between;
  71. align-items: center;
  72. margin-bottom: 2rem;
  73. padding-bottom: 1rem;
  74. border-bottom: 1px solid #e0e0e0;
  75. }
  76. .admin-header h1 {
  77. margin: 0;
  78. font-size: 1.75rem;
  79. color: #333;
  80. }
  81. .btn {
  82. display: inline-block;
  83. padding: 0.5rem 1rem;
  84. border: none;
  85. border-radius: 4px;
  86. cursor: pointer;
  87. font-size: 0.875rem;
  88. font-weight: 500;
  89. text-decoration: none;
  90. transition: background-color 0.2s;
  91. }
  92. .btn-primary { background: #007bff; color: white; }
  93. .btn-primary:hover { background: #0056b3; }
  94. .btn-secondary { background: #6c757d; color: white; }
  95. .btn-secondary:hover { background: #545b62; }
  96. .btn-edit { background: #17a2b8; color: white; }
  97. .btn-delete { background: #dc3545; color: white; }
  98. .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
  99. .btn:disabled { opacity: 0.5; cursor: not-allowed; }
  100. /* Alerts */
  101. .alert {
  102. padding: 1rem;
  103. border-radius: 4px;
  104. margin-bottom: 1rem;
  105. }
  106. .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
  107. .alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
  108. /* Access Denied */
  109. .access-denied {
  110. text-align: center;
  111. padding: 3rem;
  112. }
  113. .access-denied h2 { color: #dc3545; }
  114. </style>
  115. </head>
  116. <body>
  117. <div class="admin-container">
  118. <div class="admin-header">
  119. <h1>User Management</h1>
  120. <button sid="create-btn"
  121. spry-action=":CreateUser"
  122. class="btn btn-primary">
  123. + Create User
  124. </button>
  125. </div>
  126. <!-- Access Denied Message -->
  127. <div spry-if="this.access_denied" class="access-denied" sid="accessDeniedMsg">
  128. <h2>Access Denied</h2>
  129. <p>You do not have permission to access this page.</p>
  130. </div>
  131. <!-- Success Message -->
  132. <div spry-if="this.success_message != null" class="alert alert-success" sid="successAlert">
  133. <span content-expr="this.success_message"></span>
  134. </div>
  135. <!-- Error Message -->
  136. <div spry-if="this.error_message != null" class="alert alert-error" sid="errorAlert">
  137. <span content-expr="this.error_message"></span>
  138. </div>
  139. <!-- Main Content (hidden if access denied) -->
  140. <div spry-if="!this.access_denied" sid="mainContent">
  141. <!-- User List Component -->
  142. <spry-component name="UserListComponent" sid="user-list"/>
  143. <!-- User Form Modal -->
  144. <div id="user-form-container" sid="user-form-container">
  145. <spry-component name="UserFormComponent" sid="user-form"/>
  146. </div>
  147. </div>
  148. </div>
  149. </body>
  150. </html>
  151. """;
  152. }}
  153. public async override void prepare() throws Error {
  154. // Check permission
  155. var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
  156. if (!auth_result.is_authenticated || auth_result.user == null) {
  157. access_denied = true;
  158. return;
  159. }
  160. var has_permission = yield _permission_service.has_permission_by_id_async(
  161. auth_result.user.id,
  162. PermissionService.USER_MANAGEMENT
  163. );
  164. if (!has_permission) {
  165. access_denied = true;
  166. return;
  167. }
  168. // Share globals with child components
  169. var user_list = get_component_child<UserListComponent>("user-list");
  170. if (user_list != null) {
  171. add_globals_from(user_list);
  172. }
  173. var user_form = get_component_child<UserFormComponent>("user-form");
  174. if (user_form != null) {
  175. add_globals_from(user_form);
  176. }
  177. }
  178. public async override void handle_action(string action) throws Error {
  179. // Check permission for all actions
  180. if (access_denied) {
  181. return;
  182. }
  183. var query = _http_context.request.query_params;
  184. switch (action) {
  185. case "CreateUser":
  186. yield handle_create_user_async();
  187. break;
  188. case "EditUser":
  189. var user_id = get_query_value(query, "user_id");
  190. yield handle_edit_user_async(user_id);
  191. break;
  192. case "ToggleActive":
  193. var user_id = get_query_value(query, "user_id");
  194. yield handle_toggle_active_async(user_id);
  195. break;
  196. case "DeleteUser":
  197. var user_id = get_query_value(query, "user_id");
  198. yield handle_delete_user_async(user_id);
  199. break;
  200. }
  201. }
  202. // =========================================================================
  203. // Action Handlers
  204. // =========================================================================
  205. private async void handle_create_user_async() throws Error {
  206. var user_form = get_component_child<UserFormComponent>("user-form");
  207. if (user_form != null) {
  208. user_form.show_create();
  209. }
  210. success_message = null;
  211. error_message = null;
  212. }
  213. private async void handle_edit_user_async(string user_id) throws Error {
  214. if (user_id.length == 0) {
  215. error_message = "Invalid user ID";
  216. return;
  217. }
  218. var user = yield _user_service.get_user_async(user_id);
  219. if (user == null) {
  220. error_message = "User not found";
  221. return;
  222. }
  223. var user_form = get_component_child<UserFormComponent>("user-form");
  224. if (user_form != null) {
  225. user_form.set_user(user);
  226. }
  227. success_message = null;
  228. error_message = null;
  229. }
  230. private async void handle_toggle_active_async(string user_id) throws Error {
  231. // Note: Current User model doesn't have is_active field
  232. // This is a placeholder for future implementation
  233. error_message = "Toggle active functionality not yet implemented";
  234. success_message = null;
  235. // Refresh the list
  236. var user_list = get_component_child<UserListComponent>("user-list");
  237. if (user_list != null) {
  238. add_globals_from(user_list);
  239. }
  240. }
  241. private async void handle_delete_user_async(string user_id) throws Error {
  242. if (user_id.length == 0) {
  243. error_message = "Invalid user ID";
  244. return;
  245. }
  246. // Prevent self-deletion
  247. var auth_result = yield _session_service.authenticate_request_async(_http_context, _user_service);
  248. if (auth_result.is_authenticated && auth_result.user != null) {
  249. if (auth_result.user.id == user_id) {
  250. error_message = "Cannot delete your own account";
  251. success_message = null;
  252. // Refresh the list
  253. var user_list = get_component_child<UserListComponent>("user-list");
  254. if (user_list != null) {
  255. add_globals_from(user_list);
  256. }
  257. return;
  258. }
  259. }
  260. // Delete the user
  261. try {
  262. yield _user_service.delete_user_async(user_id);
  263. success_message = "User deleted successfully";
  264. error_message = null;
  265. } catch (Error e) {
  266. error_message = e.message;
  267. success_message = null;
  268. }
  269. // Refresh the list
  270. var user_list = get_component_child<UserListComponent>("user-list");
  271. if (user_list != null) {
  272. add_globals_from(user_list);
  273. }
  274. }
  275. // =========================================================================
  276. // Private Helpers
  277. // =========================================================================
  278. private string get_query_value(Catalogue<string, string> query, string key) {
  279. var value = query.get_any_or_default(key);
  280. return value != null ? ((!)value).strip() : "";
  281. }
  282. }
  283. }