Procházet zdrojové kódy

refactor(auth): restructure authentication components and enhance component framework

- Rename LoginFormComponent to LoginComponent with simplified declarative usage
- Split UserDetailsComponent into UserComponent (display) and UserEditComponent (editing)
- Consolidate NewUserComponent into UserEditComponent
- Simplify UserManagementComponent with built-in pagination and permission checks
- Add spry-method attribute for configuring HTTP methods on component actions
- Add skip_content() to ResponseState for returning empty responses
- Support boolean expressions in attribute-expr for conditional attributes
Billy Barrow před 1 měsícem
rodič
revize
0eeb8c150d

+ 6 - 18
examples/UsersExample.vala

@@ -7,7 +7,6 @@ using InvercargillSqlInversion;
 using Inversion;
 using Spry;
 using Spry.Authentication;
-using Spry.Authentication.Components;
 using Spry.Authorisation;
 
 /**
@@ -592,7 +591,6 @@ public class RegisterPage : PageComponent {
 public class LoginPage : PageComponent {
     
     private ComponentFactory _factory = inject<ComponentFactory>();
-    private LoginFormComponent _login_form;
     
     public const string ROUTE = "/login";
     
@@ -600,7 +598,7 @@ public class LoginPage : PageComponent {
         return """
         <div class="card">
             <h1>Login</h1>
-            <spry-outlet sid="login-form-outlet"/>
+            <spry-component name="SpryAuthenticationLoginComponent" />
             <p style="margin-top: 1.5rem;">
                 Don't have an account? <a href="/register">Register here</a>
             </p>
@@ -608,17 +606,6 @@ public class LoginPage : PageComponent {
         """;
     }}
     
-    public override async void prepare() throws Error {
-        // Create and configure the login form component
-        _login_form = _factory.create<LoginFormComponent>();
-        _login_form.redirect_url = "/dashboard";  // Redirect here after successful login
-        
-        // Add the form to our outlet
-        add_outlet_child("login-form-outlet", _login_form);
-        
-        // Share globals with the login form (required for action handling)
-        add_globals_from(_login_form);
-    }
 }
 
 /**
@@ -821,9 +808,10 @@ public class UserAdminPage : PageComponent {
         
         // Pass the application-defined permissions to the component
         // This allows the component to display appropriate checkboxes
-        _user_management.available_permissions = get_application_permissions();
+        //  _user_management.available_permissions = get_application_permissions();
         
         // Add it to our outlet
+        _user_management.authorisation_permission = "admin";
         add_outlet_child("user-management-outlet", _user_management);
         
         // Share globals with the component (required for action handling)
@@ -1029,12 +1017,12 @@ private async void start_application(int port) throws Error {
     application.add_endpoint<UserAdminPage>(new EndpointRoute(UserAdminPage.ROUTE));
     
     // Register LoginFormComponent (used by LoginPage)
-    application.add_transient<LoginFormComponent>();
+    application.add_transient<LoginComponent>();
     
     // Register new user management components
     application.add_transient<UserManagementComponent>();
-    application.add_transient<UserDetailsComponent>();
-    application.add_transient<NewUserComponent>();
+    application.add_transient<UserComponent>();
+    application.add_transient<UserEditComponent>();
     
     // Register CSS as FastResource
     application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/main.css"), () => {

+ 3 - 3
meson.build

@@ -26,6 +26,6 @@ add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 
 subdir('src')
 subdir('examples')
-subdir('tools')
-subdir('website')
-subdir('demo')
+# subdir('tools')
+# subdir('website')
+# subdir('demo')

+ 70 - 0
src/Authentication/Components/LoginComponent.vala

@@ -0,0 +1,70 @@
+using Astralis;
+using Inversion;
+
+namespace Spry.Authentication {
+
+    public class LoginComponent : Component {
+
+        public string redirect_url { get; set; default = "/"; }
+        public string error_message { get; set; }
+        public string username_prefill { get; set; }
+
+        public override string markup { get { return """
+        <spry-context property="redirect_url" />
+        <form
+          spry-action=":authenticate"
+          spry-method="post"
+          hx-disabled-elt="find input, find button"
+          hx-indicator="find button"
+          hx-swap="outerHTML"
+          style="display: grid; grid-template-columns: max-content auto; column-gap: 3em; row-gap: 1em;">
+            <label for="username">Username</label>
+            <input type="text" placeholder="Enter Username" name="username" value-expr="this.username_prefill" required>
+
+            <label for="password">Password</label>
+            <input type="password" placeholder="Enter Password" name="password" required>
+            
+            <label style="grid-column: 2;">
+            <input type="checkbox" checked="checked" name="remember"> Remember me
+            </label>
+
+            <span 
+              spry-if="this.error_message.length > 0"
+              style="grid-column: span 2;"
+              class="error-text"
+              content-expr="this.error_message">
+            </span>
+
+            <button style="grid-column: span 2;" type="submit">Authenticate</button>
+        </form>
+        """; }}
+
+        private HttpContext http_context = inject<HttpContext>();
+        private UserService user_service = inject<UserService>();
+
+        public async override void handle_action (string action) throws Error {
+            if(action == "authenticate") {
+                yield authenticate();
+            }
+        }
+
+        private async void authenticate() throws Error {
+            // Extract the form 
+            var form = yield Astralis.FormDataParser.parse (http_context.request.request_body, http_context.request.content_type);
+            var username = form.get_field("username");
+            var password = form.get_field("password");
+
+            var token = yield user_service.authenticate_user(username, password);
+            if(token == null) {
+                error_message = "Invalid username or password";
+                username_prefill = username;
+                return;
+            }
+
+            response.set_authorisation_token (token);
+            response.redirect(redirect_url, RedirectType.CLIENT_SIDE);
+        }
+        
+    }
+
+}

+ 0 - 230
src/Authentication/Components/LoginFormComponent.vala

@@ -1,230 +0,0 @@
-using Spry;
-using Spry.Authorisation;
-using Inversion;
-using Astralis;
-using Invercargill.DataStructures;
-
-namespace Spry.Authentication.Components {
-
-    /**
-     * LoginFormComponent - A reusable login form component with HTMX-based submission.
-     *
-     * This component provides a complete login flow:
-     * - Displays login form with username/email and password fields
-     * - Handles form submission via HTMX
-     * - Authenticates users via UserService
-     * - Sets authorisation cookies via AuthorisationService
-     * - Displays error messages on failed authentication
-     * - Redirects to configurable URL on success
-     *
-     * Usage:
-     *   // In a PageComponent or another component's markup:
-     *   <spry-component name="LoginFormComponent" sid="login-form"/>
-     *
-     *   // Or create via factory and configure:
-     *   var login_form = factory.create<LoginFormComponent>();
-     *   login_form.redirect_url = "/dashboard";
-     *
-     * Customization:
-     *   - redirect_url: URL to redirect after successful login (default: "/")
-     *   - login_action_name: Custom action name (default: "login")
-     *   - Override markup property for custom styling
-     *
-     * This component uses the inject<> pattern for dependency injection.
-     */
-    public class LoginFormComponent : Component {
-
-        private UserService _user_service = inject<UserService>();
-        private AuthorisationContext _auth_context = inject<AuthorisationContext>();
-        private AuthorisationService _auth_service = inject<AuthorisationService>();
-        private HttpContext _http_context = inject<HttpContext>();
-
-        // =========================================================================
-        // Configuration Properties
-        // =========================================================================
-
-        /**
-         * URL to redirect to after successful login.
-         * Default: "/"
-         */
-        public string redirect_url { get; set; default = "/"; }
-
-        /**
-         * Duration for "remember me" sessions in hours.
-         * When "remember_me" is checked, session duration is extended.
-         * Default: 168 (7 days)
-         */
-        public int remember_me_duration_hours { get; set; default = 168; }
-
-        // =========================================================================
-        // State Properties
-        // =========================================================================
-
-        /**
-         * Error message to display (set after failed authentication).
-         */
-        public string? error_message { get; private set; default = null; }
-
-        /**
-         * Username value to preserve after failed login.
-         */
-        public string preserved_username { get; private set; default = ""; }
-
-        /**
-         * Whether login was successful (triggers redirect).
-         */
-        public bool login_successful { get; private set; default = false; }
-
-        // =========================================================================
-        // Component Implementation
-        // =========================================================================
-
-        public override string markup { get {
-            return """
-            <spry-context property="redirect_url"/>
-            <script spry-res="htmx.js"></script>
-            <div class="spry-login-form" sid="login-form" hx-swap="outerHTML">
-                <form sid="form" spry-action=":login" spry-target="login-form" hx-disabled-elt="find button">
-                    <div class="form-group">
-                        <label for="username">Username or Email</label>
-                        <input type="text"
-                               name="username"
-                               sid="username-input"
-                               required
-                               autocomplete="username"
-                               autofocus
-                               placeholder="Enter your username or email"/>
-                    </div>
-
-                    <div class="form-group">
-                        <label for="password">Password</label>
-                        <input type="password"
-                               name="password"
-                               sid="password-input"
-                               required
-                               autocomplete="current-password"
-                               placeholder="Enter your password"/>
-                    </div>
-
-                    <div class="form-group form-group-checkbox">
-                        <label class="checkbox-label">
-                            <input type="checkbox" name="remember_me" sid="remember-me-input"/>
-                            <span>Remember me</span>
-                        </label>
-                    </div>
-
-                    <div spry-if="this.error_message != null" class="error-message" sid="error-container">
-                        <span content-expr="this.error_message"></span>
-                    </div>
-
-                    <button type="submit" sid="submit-btn" class="login-btn">Log In</button>
-                </form>
-            </div>
-            """;
-        }}
-
-        public override async void prepare() throws Error {
-            // Preserve username in the input field after failed login
-            if (preserved_username.length > 0) {
-                this["username-input"].set_attribute("value", preserved_username);
-            }
-        }
-
-        public async override void handle_action(string action) throws Error {
-            // Normalize action name comparison
-            if (action == "login") {
-                yield handle_login_async();
-            }
-        }
-
-        // =========================================================================
-        // Login Handler
-        // =========================================================================
-
-        private async void handle_login_async() throws Error {
-            stdout.printf("LOGIN DEBUG: handle_login_async() triggered\n");
-            
-            var query = _http_context.request.query_params;
-
-            // Get form values
-            var username_raw = query.get_any_or_default("username");
-            var password_raw = query.get_any_or_default("password");
-            var remember_me_raw = query.get_any_or_default("remember_me");
-
-            var username = username_raw != null ? ((!)username_raw).strip() : "";
-            var password = password_raw ?? "";
-            var remember_me = remember_me_raw == "on";
-
-            stdout.printf("LOGIN DEBUG: Username/email received: '%s'\n", username);
-            stdout.printf("LOGIN DEBUG: Password length: %d\n", password.length);
-            stdout.printf("LOGIN DEBUG: Remember me: %s\n", remember_me ? "true" : "false");
-
-            // Preserve username for re-display on error
-            preserved_username = username;
-
-            // Validate inputs
-            if (username.length == 0) {
-                stdout.printf("LOGIN DEBUG: Validation failed - empty username\n");
-                error_message = "Please enter your username or email";
-                return;
-            }
-
-            if (password.length == 0) {
-                stdout.printf("LOGIN DEBUG: Validation failed - empty password\n");
-                error_message = "Please enter your password";
-                return;
-            }
-
-            // Attempt authentication using the refined authentication system
-            stdout.printf("LOGIN DEBUG: Calling authenticate_user()...\n");
-            var token = yield _user_service.authenticate_user(username, password);
-            stdout.printf("LOGIN DEBUG: authenticate_user() returned token: %s\n", token != null ? "valid" : "null");
-
-            if (token == null) {
-                // Authentication failed - show generic error message
-                // (Don't reveal whether username or password was wrong for security)
-                stdout.printf("LOGIN DEBUG: Authentication failed - invalid credentials\n");
-                error_message = "Invalid username or password";
-                return;
-            }
-
-            stdout.printf("LOGIN DEBUG: Authentication successful for user: %s\n",
-                token.user_identifier != null ? token.user_identifier.stringify() : "unknown");
-
-            // Set authorisation cookie using the refined AuthorisationService
-            // Note: We use response.add_header to set the Set-Cookie header directly
-            stdout.printf("LOGIN DEBUG: Setting authorisation cookie...\n");
-            response.set_authorisation_token(token);
-            stdout.printf("LOGIN DEBUG: Authorisation cookie set\n");
-
-            // Set up HTMX redirect using ResponseState
-            // This sets the HX-Redirect header for client-side redirect
-            response.redirect(redirect_url);
-            
-            login_successful = true;
-            error_message = null;
-            stdout.printf("LOGIN DEBUG: Login successful! Redirect to: %s\n", redirect_url);
-        }
-
-        // =========================================================================
-        // Public API
-        // =========================================================================
-
-        /**
-         * Clears the error message and resets the form state.
-         */
-        public void clear_error() {
-            error_message = null;
-        }
-
-        /**
-         * Sets a custom error message.
-         * Useful for external validation or custom error handling.
-         *
-         * @param message The error message to display
-         */
-        public void set_error(string message) {
-            error_message = message;
-        }
-    }
-}

+ 0 - 359
src/Authentication/Components/NewUserComponent.vala

@@ -1,359 +0,0 @@
-using Spry;
-using Spry.Authorisation;
-using Inversion;
-using Astralis;
-using Invercargill;
-using Invercargill.DataStructures;
-
-namespace Spry.Authentication.Components {
-
-    /**
-     * NewUserComponent - Create form for new users.
-     *
-     * This component provides:
-     * - HTML5 `<details>` element with `<summary>` for new user creation
-     * - Form fields for username, email, password
-     * - Integrated permission editing with checkboxes
-     * - Create/Cancel action buttons
-     *
-     * HTMX Target: `new-user` (the details element itself)
-     * On successful creation, an hx-refresh header is sent to reload the page.
-     *
-     * Usage:
-     *   var component = factory.create<NewUserComponent>();
-     *   component.available_permissions = {"user-management", "user.create", ...};
-     *
-     * This component uses the inject<> pattern for dependency injection.
-     */
-    public class NewUserComponent : Component {
-
-        private Vector<string> _selected_permissions;
-        private UserService _user_service = inject<UserService>();
-        private AuthorisationContext _auth_context = inject<AuthorisationContext>();
-        private ComponentFactory _factory = inject<ComponentFactory>();
-        private HttpContext _http_context = inject<HttpContext>();
-
-        // =========================================================================
-        // Configuration Properties
-        // =========================================================================
-
-        /**
-         * List of permissions that the application supports.
-         * Must be set by the application before the component is rendered.
-         * The component does NOT hardcode any permissions.
-         */
-        public Vector<string> available_permissions { get; set; default = new Vector<string>(); }
-
-        // =========================================================================
-        // State Properties (must be public for template expression access)
-        // =========================================================================
-
-        public string? error_message { get; private set; default = null; }
-        public string preserved_username { get; private set; default = ""; }
-        public string preserved_email { get; private set; default = ""; }
-
-        // =========================================================================
-        // Construction
-        // =========================================================================
-
-        construct {
-            _selected_permissions = new Vector<string>();
-        }
-
-        // =========================================================================
-        // Component Implementation
-        // =========================================================================
-
-        public override string markup { get {
-            return """
-            <details sid="new-user"
-                     id="new-user"
-                     hx-swap="outerHTML"
-                     open="">
-                <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #e7f3ff; border-radius: 4px; cursor: pointer; border: 2px dashed #007bff; list-style: none;">
-                    <span style="font-weight: 500; color: #007bff;">+ New User</span>
-                </summary>
-
-                <div style="padding: 1rem; border: 2px dashed #007bff; border-top: none; border-radius: 0 0 4px 4px; background: #f8faff;">
-                    <form sid="create-form"
-                          spry-action=":CreateUser"
-                          spry-target="new-user">
-
-                        <table style="width: 100%; border-collapse: collapse;">
-                            <tbody>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">Username *</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <input type="text"
-                                               name="username"
-                                               sid="username-input"
-                                               required
-                                               minlength="3"
-                                               pattern="[a-zA-Z0-9_]+"
-                                               placeholder="Enter username"
-                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem; box-sizing: border-box;"/>
-                                        <small style="color: #6c757d; font-size: 0.75rem;">Alphanumeric and underscores only, min 3 chars</small>
-                                    </td>
-                                </tr>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <input type="email"
-                                               name="email"
-                                               sid="email-input"
-                                               required
-                                               placeholder="Enter email address"
-                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem; box-sizing: border-box;"/>
-                                    </td>
-                                </tr>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500;">Password *</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <input type="password"
-                                               name="password"
-                                               sid="password-input"
-                                               required
-                                               minlength="8"
-                                               placeholder="Enter password"
-                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem; box-sizing: border-box;"/>
-                                        <small style="color: #6c757d; font-size: 0.75rem;">Minimum 8 characters</small>
-                                    </td>
-                                </tr>
-
-                                <!-- Permission Checkboxes (dynamically generated from available_permissions) -->
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
-                                            <spry-outlet sid="permission-checkboxes"/>
-                                        </div>
-                                    </td>
-                                </tr>
-                            </tbody>
-                        </table>
-
-                        <!-- Error Message -->
-                        <div spry-if="this.error_message != null"
-                             style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
-                            <span content-expr="this.error_message"></span>
-                        </div>
-
-                        <!-- Actions -->
-                        <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
-                            <button type="submit" style="padding: 0.5rem 1rem; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 4px;">
-                                Create User
-                            </button>
-                            <button type="button"
-                                    sid="cancel-btn"
-                                    spry-action=":CancelCreate"
-                                    spry-target="new-user"
-                                    style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
-                                Cancel
-                            </button>
-                        </div>
-                    </form>
-                </div>
-            </details>
-            """;
-        }}
-
-        public async override void prepare() throws Error {
-            // Preserve form values after failed submission
-            if (preserved_username.length > 0) {
-                this["username-input"].set_attribute("value", preserved_username);
-            }
-            if (preserved_email.length > 0) {
-                this["email-input"].set_attribute("value", preserved_email);
-            }
-
-            // Generate permission checkboxes from available_permissions
-            var checkboxes = new Series<Renderable>();
-            foreach (var perm in available_permissions) {
-                bool is_checked = has_permission(perm);
-                var checkbox = create_permission_checkbox(perm, is_checked);
-                checkboxes.add(checkbox);
-            }
-
-            if (available_permissions.length == 0) {
-                var no_perms = create_text_renderable("No permissions available");
-                checkboxes.add(no_perms);
-            }
-
-            set_outlet_children("permission-checkboxes", checkboxes);
-        }
-
-        public async override void handle_action(string action) throws Error {
-            switch (action) {
-                case "CreateUser":
-                    yield handle_create_user_async();
-                    break;
-
-                case "CancelCreate":
-                    yield handle_cancel_create_async();
-                    break;
-            }
-        }
-
-        // =========================================================================
-        // Action Handlers
-        // =========================================================================
-
-        private async void handle_create_user_async() throws Error {
-            var request = _http_context.request;
-
-            // Get form values
-            var username = get_query_value(request, "username").strip();
-            var email = get_query_value(request, "email").strip();
-            var password = get_query_value(request, "password");
-
-            // Preserve values for re-display on error
-            preserved_username = username;
-            preserved_email = email;
-
-            // Validate username
-            if (username.length < 3) {
-                error_message = "Username must be at least 3 characters";
-                return;
-            }
-
-            if (!is_valid_username(username)) {
-                error_message = "Username can only contain letters, numbers, and underscores";
-                return;
-            }
-
-            // Validate email
-            if (email.length == 0) {
-                error_message = "Email is required";
-                return;
-            }
-
-            if (!is_valid_email(email)) {
-                error_message = "Please enter a valid email address";
-                return;
-            }
-
-            // Validate password
-            if (password.length < 8) {
-                error_message = "Password must be at least 8 characters";
-                return;
-            }
-
-            // Build permissions from checkboxes
-            _selected_permissions.clear();
-            foreach (var perm in available_permissions) {
-                string field_name = get_permission_field_name(perm);
-                if (request.query_params.get_any_or_default(field_name) != null) {
-                    _selected_permissions.add(perm);
-                }
-            }
-
-            try {
-                // Create the user using the new register_user method
-                // Using empty strings for forename/surname and current date for date_of_birth
-                // as these fields are not collected in this form
-                var user = yield _user_service.register_user(
-                    username,
-                    email,
-                    "",  // forename - not collected in this form
-                    "",  // surname - not collected in this form
-                    new DateTime.now_utc(),  // date_of_birth - using current date as placeholder
-                    password,
-                    true  // enabled - new users are enabled by default
-                );
-
-                // Set permissions if any were selected
-                if (_selected_permissions.length > 0) {
-                    foreach (var perm in _selected_permissions) {
-                        yield _user_service.set_user_permission(user.id, perm);
-                    }
-                }
-
-                // Set hx-refresh header to cause page reload
-                // This ensures the user list is refreshed without needing parent references
-                set_refresh_response();
-
-            } catch (Error e) {
-                // Handle database constraint violations for duplicate username/email
-                if (e.message.contains("username") && e.message.down().contains("unique")) {
-                    error_message = "Username already exists";
-                } else if (e.message.contains("email") && e.message.down().contains("unique")) {
-                    error_message = "Email already registered";
-                } else {
-                    error_message = "Error: %s".printf(e.message);
-                }
-            }
-        }
-
-        private async void handle_cancel_create_async() throws Error {
-            // Set hx-refresh header to cause page reload
-            // This closes the form and refreshes the user list
-            set_refresh_response();
-        }
-
-        // =========================================================================
-        // Private Helpers
-        // =========================================================================
-
-        private bool has_permission(string permission) {
-            foreach (var perm in _selected_permissions) {
-                if (perm == permission) {
-                    return true;
-                }
-            }
-            return false;
-        }
-
-        private string get_permission_field_name(string permission) {
-            return @"perm_$(permission.replace(".", "-"))";
-        }
-
-        private string get_query_value(Astralis.HttpRequest request, string key) {
-            string? value = request.query_params.get_any_or_default(key);
-            return value != null ? ((!)value).strip() : "";
-        }
-
-        private bool is_valid_username(string username) {
-            if (username.length == 0) return false;
-            foreach (var c in username.data) {
-                if (!((c >= 'a' && c <= 'z') ||
-                      (c >= 'A' && c <= 'Z') ||
-                      (c >= '0' && c <= '9') ||
-                      c == '_')) {
-                    return false;
-                }
-            }
-            return true;
-        }
-
-        private bool is_valid_email(string email) {
-            var at_index = email.index_of("@");
-            if (at_index < 1) return false;
-            var dot_index = email.index_of(".", at_index);
-            return dot_index > at_index + 1 && dot_index < email.length - 1;
-        }
-
-        private Renderable create_permission_checkbox(string permission, bool is_checked) {
-            var doc = new MarkupDocument();
-            var escaped = GLib.Markup.escape_text(permission);
-            var field_name = get_permission_field_name(permission);
-            string checked_attr = is_checked ? "checked" : "";
-            doc.body.inner_html = @"<label style=\"display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem; cursor: pointer;\"><input type=\"checkbox\" name=\"$field_name\" value=\"$permission\" $checked_attr/>$escaped</label>";
-            return new InlineRenderable(doc);
-        }
-
-        private Renderable create_text_renderable(string text) {
-            var doc = new MarkupDocument();
-            var escaped = GLib.Markup.escape_text(text);
-            doc.body.inner_html = @"<span style=\"color: #999; font-style: italic; font-size: 0.85rem;\">$escaped</span>";
-            return new InlineRenderable(doc);
-        }
-
-        /**
-         * Sets the HX-Refresh header on the response to trigger a full page refresh.
-         * This is used instead of parent component references to update the user list.
-         */
-        private void set_refresh_response() {
-            response.set_header("HX-Refresh", "true");
-        }
-    }
-}

+ 105 - 0
src/Authentication/Components/UserComponent.vala

@@ -0,0 +1,105 @@
+using Astralis;
+using Inversion;
+using InvercargillSql.Orm;
+using Invercargill.Expressions;
+
+namespace Spry.Authentication {
+
+    public class UserComponent : Component {
+
+        public int user_id { get; set; }
+        public bool edit { get; set; }
+        public UserProjection user { get; set; }
+        public bool expanded { get; set; }
+
+        public override string markup { get { return """
+        <spry-context property="user_id" />
+        <details sid="details" open-expr="this.expanded">
+            <summary>
+                <span content-expr="this.user.forename"></span>
+                <span content-expr="this.user.surname"></span>
+                <em content-expr='format("({{this.user.username}})")'></span>
+            </summary>
+            <div spry-if="!this.edit" style="display: grid; grid-template-columns: max-content auto; column-gap: 3em; row-gap: 0.25em;">
+                <strong>Username:</strong>
+                <span content-expr="this.user.username"></span>
+
+                <strong>Forename:</strong>
+                <span content-expr="this.user.forename"></span>
+
+                <strong>Surname:</strong>
+                <span content-expr="this.user.surname"></span>
+
+                <strong>Email Address:</strong>
+                <span content-expr="this.user.email"></span>
+
+                <strong>Date of Birth:</strong>
+                <span content-expr='this.user.date_of_birth.format("%d/%m/%Y")'></span>
+
+                <strong>Permissions:</strong>
+                <div style="display: flex; gap: 1em;">
+                    <code spry-per-permission="this.user.permissions" content-expr="permission"></code>
+                </div>
+
+                <div style="grid-column: 2; display: flex; gap: 1em;">
+                    <button 
+                      style="flex: 1"
+                      spry-target="details"
+                      hx-swap="outerHTML"
+                      spry-action=":edit">
+                        Edit User
+                    </button>
+                    <button 
+                      style="flex: 1"
+                      spry-if="this.user.enabled"
+                      hx-swap="outerHTML"
+                      spry-target="details"
+                      spry-action=":disable">
+                        Disable User
+                    </button>
+
+                    <button
+                      style="flex: 1"
+                      spry-else
+                      hx-swap="outerHTML"
+                      spry-target="details"
+                      spry-action=":enable">
+                        Enable User
+                    </button>
+                </div>
+            </div>
+            <spry-outlet sid="editor">
+        </details>
+        """; }}
+
+        private UserService user_service = inject<UserService>();
+        private ComponentFactory component_factory = inject<ComponentFactory>();
+        private OrmSession orm_session = inject<OrmSession>();
+
+        public async override void handle_action (string action) throws Error {
+            expanded = true;
+
+            if(action == "disable") {
+                yield user_service.set_user_enabled (user_id, false);
+            }
+            if(action == "enable") {
+                yield user_service.set_user_enabled (user_id, true);
+            }
+
+            user = yield orm_session.query<UserProjection>()
+                .where(expr("user_id == $0", elem<int64?>(user_id)))
+                .first_async();
+
+            if(action == "edit") {
+                edit = true;
+                var edit_component = component_factory.create<UserEditComponent>();
+                edit_component.user_id = user_id;
+                edit_component.user = user;
+                edit_component.username = user.username;
+
+                set_outlet_child ("editor", edit_component);
+            }
+        }
+    }
+
+}

+ 0 - 546
src/Authentication/Components/UserDetailsComponent.vala

@@ -1,546 +0,0 @@
-using Spry;
-using Inversion;
-using Astralis;
-using Invercargill;
-using Invercargill.DataStructures;
-using Spry.Authorisation;
-
-namespace Spry.Authentication.Components {
-
-    /**
-     * UserDetailsComponent - Display and edit component for a single user.
-     *
-     * This component provides:
-     * - HTML5 `<details>` element with `<summary>` showing key user info
-     * - Two-column table displaying all user fields when expanded
-     * - View mode with Edit/Delete buttons
-     * - Edit mode with form fields and integrated permission editing
-     * - Permission badges display
-     *
-     * The component uses an `is_editing` state to toggle between view and edit modes.
-     *
-     * HTMX Target: `user-{user_id}` (the details element itself)
-     *
-     * Usage:
-     *   var component = factory.create<UserDetailsComponent>();
-     *   component.set_user(user);
-     *   component.available_permissions = {"user-management", "user.create", ...};
-     *
-     * This component uses the inject<> pattern for dependency injection.
-     */
-    public class UserDetailsComponent : Component {
-
-        private UserProjection _user;
-        private AuthorisationContext _auth_context = inject<AuthorisationContext>();
-        private UserService _user_service = inject<UserService>();
-        private ComponentFactory _factory = inject<ComponentFactory>();
-        private HttpContext _http_context = inject<HttpContext>();
-
-        // Editing state
-        private Vector<string> _editing_permissions;
-        private string _editing_username;
-        private string _editing_email;
-        private string _editing_password;
-
-        // =========================================================================
-        // Configuration Properties
-        // =========================================================================
-
-        /**
-         * List of permissions that the application supports.
-         * Must be set by the application before the component is rendered.
-         * The component does NOT hardcode any permissions.
-         */
-        public Vector<string> available_permissions { get; set; default = new Vector<string>(); }
-
-        // =========================================================================
-        // State Properties (must be public for template expression access)
-        // =========================================================================
-
-        public int64 user_id { get; private set; default = 0; }
-        public string username { get; private set; default = ""; }
-        public string email { get; private set; default = ""; }
-        public string created_at { get; private set; default = ""; }
-        public string[] permissions { get; private set; default = {}; }
-        public int permission_count { get; private set; default = 0; }
-        public bool is_editing { get; private set; default = false; }
-        public string? error_message { get; private set; default = null; }
-
-        // =========================================================================
-        // Public API
-        // =========================================================================
-
-        /**
-         * Sets the user to display.
-         *
-         * @param user The user to display
-         */
-        public void set_user(UserProjection user) {
-            _user = user;
-            user_id = user.id;
-            username = user.username;
-            email = user.email;
-            created_at = user.created.format("%Y-%m-%d %H:%M:%S UTC");
-            // Convert ImmutableLot<string> to string[]
-            var perm_list = new List<string>();
-            foreach (var perm in user.permissions) {
-                perm_list.append(perm);
-            }
-            var perm_array = new string[perm_list.length()];
-            int i = 0;
-            foreach (var perm in perm_list) {
-                perm_array[i++] = perm;
-            }
-            permissions = perm_array;
-            permission_count = permissions.length;
-        }
-
-        /**
-         * Gets the user being displayed.
-         *
-         * @return The user being displayed
-         */
-        public UserProjection get_user() {
-            return _user;
-        }
-
-        // =========================================================================
-        // Component Implementation
-        // =========================================================================
-
-        public override string markup { get {
-            return """
-            <details sid="user-details"
-                     id-expr='"user-" + this.user_id'
-                     hx-swap="outerHTML">
-                <!-- Summary: Always visible, shows key info -->
-                <summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; cursor: pointer; list-style: none;">
-                    <span content-expr="this.username" style="font-weight: 500; min-width: 120px;"></span>
-                    <span spry-if="this.is_editing" style="color: #856404; font-style: italic;">Editing...</span>
-                    <span spry-if="!this.is_editing" content-expr="this.email" style="color: #6c757d;"></span>
-                    <span spry-if="!this.is_editing && this.permission_count > 0" style="margin-left: auto; font-size: 0.85rem; color: #495057;">
-                        <span content-expr="this.permission_count"></span> permission(s)
-                    </span>
-                    <span spry-if="!this.is_editing && this.permission_count == 0" style="margin-left: auto; font-size: 0.85rem; color: #999;">
-                        No permissions
-                    </span>
-                </summary>
-
-                <!-- VIEW MODE -->
-                <div spry-if="!this.is_editing" style="padding: 1rem; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 4px 4px;">
-                    <table style="width: 100%; border-collapse: collapse;">
-                        <tbody>
-                            <tr>
-                                <td style="padding: 0.5rem 0; font-weight: 500; width: 150px; vertical-align: top;">User ID</td>
-                                <td style="padding: 0.5rem 0;"><code content-expr="stringify(this.user_id)" style="font-size: 0.85rem; background: #f5f5f5; padding: 0.125rem 0.25rem; border-radius: 3px;"></code></td>
-                            </tr>
-                            <tr>
-                                <td style="padding: 0.5rem 0; font-weight: 500;">Username</td>
-                                <td style="padding: 0.5rem 0;"><span content-expr="this.username"></span></td>
-                            </tr>
-                            <tr>
-                                <td style="padding: 0.5rem 0; font-weight: 500;">Email</td>
-                                <td style="padding: 0.5rem 0;"><span content-expr="this.email"></span></td>
-                            </tr>
-                            <tr>
-                                <td style="padding: 0.5rem 0; font-weight: 500;">Created</td>
-                                <td style="padding: 0.5rem 0;"><span content-expr="this.created_at"></span></td>
-                            </tr>
-                            <tr>
-                                <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
-                                <td style="padding: 0.5rem 0;">
-                                    <div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
-                                        <spry-outlet sid="permission-badges"/>
-                                    </div>
-                                </td>
-                            </tr>
-                        </tbody>
-                    </table>
-
-                    <!-- View Mode Actions -->
-                    <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
-                        <button sid="edit-btn"
-                                spry-action=":StartEdit"
-                                spry-target="user-details"
-                                style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem;">
-                            Edit
-                        </button>
-                        <button sid="delete-btn"
-                                spry-action=":DeleteUser"
-                                hx-confirm="Are you sure you want to delete this user?"
-                                style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem; background: #dc3545; color: white; border: none; border-radius: 4px;">
-                            Delete
-                        </button>
-                    </div>
-                </div>
-
-                <!-- EDIT MODE -->
-                <div spry-if="this.is_editing" style="padding: 1rem; border: 2px solid #ffc107; border-top: none; border-radius: 0 0 4px 4px; background: #fffdf5;">
-                    <form sid="edit-form"
-                          spry-action=":SaveEdit"
-                          spry-target="user-details">
-                        
-                        <table style="width: 100%; border-collapse: collapse;">
-                            <tbody>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">User ID</td>
-                                    <td style="padding: 0.5rem 0;"><code content-expr="this.user_id.to_string()" style="font-size: 0.85rem; background: #f5f5f5; padding: 0.125rem 0.25rem; border-radius: 3px;"></code></td>
-                                </tr>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500;">Username *</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <input type="text"
-                                               name="username"
-                                               sid="username-input"
-                                               required
-                                               minlength="3"
-                                               pattern="[a-zA-Z0-9_]+"
-                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
-                                               value-expr="this.username"/>
-                                    </td>
-                                </tr>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <input type="email"
-                                               name="email"
-                                               sid="email-input"
-                                               required
-                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
-                                               value-expr="this.email"/>
-                                    </td>
-                                </tr>
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500;">New Password</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <input type="password"
-                                               name="new_password"
-                                               sid="password-input"
-                                               minlength="8"
-                                               placeholder="Leave blank to keep current"
-                                               style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"/>
-                                        <small style="color: #6c757d;">Minimum 8 characters if changing</small>
-                                    </td>
-                                </tr>
-                                
-                                <!-- Permission Checkboxes (dynamically generated from available_permissions) -->
-                                <tr>
-                                    <td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
-                                    <td style="padding: 0.5rem 0;">
-                                        <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
-                                            <spry-outlet sid="permission-checkboxes"/>
-                                        </div>
-                                    </td>
-                                </tr>
-                            </tbody>
-                        </table>
-                        
-                        <!-- Error Message -->
-                        <div spry-if="this.error_message != null"
-                             style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
-                            <span content-expr="this.error_message"></span>
-                        </div>
-                        
-                        <!-- Edit Actions -->
-                        <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
-                            <button type="submit" style="padding: 0.5rem 1rem; cursor: pointer;">Save Changes</button>
-                            <button type="button"
-                                    sid="cancel-btn"
-                                    spry-action=":CancelEdit"
-                                    spry-target="user-details"
-                                    style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
-                                Cancel
-                            </button>
-                        </div>
-                    </form>
-                </div>
-            </details>
-            """;
-        }}
-
-        public async override void prepare() throws Error {
-            if (_user == null) {
-                return;
-            }
-
-            if (is_editing) {
-                yield prepare_edit_mode();
-            } else {
-                yield prepare_view_mode();
-            }
-        }
-
-        private async void prepare_view_mode() throws Error {
-            // Create permission badges
-            var badges = new Series<Renderable>();
-            foreach (var permission in permissions) {
-                var badge = create_permission_badge(permission);
-                badges.add(badge);
-            }
-
-            // Show "No permissions" message if none
-            if (permissions.length == 0) {
-                var no_perms = create_text_renderable("No permissions assigned");
-                badges.add(no_perms);
-            }
-
-            set_outlet_children("permission-badges", badges);
-        }
-
-        private async void prepare_edit_mode() throws Error {
-            // Initialize editing state if not already done
-            if (_editing_permissions == null) {
-                _editing_permissions = new Vector<string>();
-                foreach (var perm in permissions) {
-                    _editing_permissions.add(perm);
-                }
-                _editing_username = username;
-                _editing_email = email;
-                _editing_password = "";
-            }
-
-            // Generate permission checkboxes from available_permissions
-            var checkboxes = new Series<Renderable>();
-            foreach (var perm in available_permissions) {
-                bool is_checked = has_editing_permission(perm);
-                var checkbox = create_permission_checkbox(perm, is_checked);
-                checkboxes.add(checkbox);
-            }
-
-            if (available_permissions.length == 0) {
-                var no_perms = create_text_renderable("No permissions available");
-                checkboxes.add(no_perms);
-            }
-
-            set_outlet_children("permission-checkboxes", checkboxes);
-        }
-
-        public async override void handle_action(string action) throws Error {
-            switch (action) {
-                case "StartEdit":
-                    yield handle_start_edit_async();
-                    break;
-
-                case "SaveEdit":
-                    yield handle_save_edit_async();
-                    break;
-
-                case "CancelEdit":
-                    handle_cancel_edit();
-                    break;
-
-                case "DeleteUser":
-                    yield handle_delete_user_async();
-                    break;
-            }
-        }
-
-        // =========================================================================
-        // Action Handlers
-        // =========================================================================
-
-        private async void handle_start_edit_async() throws Error {
-            is_editing = true;
-            error_message = null;
-            _editing_permissions = null; // Reset to trigger re-initialization
-            yield prepare();
-        }
-
-        private async void handle_save_edit_async() throws Error {
-            var query = _http_context.request.query_params;
-
-            string new_username = query.get_any_or_default("username") ?? _editing_username;
-            string new_email = query.get_any_or_default("email") ?? _editing_email;
-            string new_password = query.get_any_or_default("new_password") ?? "";
-
-            // Validate
-            if (new_username.length < 3) {
-                error_message = "Username must be at least 3 characters";
-                yield prepare();
-                return;
-            }
-
-            if (!new_email.contains("@")) {
-                error_message = "Invalid email address";
-                yield prepare();
-                return;
-            }
-
-            if (new_password.length > 0 && new_password.length < 8) {
-                error_message = "Password must be at least 8 characters";
-                yield prepare();
-                return;
-            }
-
-            // Collect permissions from checkboxes
-            _editing_permissions = new Vector<string>();
-            foreach (var perm in available_permissions) {
-                string field_name = get_permission_field_name(perm);
-                var field_value = query.get_any_or_default(field_name);
-                if (field_value != null) {
-                    _editing_permissions.add(perm);
-                }
-            }
-
-            try {
-                // Update user using alter_user
-                yield _user_service.alter_user(
-                    _user.id,
-                    new_username,
-                    new_email,
-                    _user.forename ?? "",
-                    _user.surname ?? "",
-                    _user.date_of_birth,
-                    _user.enabled
-                );
-
-                // Update password if provided
-                if (new_password.length > 0) {
-                    yield _user_service.set_password(_user.id, new_password);
-                }
-
-                // Update permissions - clear all and re-add
-                yield _user_service.clear_user_permissions(_user.id);
-                foreach (var perm in _editing_permissions) {
-                    yield _user_service.set_user_permission(_user.id, perm);
-                }
-
-                // Fetch updated user projection to refresh local state
-                var updated_permissions = yield _user_service.get_user_permissions(_user.id);
-                // Update local state with new values
-                username = new_username;
-                email = new_email;
-                var perm_list = new List<string>();
-                foreach (var perm in updated_permissions) {
-                    perm_list.append(perm);
-                }
-                var perm_array = new string[perm_list.length()];
-                int i = 0;
-                foreach (var perm in perm_list) {
-                    perm_array[i++] = perm;
-                }
-                permissions = perm_array;
-                permission_count = permissions.length;
-
-                is_editing = false;
-                error_message = null;
-                _editing_permissions = null;
-
-                yield prepare();
-            } catch (Error e) {
-                error_message = @"Failed to save: $(e.message)";
-                yield prepare();
-            }
-        }
-
-        private void handle_cancel_edit() {
-            is_editing = false;
-            error_message = null;
-            _editing_permissions = null;
-            _editing_password = "";
-        }
-
-        private async void handle_delete_user_async() throws Error {
-            // Check authorization using AuthorisationContext
-            if (_auth_context.is_anonymous()) {
-                error_message = "You must be logged in to delete users";
-                yield prepare();
-                return;
-            }
-
-            if (!_auth_context.has_permission("user-management")) {
-                error_message = "You do not have permission to delete users";
-                yield prepare();
-                return;
-            }
-
-            // Prevent self-deletion - compare int64 user ids
-            int64? current_user_id = _auth_context.token.user_identifier.as_int64_or_null();
-            if (current_user_id != null && current_user_id == user_id) {
-                error_message = "Cannot delete your own account";
-                yield prepare();
-                return;
-            }
-
-            // Delete the user
-            try {
-                yield _user_service.delete_user(user_id);
-
-                // Set hx-refresh header to cause page reload
-                // This ensures the user list is refreshed without needing parent references
-                set_refresh_response();
-
-            } catch (Error e) {
-                error_message = e.message;
-                yield prepare();
-            }
-        }
-
-        // =========================================================================
-        // Private Helpers
-        // =========================================================================
-
-        private bool has_editing_permission(string permission) {
-            if (_editing_permissions == null) {
-                return false;
-            }
-            foreach (var perm in _editing_permissions) {
-                if (perm == permission) {
-                    return true;
-                }
-            }
-            return false;
-        }
-
-        private string get_permission_field_name(string permission) {
-            return @"perm_$(permission.replace(".", "-"))";
-        }
-
-        private Renderable create_permission_badge(string permission) {
-            var doc = new MarkupDocument();
-            var escaped = GLib.Markup.escape_text(permission);
-            doc.body.inner_html = @"<span style=\"background: #e9ecef; color: #495057; padding: 0.125rem 0.5rem; border-radius: 12px; font-size: 0.8rem; white-space: nowrap;\">$escaped</span>";
-            return new InlineRenderable(doc);
-        }
-
-        private Renderable create_text_renderable(string text) {
-            var doc = new MarkupDocument();
-            var escaped = GLib.Markup.escape_text(text);
-            doc.body.inner_html = @"<span style=\"color: #999; font-style: italic; font-size: 0.85rem;\">$escaped</span>";
-            return new InlineRenderable(doc);
-        }
-
-        private Renderable create_permission_checkbox(string permission, bool is_checked) {
-            var doc = new MarkupDocument();
-            var escaped = GLib.Markup.escape_text(permission);
-            var field_name = get_permission_field_name(permission);
-            string checked_attr = is_checked ? "checked" : "";
-            doc.body.inner_html = @"<label style=\"display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem; cursor: pointer;\"><input type=\"checkbox\" name=\"$field_name\" value=\"$permission\" $checked_attr/>$escaped</label>";
-            return new InlineRenderable(doc);
-        }
-
-        /**
-         * Sets the HX-Refresh header on the response to trigger a full page refresh.
-         * This is used instead of parent component references to update the user list.
-         */
-        private void set_refresh_response() {
-            response.set_header("HX-Refresh", "true");
-        }
-    }
-
-    /**
-     * Helper class for inline HTML rendering.
-     * Used for dynamically generated permission badges and checkboxes.
-     */
-    internal class InlineRenderable : GLib.Object, Renderable {
-        private MarkupDocument _doc;
-
-        public InlineRenderable(MarkupDocument doc) {
-            _doc = doc;
-        }
-
-        public async MarkupDocument to_document() throws Error {
-            return _doc;
-        }
-    }
-}

+ 63 - 0
src/Authentication/Components/UserEditComponent.vala

@@ -0,0 +1,63 @@
+using Astralis;
+using Inversion;
+
+namespace Spry.Authentication {
+
+    public class UserEditComponent : Component {
+
+        public int user_id { get; set; }
+        public string username { get; set; }
+        public UserProjection user { get; set; }
+
+        public override string markup { get { return """
+        <spry-context property="user_id" />
+        <spry-context property="username" />
+        <form
+          spry-action=":save"
+          spry-method="post"
+          hx-disabled-elt="find input, find button"
+          hx-indicator="find button"
+          hx-swap="outerHTML"
+          style="display: grid; grid-template-columns: max-content auto; column-gap: 3em; row-gap: 1em;">
+            <label for="username">Username</label>
+            <input type="text" name="username" value-expr="this.user.username" disabled>
+
+            <label for="forename">Forename</label>
+            <input type="text" name="forename" value-expr="this.user.forename" required>
+
+            <label for="surname">Surname</label>
+            <input type="text" name="surname" value-expr="this.user.surname" required>
+
+            <label for="email">Email Address</label>
+            <input type="text" name="email" value-expr="this.user.email" required>
+
+            <label for="date_of_birth">Date of Birth</label>
+            <input type="date" name="date_of_birth" value-expr='this.user.date_of_birth.format("%Y-%m-%d")' required>
+
+            <label for="new_password">Password</label>
+            <input type="password" name="new_password" placeholder="Leave blank to keep unchanged">
+
+            <button style="grid-column: span 2;" type="submit">Save</button>
+        </form>
+        """; }}        
+
+        private UserService user_service = inject<UserService>();
+        private HttpContext http_context = inject<HttpContext>();
+
+        public async override void handle_action (string action) throws Error {
+            var form = yield Astralis.FormDataParser.parse (http_context.request.request_body, http_context.request.content_type);
+            var dob_strs = form.get_field ("date_of_birth").split("-");
+            var dob = new DateTime(new TimeZone.utc (), int.parse(dob_strs[0]), int.parse(dob_strs[1]), int.parse(dob_strs[2]), 0, 0, 0);
+            yield user_service.alter_user (user_id, username, form.get_field ("email"), form.get_field ("forename"), form.get_field ("surname"), dob, true);
+            
+            var new_password = form.get_field ("new_password");
+            if(new_password != null && new_password != "") {
+                yield user_service.set_password (user_id, new_password);
+            }
+
+            response.set_header ("HX-Refresh", "true");
+            response.skip_content ();
+        }
+    }
+
+}

+ 43 - 194
src/Authentication/Components/UserManagementComponent.vala

@@ -1,213 +1,62 @@
-using Spry;
-using Inversion;
 using Astralis;
-using Invercargill;
-using Invercargill.DataStructures;
+using Inversion;
 using Spry.Authorisation;
 
-namespace Spry.Authentication.Components {
+namespace Spry.Authentication {
 
-    /**
-     * UserManagementComponent - Container component for user management.
-     *
-     * This component provides:
-     * - Header with "Create User" button
-     * - User list with expandable details for each user
-     * - Success/error message display
-     * - New user form (shown via HTMX)
-     *
-     * Unlike UserManagementPage (which was a PageComponent), this is a regular
-     * Component that can be placed anywhere in an application's layout.
-     *
-     * HTMX Target: `user-management`
-     *
-     * Usage:
-     *   // In your PageComponent:
-     *   var component = factory.create<UserManagementComponent>();
-     *   component.available_permissions = {"user-management", "user.create", ...};
-     *   add_outlet_child("user-management-outlet", component);
-     *
-     * This component uses the inject<> pattern for dependency injection.
-     */
     public class UserManagementComponent : Component {
-
-        private AuthorisationContext _auth_context = inject<AuthorisationContext>();
-        private UserService _user_service = inject<UserService>();
-        private ComponentFactory _factory = inject<ComponentFactory>();
-        private HttpContext _http_context = inject<HttpContext>();
-
-        // =========================================================================
-        // Configuration Properties
-        // =========================================================================
-
-        /**
-         * List of permissions that the application supports.
-         * Must be set by the application before the component is rendered.
-         * This is passed to child components (NewUserComponent, UserDetailsComponent).
-         */
-        public Vector<string> available_permissions { get; set; default = new Vector<string>(); }
-
-        // =========================================================================
-        // State Properties (must be public for template expression access)
-        // =========================================================================
-
-        public string? success_message { get; private set; default = null; }
-        public string? error_message { get; private set; default = null; }
-        public bool access_denied { get; private set; default = false; }
-        public bool show_create_form { get; private set; default = false; }
-        public ImmutableLot<UserProjection> users { get; private set; }
-
-        // =========================================================================
-        // Component Implementation
-        // =========================================================================
-
-        public override string markup { get {
-            return """
-            <div sid="user-management" id="user-management" hx-swap="outerHTML">
-                <script spry-res="htmx.js"></script>
-
-                <!-- Header with Create Button -->
-                <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
-                    <h3 style="margin: 0;">Users</h3>
-                    <button sid="create-btn"
-                            spry-if="!this.access_denied"
-                            spry-action=":ShowCreateUser"
-                            spry-target="user-management"
-                            style="padding: 0.5rem 1rem; cursor: pointer;">
-                        + Create User
-                    </button>
-                </div>
-
-                <!-- Access Denied Message -->
-                <div spry-if="this.access_denied"
-                     style="padding: 1rem; background: #f8d7da; color: #721c24; border-radius: 4px; text-align: center;">
-                    <h4 style="margin: 0 0 0.5rem 0; color: #dc3545;">Access Denied</h4>
-                    <p style="margin: 0;">You do not have permission to access this page.</p>
-                </div>
-
-                <!-- Success Message -->
-                <div spry-if="this.success_message != null && this.success_message.length > 0"
-                     style="padding: 0.75rem; margin-bottom: 1rem; background: #d4edda; color: #155724; border-radius: 4px;">
-                    <span content-expr="this.success_message"></span>
-                </div>
-
-                <!-- Error Message -->
-                <div spry-if="this.error_message != null && this.error_message.length > 0"
-                     style="padding: 0.75rem; margin-bottom: 1rem; background: #f8d7da; color: #721c24; border-radius: 4px;">
-                    <span content-expr="this.error_message"></span>
-                </div>
-
-                <!-- Main Content (hidden if access denied) -->
-                <div spry-if="!this.access_denied" sid="main-content">
-                    <!-- New User Form (conditionally visible) -->
-                    <div spry-if="this.show_create_form" sid="new-user-container" style="margin-bottom: 1rem;">
-                        <spry-outlet sid="new-user-outlet"/>
-                    </div>
-
-                    <!-- User List -->
-                    <div sid="user-list" style="display: flex; flex-direction: column; gap: 0.5rem;">
-                        <spry-outlet sid="users"/>
-                    </div>
-                </div>
+        public int page_number { get; set; default = 0; }
+        public string authorisation_permission { get; set; default = "spry.admin"; }
+        public bool authorised { get; set; }
+        public const int64 users_per_page = 20;
+
+        public override string markup { get { return """
+        <spry-context property="page_number" />
+        <div spry-if="!this.authorised">
+            <strong>You must have the permission <code content-expr="this.authorisation_permission"></code> to access this content.</strong>
+        </div>
+        <div spry-else sid="container" style="gap: 1em;">
+            <spry-outlet sid="outlet" />
+            <div>
+                <button spry-action=":previous" spry-target="container">Previous</button>
+                <span>Page <strong content-expr="stringify(this.page_number + 1)"></strong></span>
+                <button spry-action=":next" spry-target="container">Next</button>
             </div>
-            """;
-        }}
+        </div>
+        """; }}
 
-        public async override void prepare() throws Error {
-            // Check authorization using AuthorisationContext
-            if (_auth_context.is_anonymous()) {
-                access_denied = true;
-                return;
-            }
+        private UserService user_service = inject<UserService>();
+        private AuthorisationContext authorisation_context = inject<AuthorisationContext>();
+        private ComponentFactory component_factory = inject<ComponentFactory>();
 
-            if (!_auth_context.has_permission("user-management")) {
-                access_denied = true;
+        public async override void prepare () throws Error {
+            if(authorisation_context.is_anonymous() || !authorisation_context.has_permission (authorisation_permission)) {
+                authorised = false;
                 return;
             }
 
-            // Load users
-            yield load_users_async();
-
-            // Create user detail components
-            var items = new Series<Renderable>();
+            authorised = true;
+            var users = yield user_service.list_users (page_number * users_per_page, page_number * users_per_page + users_per_page);
             foreach (var user in users) {
-                var item = _factory.create<UserDetailsComponent>();
-                item.set_user(user);
-                item.available_permissions = available_permissions;
-                items.add(item);
-                add_globals_from(item);
-            }
-            set_outlet_children("users", items);
-
-            // Create new user component if form should be shown
-            if (show_create_form) {
-                var new_user_comp = _factory.create<NewUserComponent>();
-                new_user_comp.available_permissions = available_permissions;
-                set_outlet_child("new-user-outlet", new_user_comp);
-                add_globals_from(new_user_comp);
+                var component = component_factory.create<UserComponent>();
+                component.user_id = (int)user.id;
+                component.user = user;
+                add_outlet_child ("outlet", component);
             }
         }
 
-        public async override void handle_action(string action) throws Error {
-            // Check permission for all actions
-            if (access_denied) {
-                return;
+        public async override void handle_action(string action) {
+            if(action == "previous") {
+                page_number --;
+                if(page_number < 0) {
+                    page_number = 0;
+                }
             }
-
-            switch (action) {
-                case "ShowCreateUser":
-                    show_create_form = true;
-                    success_message = null;
-                    error_message = null;
-                    break;
-
-                case "CancelCreate":
-                    show_create_form = false;
-                    success_message = null;
-                    error_message = null;
-                    break;
-
-                case "ClearMessages":
-                    success_message = null;
-                    error_message = null;
-                    break;
+            if(action == "next") {
+                page_number ++;
             }
         }
-
-        // =========================================================================
-        // Public API
-        // =========================================================================
-
-        /**
-         * Sets a success message to display.
-         */
-        public void set_success(string message) {
-            success_message = message;
-            error_message = null;
-        }
-
-        /**
-         * Sets an error message to display.
-         */
-        public void set_error(string message) {
-            error_message = message;
-            success_message = null;
-        }
-
-        /**
-         * Clears all messages.
-         */
-        public void clear_messages() {
-            success_message = null;
-            error_message = null;
-        }
-
-        // =========================================================================
-        // Private Helpers
-        // =========================================================================
-
-        private async void load_users_async() throws Error {
-            users = yield _user_service.list_users(0, 100);
-        }
+        
     }
-}
+
+}

+ 28 - 2
src/Component.vala

@@ -166,6 +166,9 @@ namespace Spry {
             }
             yield prepare();
 
+            if(response._skip_content) {
+                return new MarkupDocument();
+            }
             var final_instance = instance.copy();
             yield transform_document(final_instance);
             return final_instance;
@@ -178,6 +181,10 @@ namespace Spry {
             }
             yield prepare();
 
+            if(response._skip_content) {
+                return new MarkupDocument();
+            }
+
             // Extract the dynamic fragment
             var final_instance = instance.copy();
             var template_fragment = final_instance.select_one(@"//*[@spry-dynamic='$name']")?.outer_html;
@@ -198,6 +205,10 @@ namespace Spry {
             }
             yield prepare();
 
+            if(response._skip_content) {
+                return new MarkupDocument();
+            }
+
             var final_instance = instance.copy();
             yield transform_document(final_instance);
 
@@ -297,6 +308,10 @@ namespace Spry {
                 var component_action = action[1];
 
                 node.remove_attribute("spry-action");
+                var attribute = "hx-get";
+                if(node.has_attribute("spry-method")) {
+                    attribute = @"hx-$(node.get_attribute("spry-method"))";
+                }
 
                 if(component_name == this.get_type().name() && _context_properties.length > 0) {
                     var data = new PropertyDictionary();
@@ -313,10 +328,10 @@ namespace Spry {
                         data = data
                     };
                     var context_blob = _context_service.author_context_blob(context);
-                    node.set_attribute("hx-get", _path_provider.get_action_path_with_context(component_name, component_action, context_blob));
+                    node.set_attribute(attribute, _path_provider.get_action_path_with_context(component_name, component_action, context_blob));
                 }
                 else {
-                    node.set_attribute("hx-get", _path_provider.get_action_path(component_name, component_action));
+                    node.set_attribute(attribute, _path_provider.get_action_path(component_name, component_action));
                 }
             }
         }
@@ -588,6 +603,17 @@ namespace Spry {
                         continue;
                     }
 
+                    // if the expression is a boolean, toggle the attribute on or off
+                    if(result.type().is_a(typeof(bool))) {
+                        if(result.as<bool>()) {
+                            node.set_attribute(real_attribute, "");
+                        }
+                        else {
+                            node.remove_attribute(real_attribute);
+                        }
+                        continue;
+                    }
+
                     // everything else read as string
                     var str_value = result.as<string>();
 

+ 6 - 7
src/ResponseState.vala

@@ -52,6 +52,12 @@ namespace Spry {
         // Redirect configuration
         private string? _redirect_url = null;
         private RedirectType _redirect_type = RedirectType.CLIENT_SIDE;
+
+        internal bool _skip_content = false;
+
+        public void skip_content() {
+            _skip_content = true;
+        }
         
         public ResponseState() {
             _headers = new HashTable<string, string>(str_hash, str_equal);
@@ -170,13 +176,6 @@ namespace Spry {
             return _status_code;
         }
         
-        /**
-         * Checks if a redirect has been configured.
-         */
-        public bool has_redirect() {
-            return _redirect_url != null;
-        }
-        
         /**
          * Applies all accumulated headers to an HttpResult.
          * Note: This only applies headers. Status code and redirect handling

+ 3 - 3
src/meson.build

@@ -35,10 +35,10 @@ sources = files(
     'Authentication/UserIdentityProvider.vala',
     'Authentication/AuthenticationModule.vala',
     'Authentication/Migrations/M0001_Initial.vala',
-    'Authentication/Components/LoginFormComponent.vala',
-    'Authentication/Components/NewUserComponent.vala',
-    'Authentication/Components/UserDetailsComponent.vala',
+    'Authentication/Components/LoginComponent.vala',
     'Authentication/Components/UserManagementComponent.vala',
+    'Authentication/Components/UserComponent.vala',
+    'Authentication/Components/UserEditComponent.vala',
 )