UserFormComponent.vala 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. using Spry;
  2. using Inversion;
  3. using Astralis;
  4. using Invercargill;
  5. using Invercargill.DataStructures;
  6. namespace Spry.Users.Components {
  7. /**
  8. * UserFormComponent - Modal form for creating and editing users.
  9. *
  10. * This component provides:
  11. * - Modal dialog for create/edit operations
  12. * - Form fields: username, email, password (create only), confirm password
  13. * - Validation with error messages
  14. * - Permission editor integration
  15. * - HTMX-based submission
  16. *
  17. * The component can operate in two modes:
  18. * - Create mode: All fields including password are required
  19. * - Edit mode: Password is optional; only set if changing
  20. *
  21. * Usage:
  22. * // Create mode:
  23. * var form = factory.create<UserFormComponent>();
  24. * form.show_create();
  25. *
  26. * // Edit mode:
  27. * var form = factory.create<UserFormComponent>();
  28. * form.set_user(existing_user);
  29. */
  30. public class UserFormComponent : Component {
  31. private UserService user_service = inject<UserService>();
  32. private ComponentFactory factory = inject<ComponentFactory>();
  33. private HttpContext http_context = inject<HttpContext>();
  34. // =========================================================================
  35. // State Properties
  36. // =========================================================================
  37. private User? _editing_user = null;
  38. private bool _is_visible = false;
  39. private string? _error_message = null;
  40. private string? _success_message = null;
  41. // =========================================================================
  42. // Configuration Properties
  43. // =========================================================================
  44. /**
  45. * Whether to display as a modal dialog.
  46. * Default: true
  47. */
  48. public bool is_modal { get; set; default = true; }
  49. // =========================================================================
  50. // Public API
  51. // =========================================================================
  52. /**
  53. * Sets the user to edit and shows the form in edit mode.
  54. *
  55. * @param user The user to edit
  56. */
  57. public void set_user(User user) {
  58. _editing_user = user;
  59. _is_visible = true;
  60. _error_message = null;
  61. _success_message = null;
  62. }
  63. /**
  64. * Shows the form in create mode.
  65. */
  66. public void show_create() {
  67. _editing_user = null;
  68. _is_visible = true;
  69. _error_message = null;
  70. _success_message = null;
  71. }
  72. /**
  73. * Hides the form and clears state.
  74. */
  75. public void hide() {
  76. _is_visible = false;
  77. _editing_user = null;
  78. _error_message = null;
  79. _success_message = null;
  80. }
  81. /**
  82. * Returns true if in create mode (not editing an existing user).
  83. */
  84. public bool is_creating {
  85. get { return _editing_user == null; }
  86. }
  87. /**
  88. * Returns true if the form is currently visible.
  89. */
  90. public bool visible {
  91. get { return _is_visible; }
  92. }
  93. /**
  94. * Gets the current error message, if any.
  95. */
  96. public string? error_message {
  97. get { return _error_message; }
  98. }
  99. // =========================================================================
  100. // Component Implementation
  101. // =========================================================================
  102. public override string markup { get {
  103. return """
  104. <div class="spry-user-form-container" sid="form-container" hx-swap="outerHTML">
  105. <script spry-res="htmx.js"></script>
  106. <!-- Hidden state (no modal visible) -->
  107. <div spry-if="!this._is_visible" sid="hidden-state"></div>
  108. <!-- Modal Overlay (shown when visible) -->
  109. <div spry-if="this._is_visible" class="modal-overlay" sid="modal">
  110. <div class="modal-content" sid="modal-content">
  111. <div class="modal-header">
  112. <h3 content-expr="this.is_creating ? 'Create New User' : 'Edit User'"></h3>
  113. <button sid="close-btn"
  114. spry-action=":Cancel"
  115. spry-target="form-container"
  116. class="close-btn">&times;</button>
  117. </div>
  118. <!-- Error Message -->
  119. <div spry-if="this._error_message != null" class="error alert" sid="error">
  120. <span content-expr="this._error_message"></span>
  121. </div>
  122. <!-- Form -->
  123. <form sid="form" spry-action=":Save" spry-target="form-container">
  124. <input type="hidden" name="user_id" sid="user-id"/>
  125. <div class="form-body">
  126. <!-- Username -->
  127. <div class="form-group">
  128. <label for="username">Username *</label>
  129. <input type="text"
  130. name="username"
  131. sid="form-username"
  132. required
  133. minlength="3"
  134. pattern="[a-zA-Z0-9_]+"
  135. placeholder="Enter username"
  136. class="form-input"/>
  137. <small class="form-hint">Alphanumeric characters and underscores only, minimum 3 characters</small>
  138. </div>
  139. <!-- Email -->
  140. <div class="form-group">
  141. <label for="email">Email *</label>
  142. <input type="email"
  143. name="email"
  144. sid="form-email"
  145. required
  146. placeholder="Enter email address"
  147. class="form-input"/>
  148. </div>
  149. <!-- Password (Create Mode) -->
  150. <div class="form-group" spry-if="this.is_creating">
  151. <label for="password">Password *</label>
  152. <input type="password"
  153. name="password"
  154. sid="form-password"
  155. required
  156. minlength="8"
  157. placeholder="Enter password"
  158. class="form-input"/>
  159. <small class="form-hint">Minimum 8 characters</small>
  160. </div>
  161. <!-- Password (Edit Mode - Optional) -->
  162. <div class="form-group" spry-if="!this.is_creating">
  163. <label for="new_password">New Password</label>
  164. <input type="password"
  165. name="new_password"
  166. sid="form-new-password"
  167. minlength="8"
  168. placeholder="Leave blank to keep current password"
  169. class="form-input"/>
  170. <small class="form-hint">Leave blank to keep current password. Minimum 8 characters if changing.</small>
  171. </div>
  172. <!-- Permission Editor -->
  173. <div class="form-group">
  174. <label>Permissions</label>
  175. <spry-component name="PermissionEditorComponent" sid="permission-editor"/>
  176. </div>
  177. </div>
  178. <!-- Form Actions -->
  179. <div class="form-actions">
  180. <button type="submit" sid="save-btn" class="btn btn-primary">
  181. <span spry-if="this.is_creating">Create User</span>
  182. <span spry-else>Save Changes</span>
  183. </button>
  184. <button type="button"
  185. sid="cancel-btn"
  186. spry-action=":Cancel"
  187. spry-target="form-container"
  188. class="btn btn-secondary">Cancel</button>
  189. </div>
  190. </form>
  191. </div>
  192. </div>
  193. </div>
  194. """;
  195. }}
  196. public override async void prepare() throws Error {
  197. if (!_is_visible) {
  198. return;
  199. }
  200. // Pre-populate form fields if editing
  201. if (_editing_user != null) {
  202. this["user-id"].set_attribute("value", _editing_user.id);
  203. this["form-username"].set_attribute("value", _editing_user.username);
  204. this["form-email"].set_attribute("value", _editing_user.email);
  205. // Set up permission editor with user's current permissions
  206. var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
  207. perm_editor.set_permissions(_editing_user.permissions);
  208. } else {
  209. // Clear permission editor for create mode
  210. var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
  211. perm_editor.clear_all();
  212. }
  213. }
  214. public async override void handle_action(string action) throws Error {
  215. switch (action) {
  216. case "Save":
  217. yield save_user();
  218. break;
  219. case "Cancel":
  220. hide();
  221. break;
  222. }
  223. }
  224. // =========================================================================
  225. // Private Helpers
  226. // =========================================================================
  227. private async void save_user() throws Error {
  228. var query = http_context.request.query_params;
  229. // Get form values
  230. var user_id = get_query_value(query, "user_id");
  231. var username = get_query_value(query, "username").strip();
  232. var email = get_query_value(query, "email").strip();
  233. // Validate username
  234. if (username.length < 3) {
  235. _error_message = "Username must be at least 3 characters";
  236. return;
  237. }
  238. // Validate username format (alphanumeric + underscore)
  239. if (!is_valid_username(username)) {
  240. _error_message = "Username can only contain letters, numbers, and underscores";
  241. return;
  242. }
  243. // Validate email
  244. if (email.length == 0) {
  245. _error_message = "Email is required";
  246. return;
  247. }
  248. if (!is_valid_email(email)) {
  249. _error_message = "Please enter a valid email address";
  250. return;
  251. }
  252. // Get permissions from editor
  253. var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
  254. var permissions = perm_editor.get_selected();
  255. try {
  256. if (user_id.length == 0) {
  257. // Create new user
  258. var password = get_query_value(query, "password");
  259. if (password.length < 8) {
  260. _error_message = "Password must be at least 8 characters";
  261. return;
  262. }
  263. string? create_error;
  264. var user = user_service.create_user(username, email, password, out create_error);
  265. if (user == null) {
  266. _error_message = create_error ?? "Failed to create user";
  267. return;
  268. }
  269. // Set permissions if any were selected
  270. if (permissions.length > 0) {
  271. user.permissions = permissions;
  272. string? update_error;
  273. if (!user_service.update_user(user, out update_error)) {
  274. _error_message = update_error ?? "Failed to set permissions";
  275. return;
  276. }
  277. }
  278. _success_message = "User created successfully";
  279. hide();
  280. } else {
  281. // Update existing user
  282. var user = user_service.get_user(user_id);
  283. if (user == null) {
  284. _error_message = "User not found";
  285. return;
  286. }
  287. // Update fields
  288. user.username = username;
  289. user.email = email;
  290. user.permissions = permissions;
  291. // Update password if provided
  292. var new_password = get_query_value(query, "new_password");
  293. if (new_password.length > 0) {
  294. if (new_password.length < 8) {
  295. _error_message = "New password must be at least 8 characters";
  296. return;
  297. }
  298. string? password_error;
  299. if (!user_service.set_password(user, new_password, out password_error)) {
  300. _error_message = password_error ?? "Failed to update password";
  301. return;
  302. }
  303. }
  304. // Save changes
  305. string? update_error;
  306. if (!user_service.update_user(user, out update_error)) {
  307. _error_message = update_error ?? "Failed to update user";
  308. return;
  309. }
  310. _success_message = "User updated successfully";
  311. hide();
  312. }
  313. } catch (Error e) {
  314. _error_message = "Error: %s".printf(e.message);
  315. }
  316. }
  317. private string get_query_value(Catalogue<string, string> query, string key) {
  318. var value = query.get_any_or_default(key);
  319. return value != null ? ((!)value).strip() : "";
  320. }
  321. private bool is_valid_username(string username) {
  322. if (username.length == 0) return false;
  323. foreach (var c in username.data) {
  324. if (!((c >= 'a' && c <= 'z') ||
  325. (c >= 'A' && c <= 'Z') ||
  326. (c >= '0' && c <= '9') ||
  327. c == '_')) {
  328. return false;
  329. }
  330. }
  331. return true;
  332. }
  333. private bool is_valid_email(string email) {
  334. // Basic email validation: contains @ and at least one . after @
  335. var at_index = email.index_of("@");
  336. if (at_index < 1) return false;
  337. var dot_index = email.index_of(".", at_index);
  338. return dot_index > at_index + 1 && dot_index < email.length - 1;
  339. }
  340. }
  341. }