Sfoglia il codice sorgente

feat(auth): add session token creation and password hashing

Add token management functionality to CryptographyProvider with
sign-then-seal encryption pattern for secure session tokens. Includes
optional expiry support and validation result object.

- TokenValidationResult class for validation outcomes
- sign_then_seal_token for creating encrypted signed tokens
- unseal_then_verify_token for decrypting and verifying tokens
- PasswordHashing bindings in libsodium vapi for Argon2id hashing

Also adds implexus dependency and Users subdirectory to build.
Billy Barrow 1 mese fa
parent
commit
c608f8a279

+ 2 - 0
meson.build

@@ -14,6 +14,7 @@ astralis_dep = dependency('astralis-0.1')
 json_glib_dep = dependency('json-glib-1.0')
 invercargill_json_dep = dependency('invercargill-json')
 libxml_dep = dependency('libxml-2.0')
+implexus_dep = dependency('implexus-0.1')
 sodium_vapi = files('vapi/libsodium.vapi')
 sodium_c_lib = meson.get_compiler('c').find_library('sodium', required: true)
 sodium_deps = declare_dependency(sources: sodium_vapi, dependencies: sodium_c_lib)
@@ -22,6 +23,7 @@ sodium_deps = declare_dependency(sources: sodium_vapi, dependencies: sodium_c_li
 add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
 
 subdir('src')
+subdir('src/Users')
 subdir('examples')
 subdir('tools')
 subdir('website')

+ 132 - 0
src/CryptographyProvider.vala

@@ -4,6 +4,55 @@ using Invercargill.DataStructures;
 using InvercargillJson;
 namespace Spry {
 
+    /**
+     * Result of token validation containing the payload and status information.
+     */
+    public class TokenValidationResult : Object {
+        /**
+         * Whether the token was successfully decrypted and verified.
+         */
+        public bool is_valid { get; construct set; }
+
+        /**
+         * The decrypted payload string, or null if validation failed.
+         */
+        public string? payload { get; construct set; }
+
+        /**
+         * Error message describing why validation failed, or null if valid.
+         */
+        public string? error_message { get; construct set; }
+
+        /**
+         * Whether the token has expired (only meaningful when is_valid is true).
+         */
+        public bool is_expired { get; construct set; }
+
+        /**
+         * Creates a successful validation result.
+         */
+        public TokenValidationResult.success(string payload, bool expired = false) {
+            Object(
+                is_valid: true,
+                payload: payload,
+                error_message: null,
+                is_expired: expired
+            );
+        }
+
+        /**
+         * Creates a failed validation result.
+         */
+        public TokenValidationResult.failure(string error_message, bool expired = false) {
+            Object(
+                is_valid: false,
+                payload: null,
+                error_message: error_message,
+                is_expired: expired
+            );
+        }
+    }
+
     public class CryptographyProvider : Object {
 
         private uint8[] signing_secret_key;
@@ -49,6 +98,89 @@ namespace Spry {
             var context = mapper.materialise(json.as<JsonObject>());
             return context;
         }
+
+        /**
+         * Creates a signed and encrypted session token.
+         *
+         * The token is created by first signing the payload with Ed25519,
+         * then encrypting the signed data with X25519-Seal. The result is
+         * URL-safe Base64 encoded.
+         *
+         * @param payload The JSON payload string containing session data
+         * @param expires_at Optional expiry timestamp to include in the token
+         * @return A URL-safe Base64 encoded token string
+         */
+        public string sign_then_seal_token(string payload, DateTime? expires_at = null) {
+            // Build the token JSON with optional expiry
+            string token_json;
+            if (expires_at != null) {
+                var exp_str = ((!)expires_at).format_iso8601();
+                // Escape the payload JSON for embedding in a JSON string
+                var escaped_payload = payload.replace("\\", "\\\\").replace("\"", "\\\"");
+                token_json = @"{\"payload\":\"$escaped_payload\",\"exp\":\"$exp_str\"}";
+            } else {
+                var escaped_payload = payload.replace("\\", "\\\\").replace("\"", "\\\"");
+                token_json = @"{\"payload\":\"$escaped_payload\"}";
+            }
+
+            // Sign then seal (same pattern as component context blobs)
+            var signed = Sodium.Asymmetric.Signing.sign(token_json.data, signing_secret_key);
+            var @sealed = Sodium.Asymmetric.Sealing.seal(signed, sealing_public_key);
+
+            // URL-safe Base64 encoding
+            return Base64.encode(@sealed).replace("+", "-").replace("/", "_");
+        }
+
+        /**
+         * Decrypts and verifies a session token.
+         *
+         * The token is decrypted by first unsealing with X25519-Seal,
+         * then verifying the signature with Ed25519. If an expiry is
+         * present in the token, it is checked against the current time.
+         *
+         * @param token The URL-safe Base64 encoded token string
+         * @return A TokenValidationResult containing the validation outcome
+         */
+        public TokenValidationResult unseal_then_verify_token(string token) {
+            // URL-safe Base64 decode
+            var decoded = Base64.decode(token.replace("-", "+").replace("_", "/"));
+
+            // Unseal the token
+            var signed = Sodium.Asymmetric.Sealing.unseal(decoded, sealing_public_key, sealing_secret_key);
+            if (signed == null) {
+                return new TokenValidationResult.failure("Could not unseal token");
+            }
+
+            // Verify the signature
+            var cleartext = Sodium.Asymmetric.Signing.verify(signed, signing_public_key);
+            if (cleartext == null) {
+                return new TokenValidationResult.failure("Invalid token signature");
+            }
+
+            // Parse the token payload
+            try {
+                var json_string = Wrap.byte_array(cleartext).to_raw_string();
+                var json = new JsonElement.from_string(json_string);
+                var obj = json.as<JsonObject>();
+
+                // Check expiry if present
+                if (obj.has("exp")) {
+                    var exp_str = obj.get("exp").as<string>();
+                    var expires_at = new DateTime.from_iso8601(exp_str, new TimeZone.utc());
+                    if (expires_at.compare(new DateTime.now_utc()) <= 0) {
+                        // Token has expired - we still return the payload but mark as expired
+                        var payload_str = obj.get("payload").as<string>();
+                        return new TokenValidationResult.success(payload_str, true);
+                    }
+                }
+
+                // Extract and return the payload
+                var payload_str = obj.get("payload").as<string>();
+                return new TokenValidationResult.success(payload_str);
+            } catch (Error e) {
+                return new TokenValidationResult.failure("Invalid token format: %s".printf(e.message));
+            }
+        }
     }
 
     public class ComponentContext {

+ 1878 - 0
src/Users/ARCHITECTURE.md

@@ -0,0 +1,1878 @@
+# Spry Users System - Architecture Document
+
+## 1. Overview
+
+The Spry Users System provides a comprehensive user management and authentication solution for Spry web applications. It offers cookie-based authentication with signed and encrypted session tokens, granular string-keyed permissions, and optional UI components for login and user management.
+
+### Key Features
+
+- **Cookie-based Authentication**: Secure session tokens using signed+encrypted cookies
+- **User Management**: CRUD operations for users with configurable password hashing
+- **Granular Permissions**: String-keyed permissions system (e.g., "admin", "user-management", "content.edit")
+- **Application Data**: Custom JSON-serializable data field per user
+- **Optional UI Components**: Pre-built LoginFormComponent and UserManagementInterface
+- **Implexus Storage**: All data stored in nested container at `/spry/users`
+
+### Design Principles
+
+1. **Minimal Dependencies**: Uses Invercargill.DataStructures instead of Libgee
+2. **Security First**: Leverages existing CryptographyProvider for token security
+3. **Optional UI**: Applications can use custom UI or provided components
+4. **Configurable**: Session expiry, permission defaults, and storage paths are configurable
+
+---
+
+## 2. File Structure
+
+```
+src/Users/
+├── ARCHITECTURE.md           # This document
+├── UsersModule.vala          # Module registration and configuration
+├── UserService.vala          # Core user management service
+├── SessionService.vala       # Session management and cookie handling
+├── PermissionService.vala    # Permission checking and management
+├── Models/
+│   ├── User.vala             # User data model
+│   ├── Session.vala          # Session data model
+│   └── Permission.vala       # Permission constants and helpers
+├── Components/
+│   ├── LoginFormComponent.vala       # Optional login form
+│   ├── UserListComponent.vala        # User list with search/filter
+│   ├── UserListItemComponent.vala    # Individual user row with actions
+│   ├── UserFormComponent.vala        # Create/edit user form
+│   └── PermissionEditorComponent.vala # Permission management widget
+├── Pages/
+│   └── UserManagementPage.vala       # PageComponent orchestrating user management
+└── meson.build               # Build configuration
+```
+
+### File Descriptions
+
+| File | Purpose |
+|------|---------|
+| `UsersModule.vala` | IoC registration, configuration, and module setup |
+| `UserService.vala` | User CRUD, password hashing, user queries |
+| `SessionService.vala` | Session creation, validation, cookie management, expiry |
+| `PermissionService.vala` | has_permission, set_permission, clear_permission operations |
+| `Models/User.vala` | User data structure with PropertyMapper for serialization |
+| `Models/Session.vala` | Session data structure with expiry tracking |
+| `Models/Permission.vala` | Permission constants and helper methods |
+| `Components/LoginFormComponent.vala` | HTMX-based login form with error handling |
+| `Components/UserListComponent.vala` | Displays user list with search/filter capabilities |
+| `Components/UserListItemComponent.vala` | Individual user row with inline action buttons |
+| `Components/UserFormComponent.vala` | Modal or inline form for create/edit user |
+| `Components/PermissionEditorComponent.vala` | Permission management widget with checkboxes |
+| `Pages/UserManagementPage.vala` | PageComponent orchestrating all user management components |
+
+---
+
+## 3. Data Models
+
+### 3.1 User Model
+
+```vala
+namespace Spry.Users.Models {
+
+    public class User : Object {
+        // Identity
+        public string id { get; set; }              // UUID, also used as document name
+        public string username { get; set; }        // Unique username
+        public string email { get; set; }           // Unique email address
+        public string password_hash { get; set; }   // Argon2id hashed password
+        
+        // Metadata
+        public DateTime created_at { get; set; }
+        public DateTime? last_login_at { get; set; }
+        public DateTime? updated_at { get; set; }
+        public bool is_active { get; set; default = true; }
+        public bool is_verified { get; set; default = false; }
+        
+        // Permissions - stored as JSON array of strings
+        public Series<string> permissions { get; set; default = new Series<string>(); }
+        
+        // Application-specific data - stored as JSON object
+        public Properties? application_data { get; set; }
+        
+        // PropertyMapper for Implexus serialization
+        public static PropertyMapper<User> get_mapper() {
+            return PropertyMapper.build_for<User>(cfg => {
+                cfg.map<string>("id", u => u.id, (u, v) => u.id = v);
+                cfg.map<string>("username", u => u.username, (u, v) => u.username = v);
+                cfg.map<string>("email", u => u.email, (u, v) => u.email = v);
+                cfg.map<string>("password_hash", u => u.password_hash, (u, v) => u.password_hash = v);
+                cfg.map<string>("created", u => u.created_at.format_iso8601(), 
+                    (u, v) => u.created_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
+                cfg.map<string?>("last_login", 
+                    u => u.last_login_at != null ? ((!)u.last_login_at).format_iso8601() : null,
+                    (u, v) => u.last_login_at = v != null ? new DateTime.from_iso8601((!)v, new TimeZone.utc()) : null);
+                cfg.map<string?>("updated",
+                    u => u.updated_at != null ? ((!)u.updated_at).format_iso8601() : null,
+                    (u, v) => u.updated_at = v != null ? new DateTime.from_iso8601((!)v, new TimeZone.utc()) : null);
+                cfg.map<bool>("active", u => u.is_active, (u, v) => u.is_active = v);
+                cfg.map<bool>("verified", u => u.is_verified, (u, v) => u.is_verified = v);
+                cfg.map<Series<string>>("permissions", u => u.permissions, (u, v) => u.permissions = v);
+                cfg.map<Properties?>("app_data", u => u.application_data, (u, v) => u.application_data = v);
+                cfg.set_constructor(() => new User());
+            });
+        }
+    }
+}
+```
+
+### 3.2 Session Model
+
+```vala
+namespace Spry.Users.Models {
+
+    public class Session : Object {
+        public string id { get; set; }              // UUID for session
+        public string user_id { get; set; }         // Reference to User.id
+        public DateTime created_at { get; set; }
+        public DateTime expires_at { get; set; }
+        public string? ip_address { get; set; }     // Optional IP binding
+        public string? user_agent { get; set; }     // Optional UA tracking
+        
+        public bool is_expired {
+            get { return expires_at.compare(new DateTime.now_utc()) <= 0; }
+        }
+        
+        public static PropertyMapper<Session> get_mapper() {
+            return PropertyMapper.build_for<Session>(cfg => {
+                cfg.map<string>("id", s => s.id, (s, v) => s.id = v);
+                cfg.map<string>("user", s => s.user_id, (s, v) => s.user_id = v);
+                cfg.map<string>("created", s => s.created_at.format_iso8601(),
+                    (s, v) => s.created_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
+                cfg.map<string>("expires", s => s.expires_at.format_iso8601(),
+                    (s, v) => s.expires_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
+                cfg.map<string?>("ip", s => s.ip_address, (s, v) => s.ip_address = v);
+                cfg.map<string?>("ua", s => s.user_agent, (s, v) => s.user_agent = v);
+                cfg.set_constructor(() => new Session());
+            });
+        }
+    }
+}
+```
+
+### 3.3 Permission Constants
+
+```vala
+namespace Spry.Users.Models {
+
+    public class Permission : Object {
+        // Built-in permissions
+        public const string USER_MANAGEMENT = "user-management";
+        public const string ADMIN = "admin";
+        
+        // Helper to check if a permission implies another
+        public static bool implies(string permission, string required) {
+            // "admin" implies all permissions
+            if (permission == ADMIN) return true;
+            
+            // Exact match
+            if (permission == required) return true;
+            
+            // Prefix match: "content.*" implies "content.edit", "content.delete"
+            if (permission.has_suffix(".*")) {
+                string prefix = permission.substring(0, permission.length - 1);
+                return required.has_prefix(prefix);
+            }
+            
+            return false;
+        }
+        
+        // Validate permission string format
+        public static bool is_valid(string permission) {
+            // Must be non-empty, alphanumeric with dots, dashes, underscores
+            return /^[a-zA-Z0-9._-]+$/.match(permission);
+        }
+    }
+}
+```
+
+---
+
+## 4. Class Diagrams
+
+### 4.1 Core Services
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        UsersModule                               │
+│  - Configures UserService, SessionService, PermissionService    │
+│  - Registers components and pages                               │
+│  - Provides UsersConfiguration for customization                │
+└─────────────────────────────────────────────────────────────────┘
+                                │
+                                │ registers
+                                ▼
+┌──────────────────────┐  ┌──────────────────────┐  ┌──────────────────────┐
+│     UserService      │  │    SessionService    │  │  PermissionService   │
+├──────────────────────┤  ├──────────────────────┤  ├──────────────────────┤
+│ + create_user        │  │ + create_session     │  │ + has_permission     │
+│ + get_user_by_id     │  │ + validate_session   │  │ + set_permission     │
+│ + get_user_by_name   │  │ + destroy_session    │  │ + clear_permission   │
+│ + get_user_by_email  │  │ + get_current_user   │  │ + get_permissions    │
+│ + update_user        │  │ + set_session_cookie │  │ + check_any          │
+│ + delete_user        │  │ + clear_session_cookie│ │ + check_all          │
+│ + authenticate       │  │ + get_session_from   │  │                      │
+│ + list_users         │  │   cookie             │  │                      │
+│ + hash_password      │  │ + cleanup_expired    │  │                      │
+│ + verify_password    │  │                      │  │                      │
+└──────────────────────┘  └──────────────────────┘  └──────────────────────┘
+         │                          │                         │
+         │ uses                     │ uses                    │ uses
+         ▼                          ▼                         ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                    Implexus.Core.Engine                          │
+│  Storage path: /spry/users                                       │
+│  - /spry/users/users/{user_id}     - User documents              │
+│  - /spry/users/sessions/{session_id} - Session documents         │
+│  - /spry/users/by_username         - Category for username lookup│
+│  - /spry/users/by_email            - Category for email lookup   │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### 4.2 Component Hierarchy
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                       Component                                  │
+│  (from Spry namespace)                                           │
+└─────────────────────────────────────────────────────────────────┘
+                                ▲
+                                │ extends
+        ┌───────────────────────┼───────────────────────┐
+        │                       │                       │
+┌───────┴───────┐     ┌────────┴────────┐     ┌───────┴───────┐
+│ LoginFormComp │     │ UserListComp    │     │ PageComponent │
+├───────────────┤     ├─────────────────┤     └───────┬───────┘
+│ - username    │     │ - users: Series │             ▲
+│ - error_msg   │     │ - search_query  │             │ extends
+│ - redirect_url│     │ - current_user  │     ┌───────┴───────┐
+├───────────────┤     ├─────────────────┤     │UserMgmtPage   │
+│ + prepare()   │     │ + prepare()     │     ├───────────────┤
+│ + handle_     │     │ + handle_action │     │ - list: inject│
+│   action()    │     │   - Search      │     │ - form: inject│
+│   - Login     │     │   - Refresh     │     │ + prepare()   │
+│   - Logout    │     └────────┬────────┘     │ + handle_     │
+└───────────────┘              │ creates      │   action()    │
+                               ▼              │   - CreateUser│
+                    ┌──────────────────┐      │   - EditUser  │
+                    │UserListItemComp  │      │   - DeleteUser│
+                    ├──────────────────┤      └───────────────┘
+                    │ - user: User     │              │
+                    │ - parent_list    │              │ contains
+                    ├──────────────────┤              ▼
+                    │ + prepare()      │      ┌──────────────────┐
+                    │ + handle_action  │      │UserFormComponent │
+                    │   - Edit         │      ├──────────────────┤
+                    │   - Delete       │      │ - editing_user   │
+                    │   - ToggleActive │      │ - is_modal       │
+                    └──────────────────┘      │ - visible        │
+                                              ├──────────────────┤
+                                              │ + prepare()      │
+                                              │ + handle_action  │
+                                              │   - Save         │
+                                              │   - Cancel       │
+                                              │ + show()         │
+                                              │ + hide()         │
+                                              └────────┬─────────┘
+                                                       │ contains
+                                                       ▼
+                                       ┌───────────────────────────┐
+                                       │PermissionEditorComponent  │
+                                       ├───────────────────────────┤
+                                       │ - user: User              │
+                                       │ - available_perms: Series │
+                                       │ - selected_perms: Series  │
+                                       ├───────────────────────────┤
+                                       │ + prepare()               │
+                                       │ + get_selected()          │
+                                       │ + set_permissions()       │
+                                       └───────────────────────────┘
+```
+
+### 4.3 Component Communication Flow
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                    UserManagementPage                            │
+│  - Orchestrates all user management components                   │
+│  - Handles cross-component actions                               │
+│  - Manages modal state for UserFormComponent                     │
+└─────────────────────────────────────────────────────────────────┘
+         │                           │                      │
+         │ contains                  │ contains             │ contains
+         ▼                           ▼                      ▼
+┌─────────────────┐     ┌──────────────────────┐  ┌─────────────────┐
+│ UserListComponent│     │ UserFormComponent    │  │ Status messages │
+│                 │     │ - modal overlay      │  │ - success/error │
+│ - Search input  │     │ - create/edit form   │  │   alerts        │
+│ - User table    │     │ - permission editor  │  │                 │
+│ - Pagination    │     │ - password fields    │  │                 │
+└────────┬────────┘     └──────────┬───────────┘  └─────────────────┘
+         │                         │
+         │ creates per user        │ contains
+         ▼                         ▼
+┌──────────────────────┐  ┌───────────────────────────┐
+│ UserListItemComponent│  │ PermissionEditorComponent │
+│ - Username/email     │  │ - Checkbox list           │
+│ - Status badges      │  │ - Common permissions      │
+│ - Action buttons     │  │ - Custom permission input │
+│   - Edit → triggers  │  └───────────────────────────┘
+│     form modal       │
+│   - Delete           │
+│   - Toggle active    │
+└──────────────────────┘
+
+Communication Pattern:
+1. UserListItemComponent.Edit → UserManagementPage.handle_action("EditUser")
+2. UserManagementPage shows UserFormComponent with user data
+3. UserFormComponent.Save → UserService.update_user()
+4. UserManagementPage refreshes UserListComponent via add_globals_from()
+```
+
+### 4.3 Data Relationships
+
+```
+┌───────────────────┐       ┌───────────────────┐
+│       User        │       │      Session      │
+├───────────────────┤       ├───────────────────┤
+│ id: string        │◄──────│ user_id: string   │
+│ username: string  │  1:N  │ id: string        │
+│ email: string     │       │ expires_at: Date  │
+│ password_hash     │       │ created_at: Date  │
+│ permissions[]     │       │ ip_address: ?     │
+│ application_data  │       │ user_agent: ?     │
+│ created_at        │       └───────────────────┘
+│ last_login_at     │
+│ is_active         │
+│ is_verified       │
+└───────────────────┘
+         │
+         │ stored in
+         ▼
+┌───────────────────────────────────────────────────┐
+│              Implexus Storage                     │
+├───────────────────────────────────────────────────┤
+│ /spry/users/                                       │
+│   ├── users/                    [Container]       │
+│   │   ├── {user_id_1}           [Document:User]   │
+│   │   ├── {user_id_2}           [Document:User]   │
+│   │   └── ...                                      │
+│   ├── sessions/                 [Container]       │
+│   │   ├── {session_id_1}        [Document:Session]│
+│   │   └── ...                                      │
+│   ├── by_username               [Category]        │
+│   │   └── expression: "type=='User'"              │
+│   │   └── indexed on: username                    │
+│   └── by_email                  [Category]        │
+│       └── expression: "type=='User'"              │
+│       └── indexed on: email                       │
+└───────────────────────────────────────────────────┘
+```
+
+---
+
+## 5. API Design
+
+### 5.1 UserService API
+
+```vala
+namespace Spry.Users {
+
+    public errordomain UserError {
+        USER_NOT_FOUND,
+        DUPLICATE_USERNAME,
+        DUPLICATE_EMAIL,
+        INVALID_PASSWORD,
+        INVALID_CREDENTIALS,
+        USER_INACTIVE,
+        PERMISSION_DENIED
+    }
+
+    public class UserService : Object {
+        private Implexus.Core.Engine engine = inject<Implexus.Core.Engine>();
+        private UsersConfiguration config = inject<UsersConfiguration>();
+        
+        // User CRUD
+        public User create_user(string username, string email, string password, 
+            Series<string>? permissions = null, Properties? app_data = null) throws Error;
+        
+        public User? get_user_by_id(string id);
+        public User? get_user_by_username(string username);
+        public User? get_user_by_email(string email);
+        
+        public void update_user(User user) throws Error;
+        public void delete_user(string user_id) throws Error;
+        
+        public Series<User> list_users(int offset = 0, int limit = 100);
+        public Series<User> search_users(string query, int limit = 50);
+        
+        // Authentication
+        public User authenticate(string username_or_email, string password) throws Error;
+        public void update_password(string user_id, string new_password) throws Error;
+        public void record_login(string user_id);
+        
+        // Password hashing (using Argon2id via libsodium)
+        public string hash_password(string password);
+        public bool verify_password(string password, string hash);
+        
+        // Utility
+        public bool username_exists(string username);
+        public bool email_exists(string email);
+        public int user_count();
+    }
+}
+```
+
+### 5.2 SessionService API
+
+```vala
+namespace Spry.Users {
+
+    public errordomain SessionError {
+        SESSION_NOT_FOUND,
+        SESSION_EXPIRED,
+        INVALID_SESSION_TOKEN,
+        COOKIE_NOT_FOUND
+    }
+
+    public class SessionService : Object {
+        private Implexus.Core.Engine engine = inject<Implexus.Core.Engine>();
+        private CryptographyProvider crypto = inject<CryptographyProvider>();
+        private UsersConfiguration config = inject<UsersConfiguration>();
+        private HttpContext http_context = inject<HttpContext>();
+        
+        // Session Management
+        public Session create_session(string user_id, 
+            string? ip_address = null, string? user_agent = null) throws Error;
+        
+        public Session? validate_session(string session_id) throws Error;
+        public void destroy_session(string session_id) throws Error;
+        public void destroy_all_user_sessions(string user_id) throws Error;
+        
+        // Cookie Operations
+        public void set_session_cookie(Session session);
+        public void clear_session_cookie();
+        public Session? get_session_from_cookie() throws Error;
+        
+        // Current User Context
+        public User? get_current_user() throws Error;
+        public string? get_current_user_id() throws Error;
+        public bool is_authenticated() throws Error;
+        
+        // Maintenance
+        public void cleanup_expired_sessions();
+        public int active_session_count(string? user_id = null);
+        
+        // Session Token (signed + encrypted)
+        public string create_session_token(Session session) throws Error;
+        public Session? validate_session_token(string token) throws Error;
+    }
+}
+```
+
+### 5.3 PermissionService API
+
+```vala
+namespace Spry.Users {
+
+    public class PermissionService : Object {
+        private UserService user_service = inject<UserService>();
+        private SessionService session_service = inject<SessionService>();
+        
+        // Permission Checking
+        public bool has_permission(string user_id, string permission) throws Error;
+        public bool has_any_permission(string user_id, Series<string> permissions) throws Error;
+        public bool has_all_permissions(string user_id, Series<string> permissions) throws Error;
+        
+        // Current User Shortcuts
+        public bool current_user_has(string permission) throws Error;
+        public bool current_user_has_any(Series<string> permissions) throws Error;
+        public bool current_user_has_all(Series<string> permissions) throws Error;
+        
+        // Permission Management
+        public void set_permission(string user_id, string permission) throws Error;
+        public void clear_permission(string user_id, string permission) throws Error;
+        public void set_permissions(string user_id, Series<string> permissions) throws Error;
+        public void clear_all_permissions(string user_id) throws Error;
+        
+        // Querying
+        public Series<string> get_permissions(string user_id) throws Error;
+        public Series<User> get_users_with_permission(string permission) throws Error;
+        
+        // Validation
+        public void require_permission(string permission) throws Error;
+        public void require_any(Series<string> permissions) throws Error;
+        public void require_all(Series<string> permissions) throws Error;
+    }
+}
+```
+
+### 5.4 UsersConfiguration API
+
+```vala
+namespace Spry.Users {
+
+    public class UsersConfiguration : Object {
+        // Session Settings
+        public TimeSpan session_duration { get; set; default = TimeSpan.HOUR * 24 * 7; } // 7 days
+        public bool bind_to_ip { get; set; default = false; }
+        public bool track_user_agent { get; set; default = true; }
+        public string cookie_name { get; set; default = "spry_session"; }
+        public bool cookie_secure { get; set; default = true; }
+        public bool cookie_http_only { get; set; default = true; }
+        public string? cookie_domain { get; set; default = null; }
+        public string? cookie_path { get; set; default = "/"; }
+        
+        // Password Settings
+        public int min_password_length { get; set; default = 8; }
+        public bool require_uppercase { get; set; default = true; }
+        public bool require_lowercase { get; set; default = true; }
+        public bool require_digit { get; set; default = true; }
+        public bool require_special { get; set; default = false; }
+        
+        // Storage Paths
+        public string storage_base_path { get; set; default = "/spry/users"; }
+        
+        // Default Permissions for New Users
+        public Series<string> default_permissions { get; set; default = new Series<string>(); }
+        
+        // Validation
+        public bool is_valid_password(string password);
+        public string? password_validation_message(string password);
+    }
+}
+```
+
+---
+
+## 6. Storage Schema
+
+### 6.1 Implexus Container Structure
+
+All user data is stored under the `/spry/users` container:
+
+```
+/spry/users/                           [Container] - Root for all user data
+├── users/                             [Container] - User documents
+│   ├── {uuid-v4-user-id-1}            [Document:type=User]
+│   ├── {uuid-v4-user-id-2}            [Document:type=User]
+│   └── {uuid-v4-user-id-3}            [Document:type=User]
+├── sessions/                          [Container] - Session documents
+│   ├── {uuid-v4-session-id-1}         [Document:type=Session]
+│   └── {uuid-v4-session-id-2}         [Document:type=Session]
+├── by_username/                       [Category] - Username lookup index
+│   └── Config: type=User, expr=username
+└── by_email/                          [Category] - Email lookup index
+    └── Config: type=User, expr=email
+```
+
+### 6.2 User Document Schema
+
+```json
+{
+    "id": "550e8400-e29b-41d4-a716-446655440000",
+    "username": "johndoe",
+    "email": "john@example.com",
+    "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$...",
+    "created": "2026-01-15T10:30:00Z",
+    "last_login": "2026-03-13T15:45:00Z",
+    "updated": null,
+    "active": true,
+    "verified": true,
+    "permissions": ["user-management", "content.edit"],
+    "app_data": {
+        "display_name": "John Doe",
+        "preferences": {
+            "theme": "dark",
+            "language": "en"
+        }
+    }
+}
+```
+
+### 6.3 Session Document Schema
+
+```json
+{
+    "id": "660e8400-e29b-41d4-a716-446655440001",
+    "user": "550e8400-e29b-41d4-a716-446655440000",
+    "created": "2026-03-13T10:00:00Z",
+    "expires": "2026-03-20T10:00:00Z",
+    "ip": "192.168.1.100",
+    "ua": "Mozilla/5.0 ..."
+}
+```
+
+### 6.4 Storage Initialization
+
+```vala
+public class UsersModule : Object {
+    
+    public void initialize_storage() throws Error {
+        var engine = inject<Implexus.Core.Engine>();
+        var config = inject<UsersConfiguration>();
+        
+        var root = engine.get_root();
+        
+        // Create base container: /spry/users
+        var spry = root.create_container("spry");
+        var users_container = spry.create_container("users");
+        
+        // Create users document container
+        users_container.create_container("users");
+        
+        // Create sessions document container
+        users_container.create_container("sessions");
+        
+        // Create username lookup category
+        users_container.create_category("by_username", "User", "username");
+        
+        // Create email lookup category
+        users_container.create_category("by_email", "User", "email");
+    }
+}
+```
+
+---
+
+## 7. Cryptography Integration
+
+### 7.1 Session Token Structure
+
+Session tokens are created using the existing `CryptographyProvider` pattern:
+
+```vala
+public class SessionToken {
+    public string session_id { get; set; }
+    public string user_id { get; set; }
+    public DateTime created_at { get; set; }
+    public DateTime expires_at { get; set; }
+    
+    public static PropertyMapper<SessionToken> get_mapper() {
+        return PropertyMapper.build_for<SessionToken>(cfg => {
+            cfg.map<string>("sid", t => t.session_id, (t, v) => t.session_id = v);
+            cfg.map<string>("uid", t => t.user_id, (t, v) => t.user_id = v);
+            cfg.map<string>("cat", t => t.created_at.format_iso8601(),
+                (t, v) => t.created_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
+            cfg.map<string>("exp", t => t.expires_at.format_iso8601(),
+                (t, v) => t.expires_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
+            cfg.set_constructor(() => new SessionToken());
+        });
+    }
+}
+```
+
+### 7.2 Token Creation and Validation
+
+```vala
+public class SessionService {
+    
+    private CryptographyProvider crypto = inject<CryptographyProvider>();
+    
+    public string create_session_token(Session session) throws Error {
+        var token = new SessionToken() {
+            session_id = session.id,
+            user_id = session.user_id,
+            created_at = session.created_at,
+            expires_at = session.expires_at
+        };
+        
+        var mapper = SessionToken.get_mapper();
+        var properties = mapper.map_from(token);
+        var json = new JsonElement.from_properties(properties);
+        var blob = json.stringify(false);
+        
+        // Sign then seal (same pattern as ComponentContext)
+        var signed = Sodium.Asymmetric.Signing.sign(blob.data, crypto.signing_secret_key);
+        var @sealed = Sodium.Asymmetric.Sealing.seal(signed, crypto.sealing_public_key);
+        
+        // URL-safe Base64 encoding
+        return Base64.encode(@sealed).replace("+", "-").replace("/", "_");
+    }
+    
+    public SessionToken? validate_session_token(string token) throws Error {
+        try {
+            // URL-safe Base64 decode
+            var decoded = Base64.decode(token.replace("-", "+").replace("_", "/"));
+            
+            // Unseal then verify signature
+            var signed = Sodium.Asymmetric.Sealing.unseal(
+                decoded, 
+                crypto.sealing_public_key, 
+                crypto.signing_secret_key
+            );
+            if (signed == null) {
+                throw new SessionError.INVALID_SESSION_TOKEN("Could not unseal token");
+            }
+            
+            var cleartext = Sodium.Asymmetric.Signing.verify(
+                signed, 
+                crypto.signing_public_key
+            );
+            if (cleartext == null) {
+                throw new SessionError.INVALID_SESSION_TOKEN("Invalid token signature");
+            }
+            
+            // Deserialize
+            var json = new JsonElement.from_string(
+                Wrap.byte_array(cleartext).to_raw_string()
+            );
+            var mapper = SessionToken.get_mapper();
+            var session_token = mapper.materialise(json.as<JsonObject>());
+            
+            // Check expiry
+            if (session_token.expires_at.compare(new DateTime.now_utc()) <= 0) {
+                throw new SessionError.SESSION_EXPIRED("Session has expired");
+            }
+            
+            return session_token;
+        } catch (Error e) {
+            return null;
+        }
+    }
+}
+```
+
+### 7.3 Password Hashing
+
+Uses Argon2id via libsodium (already available through existing dependencies):
+
+```vala
+public class UserService {
+    
+    public string hash_password(string password) {
+        return Sodium.PasswordHashing.hash(
+            password,
+            Sodium.PasswordHashing.OPSLIMIT_MODERATE,
+            Sodium.PasswordHashing.MEMLIMIT_MODERATE
+        );
+    }
+    
+    public bool verify_password(string password, string hash) {
+        return Sodium.PasswordHashing.verify(hash, password);
+    }
+}
+```
+
+---
+
+## 8. Component Design
+
+The user management interface is built from multiple focused components that work together through the UserManagementPage orchestrator. This separation provides better maintainability, reusability, and cleaner code organization.
+
+### 8.1 LoginFormComponent
+
+A self-contained login form component with HTMX-based submission:
+
+```vala
+namespace Spry.Users.Components {
+
+    public class LoginFormComponent : Component {
+        private SessionService session_service = inject<SessionService>();
+        private UserService user_service = inject<UserService>();
+        private HttpContext http_context = inject<HttpContext>();
+        
+        // Properties for configuration
+        public string redirect_url { get; set; default = "/"; }
+        public string? error_message { get; private set; }
+        
+        public override string markup { get {
+            return """
+            <div class="spry-login-form" sid="login-form">
+                <script spry-res="htmx.js"></script>
+                
+                <form sid="form" spry-action=":Login" spry-target="login-form" hx-swap="outerHTML">
+                    <spry-context property="redirect_url"/>
+                    
+                    <div class="form-group">
+                        <label for="username">Username or Email</label>
+                        <input type="text" name="username" sid="username" required 
+                               autocomplete="username"/>
+                    </div>
+                    
+                    <div class="form-group">
+                        <label for="password">Password</label>
+                        <input type="password" name="password" sid="password" required
+                               autocomplete="current-password"/>
+                    </div>
+                    
+                    <div spry-if="this.error_message != null" class="error-message" sid="error">
+                        <span content-expr="this.error_message"></span>
+                    </div>
+                    
+                    <button type="submit" sid="submit-btn">Log In</button>
+                </form>
+            </div>
+            """;
+        }}
+        
+        public override async void prepare() throws Error {
+            this["form"].set_attribute("hx-vals", @"{\"redirect_url\":\"$redirect_url\"}");
+            if (error_message != null) {
+                this["error"].text_content = error_message;
+            }
+        }
+        
+        public async override void handle_action(string action) throws Error {
+            var query = http_context.request.query_params;
+            
+            if (action == "Login") {
+                var username = query.get_any_or_default("username", "");
+                var password = query.get_any_or_default("password", "");
+                
+                try {
+                    var user = user_service.authenticate(username, password);
+                    var ip = http_context.request.remote_address;
+                    var ua = http_context.request.headers.get_any_or_default("User-Agent", null);
+                    var session = session_service.create_session(user.id, ip, ua);
+                    
+                    this["form"].set_attribute("hx-refresh", "true");
+                    session_service.set_session_cookie(session);
+                } catch (UserError e) {
+                    error_message = "Invalid username or password";
+                }
+            }
+        }
+    }
+}
+```
+
+### 8.2 UserListComponent
+
+Displays the list of users with search and filter capabilities. Creates UserListItemComponent instances for each user:
+
+```vala
+namespace Spry.Users.Components {
+
+    public class UserListComponent : Component {
+        private UserService user_service = inject<UserService>();
+        private ComponentFactory factory = inject<ComponentFactory>();
+        private HttpContext http_context = inject<HttpContext>();
+        
+        // State
+        private string _search_query = "";
+        private int _page = 0;
+        private int _page_size = 20;
+        private Series<User> _users = new Series<User>();
+        private int _total_count = 0;
+        
+        public override string markup { get {
+            return """
+            <div class="spry-user-list" sid="user-list" spry-global="user-list">
+                <script spry-res="htmx.js"></script>
+                
+                <!-- Search Bar -->
+                <div class="search-bar" sid="search-bar">
+                    <input type="text" name="search" sid="search-input" 
+                           placeholder="Search users..."
+                           spry-action=":Search" spry-target="user-list" hx-swap="outerHTML"/>
+                    <button sid="clear-btn" spry-action=":ClearSearch" 
+                            spry-target="user-list" hx-swap="outerHTML"
+                            spry-if="this._search_query.length > 0">Clear</button>
+                </div>
+                
+                <!-- User Table -->
+                <table class="user-table" sid="table">
+                    <thead>
+                        <tr>
+                            <th>Username</th>
+                            <th>Email</th>
+                            <th>Status</th>
+                            <th>Created</th>
+                            <th>Last Login</th>
+                            <th>Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody sid="table-body">
+                        <spry-outlet sid="users"/>
+                    </tbody>
+                </table>
+                
+                <!-- Pagination -->
+                <div class="pagination" sid="pagination" spry-if="this._total_count > this._page_size">
+                    <button sid="prev-btn" spry-action=":PrevPage" 
+                            spry-target="user-list" hx-swap="outerHTML"
+                            disabled-expr="this._page == 0 ? 'disabled' : null">
+                        Previous
+                    </button>
+                    <span content-expr="format('Page %d of %d', this._page + 1, 
+                        (this._total_count + this._page_size - 1) / this._page_size)"></span>
+                    <button sid="next-btn" spry-action=":NextPage"
+                            spry-target="user-list" hx-swap="outerHTML"
+                            disabled-expr="(this._page + 1) * this._page_size >= this._total_count ? 'disabled' : null">
+                        Next
+                    </button>
+                </div>
+                
+                <!-- Empty State -->
+                <div spry-if="this._users.length == 0" class="empty-state" sid="empty">
+                    <p>No users found</p>
+                </div>
+            </div>
+            """;
+        }}
+        
+        public override async void prepare() throws Error {
+            // Load users based on current search and page
+            if (_search_query.length > 0) {
+                _users = user_service.search_users(_search_query, _page_size);
+                _total_count = _users.length;
+            } else {
+                _users = user_service.list_users(_page * _page_size, _page_size);
+                _total_count = user_service.user_count();
+            }
+            
+            // Create user item components
+            var items = new Series<Renderable>();
+            foreach (var user in _users) {
+                var item = factory.create<UserListItemComponent>();
+                item.set_user(user);
+                items.add(item);
+            }
+            set_outlet_children("users", items);
+            
+            this["search-input"].set_attribute("value", _search_query);
+        }
+        
+        public async override void handle_action(string action) throws Error {
+            var query = http_context.request.query_params;
+            
+            switch (action) {
+                case "Search":
+                    _search_query = query.get_any_or_default("search", "");
+                    _page = 0;
+                    break;
+                case "ClearSearch":
+                    _search_query = "";
+                    _page = 0;
+                    break;
+                case "PrevPage":
+                    if (_page > 0) _page--;
+                    break;
+                case "NextPage":
+                    if ((_page + 1) * _page_size < _total_count) _page++;
+                    break;
+            }
+        }
+    }
+}
+```
+
+### 8.3 UserListItemComponent
+
+Individual user row with inline action buttons. Actions are delegated to the parent UserManagementPage:
+
+```vala
+namespace Spry.Users.Components {
+
+    public class UserListItemComponent : Component {
+        private User _user;
+        
+        public void set_user(User user) {
+            _user = user;
+        }
+        
+        public override string markup { get {
+            return """
+            <tr class="user-row" sid="user-row">
+                <td class="username" sid="username"></td>
+                <td class="email" sid="email"></td>
+                <td class="status" sid="status">
+                    <span sid="active-badge" class="badge badge-active">Active</span>
+                    <span sid="inactive-badge" class="badge badge-inactive">Inactive</span>
+                    <span sid="verified-badge" class="badge badge-verified">Verified</span>
+                </td>
+                <td class="created" sid="created"></td>
+                <td class="last-login" sid="last-login"></td>
+                <td class="actions" sid="actions">
+                    <button sid="edit-btn" spry-action="UserManagementPage:EditUser"
+                            hx-target="#user-form-container" hx-swap="innerHTML">
+                        Edit
+                    </button>
+                    <button sid="toggle-btn" spry-action="UserManagementPage:ToggleActive"
+                            hx-target="#user-list" hx-swap="outerHTML">
+                        <span spry-if="this._user.is_active">Deactivate</span>
+                        <span spry-else>Activate</span>
+                    </button>
+                    <button sid="delete-btn" spry-action="UserManagementPage:DeleteUser"
+                            hx-target="#user-list" hx-swap="outerHTML"
+                            hx-confirm="Are you sure you want to delete this user?">
+                        Delete
+                    </button>
+                </td>
+            </tr>
+            """;
+        }}
+        
+        public override async void prepare() throws Error {
+            if (_user == null) return;
+            
+            this["username"].text_content = _user.username;
+            this["email"].text_content = _user.email;
+            this["created"].text_content = _user.created_at.format("%Y-%m-%d");
+            
+            if (_user.last_login_at != null) {
+                this["last-login"].text_content = ((!)_user.last_login_at).format("%Y-%m-%d %H:%M");
+            } else {
+                this["last-login"].text_content = "Never";
+            }
+            
+            // Set user ID on action buttons for cross-component actions
+            var user_id_json = @"{\"user_id\":\"$(_user.id)\"}";
+            this["edit-btn"].set_attribute("hx-vals", user_id_json);
+            this["toggle-btn"].set_attribute("hx-vals", user_id_json);
+            this["delete-btn"].set_attribute("hx-vals", user_id_json);
+        }
+    }
+}
+```
+
+### 8.4 UserFormComponent
+
+Modal form for creating and editing users. Contains PermissionEditorComponent:
+
+```vala
+namespace Spry.Users.Components {
+
+    public class UserFormComponent : Component {
+        private UserService user_service = inject<UserService>();
+        private ComponentFactory factory = inject<ComponentFactory>();
+        private HttpContext http_context = inject<HttpContext>();
+        
+        // State
+        private User? _editing_user = null;
+        private bool _is_visible = false;
+        private string? _error_message = null;
+        
+        public void set_user(User? user) {
+            _editing_user = user;
+            _is_visible = true;
+        }
+        
+        public void show_create() {
+            _editing_user = null;
+            _is_visible = true;
+        }
+        
+        public void hide() {
+            _is_visible = false;
+            _editing_user = null;
+            _error_message = null;
+        }
+        
+        public bool is_creating { get { return _editing_user == null; } }
+        
+        public override string markup { get {
+            return """
+            <div class="spry-user-form-container" sid="form-container" spry-global="user-form">
+                <script spry-res="htmx.js"></script>
+                
+                <!-- Modal Overlay (shown when visible) -->
+                <div spry-if="this._is_visible" class="modal-overlay" sid="modal">
+                    <div class="modal-content" sid="modal-content">
+                        <div class="modal-header">
+                            <h3 content-expr="this.is_creating ? 'Create User' : 'Edit User'"></h3>
+                            <button sid="close-btn" spry-action=":Cancel" 
+                                    spry-target="form-container" hx-swap="outerHTML"
+                                    class="close-btn">&times;</button>
+                        </div>
+                        
+                        <div spry-if="this._error_message != null" class="error" sid="error">
+                            <span content-expr="this._error_message"></span>
+                        </div>
+                        
+                        <form sid="form" spry-action=":Save" 
+                              spry-target="form-container" hx-swap="outerHTML">
+                            <input type="hidden" name="user_id" sid="user-id"/>
+                            
+                            <div class="form-group">
+                                <label for="username">Username *</label>
+                                <input type="text" name="username" sid="form-username" 
+                                       required minlength="3" pattern="[a-zA-Z0-9_]+"/>
+                                <small>Alphanumeric characters and underscores only</small>
+                            </div>
+                            
+                            <div class="form-group">
+                                <label for="email">Email *</label>
+                                <input type="email" name="email" sid="form-email" required/>
+                            </div>
+                            
+                            <div class="form-group" spry-if="this.is_creating">
+                                <label for="password">Password *</label>
+                                <input type="password" name="password" sid="form-password"
+                                       required minlength="8"/>
+                                <small>Minimum 8 characters</small>
+                            </div>
+                            
+                            <div class="form-group" spry-if="!this.is_creating">
+                                <label for="new_password">New Password (leave blank to keep current)</label>
+                                <input type="password" name="new_password" sid="form-new-password"
+                                       minlength="8"/>
+                            </div>
+                            
+                            <div class="form-group">
+                                <label>
+                                    <input type="checkbox" name="is_active" sid="form-active" checked/>
+                                    Active
+                                </label>
+                            </div>
+                            
+                            <div class="form-group">
+                                <label>
+                                    <input type="checkbox" name="is_verified" sid="form-verified"/>
+                                    Email Verified
+                                </label>
+                            </div>
+                            
+                            <!-- Permission Editor -->
+                            <div class="form-group">
+                                <label>Permissions</label>
+                                <spry-component name="PermissionEditorComponent" sid="permission-editor"/>
+                            </div>
+                            
+                            <div class="form-actions">
+                                <button type="submit" sid="save-btn">
+                                    <span spry-if="this.is_creating">Create User</span>
+                                    <span spry-else>Save Changes</span>
+                                </button>
+                                <button type="button" sid="cancel-btn" 
+                                        spry-action=":Cancel" spry-target="form-container" 
+                                        hx-swap="outerHTML">Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            </div>
+            """;
+        }}
+        
+        public override async void prepare() throws Error {
+            if (!_is_visible) return;
+            
+            if (_editing_user != null) {
+                this["user-id"].set_attribute("value", _editing_user.id);
+                this["form-username"].set_attribute("value", _editing_user.username);
+                this["form-email"].set_attribute("value", _editing_user.email);
+                
+                if (_editing_user.is_active) {
+                    this["form-active"].set_attribute("checked", "checked");
+                }
+                if (_editing_user.is_verified) {
+                    this["form-verified"].set_attribute("checked", "checked");
+                }
+                
+                var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
+                perm_editor.set_permissions(_editing_user.permissions);
+            } else {
+                var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
+                perm_editor.set_permissions(new Series<string>());
+            }
+        }
+        
+        public async override void handle_action(string action) throws Error {
+            switch (action) {
+                case "Save":
+                    yield save_user();
+                    break;
+                case "Cancel":
+                    hide();
+                    break;
+            }
+        }
+        
+        private async void save_user() throws Error {
+            var query = http_context.request.query_params;
+            var user_id = query.get_any_or_default("user_id", "");
+            var username = query.get_any_or_default("username", "").strip();
+            var email = query.get_any_or_default("email", "").strip();
+            
+            if (username.length < 3) {
+                _error_message = "Username must be at least 3 characters";
+                return;
+            }
+            
+            var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
+            var permissions = perm_editor.get_selected();
+            
+            try {
+                if (user_id == "") {
+                    var password = query.get_any_or_default("password", "");
+                    var is_active = query.get_any_or_default("is_active", "") == "on";
+                    var is_verified = query.get_any_or_default("is_verified", "") == "on";
+                    
+                    var user = user_service.create_user(username, email, password, permissions, null);
+                    user.is_active = is_active;
+                    user.is_verified = is_verified;
+                    user_service.update_user(user);
+                } else {
+                    var user = user_service.get_user_by_id(user_id);
+                    if (user == null) {
+                        _error_message = "User not found";
+                        return;
+                    }
+                    
+                    user.username = username;
+                    user.email = email;
+                    user.is_active = query.get_any_or_default("is_active", "") == "on";
+                    user.is_verified = query.get_any_or_default("is_verified", "") == "on";
+                    user.permissions = permissions;
+                    user.updated_at = new DateTime.now_utc();
+                    
+                    var new_password = query.get_any_or_default("new_password", "");
+                    if (new_password.length > 0) {
+                        user_service.update_password(user_id, new_password);
+                    }
+                    
+                    user_service.update_user(user);
+                }
+                
+                hide();
+            } catch (UserError e) {
+                _error_message = e.message;
+            }
+        }
+    }
+}
+```
+
+### 8.5 PermissionEditorComponent
+
+Widget for managing user permissions with checkboxes for common permissions and input for custom ones:
+
+```vala
+namespace Spry.Users.Components {
+
+    public class PermissionEditorComponent : Component {
+        private static Series<string> COMMON_PERMISSIONS = new Series<string>.from({
+            Permission.USER_MANAGEMENT,
+            Permission.ADMIN,
+            "content.read",
+            "content.edit",
+            "content.delete"
+        });
+        
+        private Series<string> _selected_permissions = new Series<string>();
+        private Series<string> _custom_permissions = new Series<string>();
+        
+        public void set_permissions(Series<string> permissions) {
+            _selected_permissions = new Series<string>();
+            _custom_permissions = new Series<string>();
+            
+            foreach (var perm in permissions) {
+                if (COMMON_PERMISSIONS.contains(perm)) {
+                    _selected_permissions.add(perm);
+                } else {
+                    _custom_permissions.add(perm);
+                }
+            }
+        }
+        
+        public Series<string> get_selected() {
+            var result = new Series<string>();
+            result.add_all(_selected_permissions);
+            result.add_all(_custom_permissions);
+            return result;
+        }
+        
+        public override string markup { get {
+            return """
+            <div class="spry-permission-editor" sid="perm-editor">
+                <script spry-res="htmx.js"></script>
+                
+                <!-- Common Permissions -->
+                <div class="permission-group" sid="common-group">
+                    <h4>Common Permissions</h4>
+                    <div class="permission-list" sid="common-list">
+                        <spry-outlet sid="common-items"/>
+                    </div>
+                </div>
+                
+                <!-- Custom Permissions -->
+                <div class="permission-group" sid="custom-group">
+                    <h4>Custom Permissions</h4>
+                    <div class="custom-permissions" sid="custom-list">
+                        <spry-outlet sid="custom-items"/>
+                    </div>
+                    
+                    <!-- Add Custom Permission -->
+                    <div class="add-permission" sid="add-perm">
+                        <input type="text" name="new_permission" sid="new-perm-input"
+                               placeholder="e.g., reports.view"/>
+                        <button sid="add-btn" spry-action=":AddPermission"
+                                spry-target="perm-editor" hx-swap="outerHTML">
+                            Add
+                        </button>
+                    </div>
+                </div>
+            </div>
+            """;
+        }}
+        
+        public override async void prepare() throws Error {
+            var common_items = new Series<Renderable>();
+            foreach (var perm in COMMON_PERMISSIONS) {
+                var item = create_permission_checkbox(perm, _selected_permissions.contains(perm));
+                common_items.add(item);
+            }
+            set_outlet_children("common-items", common_items);
+            
+            var custom_items = new Series<Renderable>();
+            foreach (var perm in _custom_permissions) {
+                var item = create_custom_permission_tag(perm);
+                custom_items.add(item);
+            }
+            set_outlet_children("custom-items", custom_items);
+        }
+        
+        private Renderable create_permission_checkbox(string permission, bool is_checked) {
+            var safe_perm = permission.replace(".", "-");
+            var checked_attr = is_checked ? "checked" : "";
+            
+            var doc = new MarkupDocument();
+            doc.body.inner_html = @"<label class=\"permission-checkbox\">
+                <input type=\"checkbox\" name=\"perm_$safe_perm\" value=\"$permission\" $checked_attr/>
+                <span>$permission</span>
+            </label>";
+            
+            return new InlineRenderable(doc);
+        }
+        
+        private Renderable create_custom_permission_tag(string permission) {
+            var doc = new MarkupDocument();
+            doc.body.inner_html = @"<span class=\"permission-tag\">
+                <span>$permission</span>
+                <button type=\"button\" spry-action=\":RemovePermission:$permission\"
+                        spry-target=\"perm-editor\" hx-swap=\"outerHTML\" 
+                        class=\"remove-tag\">&times;</button>
+            </span>";
+            
+            return new InlineRenderable(doc);
+        }
+        
+        public async override void handle_action(string action) throws Error {
+            var query = http_context.request.query_params;
+            
+            if (action == "AddPermission") {
+                var new_perm = query.get_any_or_default("new_permission", "").strip();
+                if (new_perm.length > 0 && Permission.is_valid(new_perm)) {
+                    if (!_custom_permissions.contains(new_perm) && 
+                        !_selected_permissions.contains(new_perm)) {
+                        _custom_permissions.add(new_perm);
+                    }
+                }
+            } else if (action.has_prefix("RemovePermission:")) {
+                var perm_to_remove = action.substring(17);
+                _custom_permissions.remove(perm_to_remove);
+            }
+            
+            // Update selected from checkboxes
+            _selected_permissions.clear();
+            foreach (var perm in COMMON_PERMISSIONS) {
+                var safe_perm = perm.replace(".", "-");
+                if (query.get_any_or_default(@"perm_$safe_perm", "") == perm) {
+                    _selected_permissions.add(perm);
+                }
+            }
+        }
+    }
+    
+    // Helper class for inline HTML rendering
+    internal class InlineRenderable : Object, Renderable {
+        private MarkupDocument _doc;
+        
+        public InlineRenderable(MarkupDocument doc) {
+            _doc = doc;
+        }
+        
+        public async MarkupDocument to_document() throws Error {
+            return _doc;
+        }
+    }
+}
+```
+
+### 8.6 UserManagementPage
+
+Orchestrates all user management components. Handles cross-component actions:
+
+```vala
+namespace Spry.Users.Pages {
+
+    public class UserManagementPage : PageComponent {
+        private PermissionService permission_service = inject<PermissionService>();
+        private UserService user_service = inject<UserService>();
+        private SessionService session_service = inject<SessionService>();
+        private ComponentFactory factory = inject<ComponentFactory>();
+        private HttpContext http_context = inject<HttpContext>();
+        
+        // State
+        private string? _success_message = null;
+        private string? _error_message = null;
+        
+        public override string markup { get {
+            return """
+            <!DOCTYPE html>
+            <html>
+            <head>
+                <title>User Management</title>
+                <link rel="stylesheet" href="/static/admin.css"/>
+                <style>
+                    .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; 
+                                    background: rgba(0,0,0,0.5); display: flex; 
+                                    align-items: center; justify-content: center; }
+                    .modal-content { background: white; padding: 2rem; border-radius: 8px; 
+                                    max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; }
+                    .badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; margin-right: 0.25rem; }
+                    .badge-active { background: #d4edda; color: #155724; }
+                    .badge-inactive { background: #f8d7da; color: #721c24; }
+                    .badge-verified { background: #cce5ff; color: #004085; }
+                    .permission-tag { display: inline-flex; align-items: center; 
+                                     background: #e9ecef; padding: 0.25rem 0.5rem; 
+                                     border-radius: 4px; margin: 0.25rem; }
+                </style>
+            </head>
+            <body>
+                <div class="admin-container" sid="admin-container">
+                    <header class="admin-header">
+                        <h1>User Management</h1>
+                        <div class="header-actions">
+                            <button sid="create-btn" spry-action=":CreateUser"
+                                    hx-target="#user-form-container" hx-swap="innerHTML"
+                                    class="btn btn-primary">
+                                Create User
+                            </button>
+                        </div>
+                    </header>
+                    
+                    <!-- Status Messages -->
+                    <div spry-if="this._success_message != null" class="alert alert-success" 
+                         sid="success-alert" spry-global="success-alert">
+                        <span content-expr="this._success_message"></span>
+                    </div>
+                    
+                    <div spry-if="this._error_message != null" class="alert alert-error"
+                         sid="error-alert" spry-global="error-alert">
+                        <span content-expr="this._error_message"></span>
+                    </div>
+                    
+                    <!-- User List Component -->
+                    <spry-component name="UserListComponent" sid="user-list"/>
+                    
+                    <!-- User Form Container (for modal) -->
+                    <div id="user-form-container" sid="user-form-container">
+                        <spry-component name="UserFormComponent" sid="user-form"/>
+                    </div>
+                </div>
+            </body>
+            </html>
+            """;
+        }}
+        
+        public override async void prepare() throws Error {
+            permission_service.require_permission(Permission.USER_MANAGEMENT);
+            
+            var user_form = get_component_child<UserFormComponent>("user-form");
+            user_form.hide();
+        }
+        
+        public async override void handle_action(string action) throws Error {
+            var query = http_context.request.query_params;
+            
+            switch (action) {
+                case "CreateUser":
+                    var user_form = get_component_child<UserFormComponent>("user-form");
+                    user_form.show_create();
+                    break;
+                    
+                case "EditUser":
+                    var user_id = query.get_any_or_default("user_id", "");
+                    var user = user_service.get_user_by_id(user_id);
+                    if (user != null) {
+                        var user_form = get_component_child<UserFormComponent>("user-form");
+                        user_form.set_user(user);
+                    }
+                    break;
+                    
+                case "ToggleActive":
+                    var toggle_id = query.get_any_or_default("user_id", "");
+                    var toggle_user = user_service.get_user_by_id(toggle_id);
+                    if (toggle_user != null) {
+                        toggle_user.is_active = !toggle_user.is_active;
+                        user_service.update_user(toggle_user);
+                        _success_message = toggle_user.is_active ? 
+                            "User activated" : "User deactivated";
+                        
+                        var user_list = get_component_child<UserListComponent>("user-list");
+                        add_globals_from(user_list);
+                    }
+                    break;
+                    
+                case "DeleteUser":
+                    var delete_id = query.get_any_or_default("user_id", "");
+                    
+                    var current_user_id = session_service.get_current_user_id();
+                    if (current_user_id == delete_id) {
+                        _error_message = "Cannot delete your own account";
+                        break;
+                    }
+                    
+                    user_service.delete_user(delete_id);
+                    _success_message = "User deleted successfully";
+                    
+                    var user_list = get_component_child<UserListComponent>("user-list");
+                    add_globals_from(user_list);
+                    break;
+            }
+        }
+    }
+}
+```
+
+---
+
+## 9. Integration Points
+
+### 9.1 Module Registration
+
+Applications integrate the Users system by registering the module:
+
+```vala
+// In application startup
+var application = new Application();
+application.add_module<SpryModule>();
+application.add_module<Spry.Users.UsersModule>();
+
+// Optional: Configure settings
+var config = application.resolve<Spry.Users.UsersConfiguration>();
+config.session_duration = TimeSpan.HOUR * 24; // 1 day
+config.min_password_length = 12;
+config.default_permissions.add("user");
+```
+
+### 9.2 UsersModule Implementation
+
+```vala
+namespace Spry.Users {
+
+    public class UsersModule : Object, Inversion.Module {
+        
+        public void register(Inversion.Application application) {
+            // Register configuration (singleton)
+            application.add_singleton<UsersConfiguration>();
+            
+            // Register services (scoped for per-request isolation)
+            application.add_scoped<UserService>();
+            application.add_scoped<SessionService>();
+            application.add_scoped<PermissionService>();
+            
+            // Register components (transient)
+            var spry_cfg = application.configure_with<SpryConfigurator>();
+            spry_cfg.add_component<Components.LoginFormComponent>();
+            spry_cfg.add_component<Components.UserListComponent>();
+            spry_cfg.add_component<Components.UserListItemComponent>();
+            spry_cfg.add_component<Components.UserFormComponent>();
+            spry_cfg.add_component<Components.PermissionEditorComponent>();
+            
+            // Register pages (scoped)
+            spry_cfg.add_page<Pages.UserManagementPage>(
+                new EndpointRoute("/admin/users"));
+        }
+        
+        public void initialize(Inversion.Application application) {
+            // Initialize storage structure
+            try {
+                var engine = application.resolve<Implexus.Core.Engine>();
+                initialize_storage(engine);
+            } catch (Error e) {
+                error("Failed to initialize Users storage: %s", e.message);
+            }
+        }
+        
+        private void initialize_storage(Implexus.Core.Engine engine) throws Error {
+            var root = engine.get_root();
+            
+            // Create /spry/users container hierarchy
+            var spry = get_or_create_container(root, "spry");
+            var users_base = get_or_create_container(spry, "users");
+            
+            get_or_create_container(users_base, "users");
+            get_or_create_container(users_base, "sessions");
+            get_or_create_category(users_base, "by_username", "User", "username");
+            get_or_create_category(users_base, "by_email", "User", "email");
+        }
+        
+        private Implexus.Core.Entity get_or_create_container(
+            Implexus.Core.Entity parent, string name) throws Error {
+            var child = parent.get_child(name);
+            if (child != null) return (!)child;
+            return (!)parent.create_container(name);
+        }
+        
+        private void get_or_create_category(
+            Implexus.Core.Entity parent, string name, 
+            string type_label, string expression) throws Error {
+            var child = parent.get_child(name);
+            if (child != null) return;
+            parent.create_category(name, type_label, expression);
+        }
+    }
+}
+```
+
+### 9.3 Protecting Routes
+
+Applications can protect routes using the PermissionService:
+
+```vala
+public class AdminPage : PageComponent {
+    private PermissionService permissions = inject<PermissionService>();
+    
+    public override async void prepare() throws Error {
+        // Will throw PermissionDenied if not authorized
+        permissions.require_permission("admin");
+    }
+}
+```
+
+### 9.4 Accessing Current User
+
+```vala
+public class DashboardPage : PageComponent {
+    private SessionService sessions = inject<SessionService>();
+    
+    public override async void prepare() throws Error {
+        var user = sessions.get_current_user();
+        if (user == null) {
+            // Redirect to login
+            return;
+        }
+        
+        // Use user data
+        this["welcome"].text_content = @"Welcome, $(user.username)!";
+    }
+}
+```
+
+### 9.5 Custom Application Data
+
+```vala
+// Store custom data
+var user = user_service.get_user_by_id(user_id);
+user.application_data = new Properties();
+user.application_data["theme"] = new NativeElement<string>("dark");
+user.application_data["notifications"] = new NativeElement<bool?>(true);
+user_service.update_user(user);
+
+// Retrieve custom data
+var theme = user.application_data?.get_any_or_default("theme", "light");
+```
+
+---
+
+## 10. Security Considerations
+
+### 10.1 Password Security
+
+- **Hashing**: Argon2id with moderate ops/mem limits via libsodium
+- **Never store plaintext**: Password field only accepts hashes
+- **Verification timing-safe**: Uses libsodium's constant-time comparison
+
+### 10.2 Session Token Security
+
+- **Signed**: Ed25519 signatures prevent token tampering
+- **Encrypted**: X25519-Seal prevents token inspection
+- **URL-safe encoding**: Base64 with `+` → `-` and `/` → `_` substitution
+- **Expiry validation**: Server-side expiry check in addition to token expiry
+
+### 10.3 Cookie Security
+
+- **HttpOnly**: Prevents JavaScript access to session cookie
+- **Secure**: Only transmitted over HTTPS (configurable for development)
+- **SameSite**: Strict by default to prevent CSRF
+- **Path-limited**: Cookie scoped to application path
+
+### 10.4 Permission Checks
+
+- **Deny by default**: No permissions unless explicitly granted
+- **Admin override**: Users with "admin" permission bypass all checks
+- **Wildcard support**: "content.*" implies "content.edit", "content.delete", etc.
+- **Server-side enforcement**: All permission checks happen server-side
+
+### 10.5 Input Validation
+
+- **Username**: Alphanumeric, underscores, minimum 3 characters
+- **Email**: Basic email format validation
+- **Password**: Configurable complexity requirements
+- **Permission strings**: Validated against regex `^[a-zA-Z0-9._-]+$`
+
+### 10.6 Session Management
+
+- **Session invalidation**: On password change, user deletion, or explicit logout
+- **IP binding**: Optional binding to client IP (disabled by default)
+- **User agent tracking**: Optional for anomaly detection
+- **Automatic cleanup**: Expired sessions cleaned up periodically
+
+### 10.7 Known Limitations
+
+- **No rate limiting**: Applications should implement their own rate limiting on login endpoints
+- **No account lockout**: No automatic lockout after failed attempts (application responsibility)
+- **No password reset**: Built-in password reset flow not included (application responsibility)
+- **No two-factor authentication**: Not included in this implementation
+
+---
+
+## 11. Mermaid Diagrams
+
+### 11.1 Authentication Flow
+
+```mermaid
+sequenceDiagram
+    participant Client
+    participant LoginFormComponent
+    participant UserService
+    participant SessionService
+    participant Implexus
+    
+    Client->>LoginFormComponent: Submit login form
+    LoginFormComponent->>UserService: authenticate username/password
+    UserService->>Implexus: Query user by username
+    Implexus-->>UserService: User document
+    UserService->>UserService: verify_password
+    UserService-->>LoginFormComponent: User authenticated
+    LoginFormComponent->>SessionService: create_session user_id
+    SessionService->>Implexus: Store session document
+    SessionService->>SessionService: create_session_token
+    SessionService->>Client: Set session cookie
+    LoginFormComponent-->>Client: Redirect to dashboard
+```
+
+### 11.2 Permission Check Flow
+
+```mermaid
+flowchart TD
+    A[Request to protected resource] --> B{Is authenticated?}
+    B -->|No| C[Redirect to login]
+    B -->|Yes| D{Has admin permission?}
+    D -->|Yes| E[Allow access]
+    D -->|No| F{Has required permission?}
+    F -->|Yes| E
+    F -->|No| G[Permission denied error]
+    
+    E --> H[Process request]
+```
+
+### 11.3 Storage Hierarchy
+
+```mermaid
+graph TD
+    Root[Root Container /] --> Spry[spry/]
+    Spry --> Users[users/]
+    Users --> UserDocs[users/ - User Documents]
+    Users --> SessionDocs[sessions/ - Session Documents]
+    Users --> ByUsername[by_username - Category]
+    Users --> ByEmail[by_email - Category]
+    
+    UserDocs --> User1[uuid-1 - type: User]
+    UserDocs --> User2[uuid-2 - type: User]
+    
+    SessionDocs --> Session1[uuid-s1 - type: Session]
+    SessionDocs --> Session2[uuid-s2 - type: Session]
+```
+
+---
+
+## 12. Dependencies
+
+### 12.1 Meson Build Configuration
+
+```meson
+# src/Users/meson.build
+
+users_sources = files(
+    'UsersModule.vala',
+    'UserService.vala',
+    'SessionService.vala',
+    'PermissionService.vala',
+    'Models/User.vala',
+    'Models/Session.vala',
+    'Models/Permission.vala',
+    'Components/LoginFormComponent.vala',
+    'Components/UserListComponent.vala',
+    'Components/UserListItemComponent.vala',
+    'Components/UserFormComponent.vala',
+    'Components/PermissionEditorComponent.vala',
+    'Pages/UserManagementPage.vala'
+)
+
+# Users library
+libspry_users = static_library('spry-users',
+    users_sources,
+    dependencies: [spry_dep, implexus_dep, sodium_deps],
+    include_directories: include_directories('..')
+)
+
+spry_users_dep = declare_dependency(
+    link_with: libspry_users,
+    dependencies: [spry_dep, implexus_dep]
+)
+```
+
+### 12.2 Required Dependencies
+
+| Dependency | Purpose |
+|------------|---------|
+| `spry-0.1` | Core framework (Component, PageComponent, CryptographyProvider) |
+| `implexus-0.1` | Document storage |
+| `invercargill-1` | Data structures (Series, Dictionary, HashSet) |
+| `invercargill-json` | JSON serialization |
+| `inversion-0.1` | IoC container |
+| `libsodium` | Cryptography (password hashing, signing, encryption) |
+| `astralis-0.1` | HTTP handling |
+
+### 12.3 Root meson.build Update
+
+Add to root `meson.build`:
+
+```meson
+implexus_dep = dependency('implexus-0.1')
+subdir('src/Users')
+```
+
+---
+
+## 13. Future Considerations
+
+### Potential Enhancements
+
+1. **OAuth Integration**: Add support for external OAuth providers
+2. **Two-Factor Authentication**: TOTP or WebAuthn support
+3. **Password Reset Flow**: Email-based password recovery
+4. **Account Verification**: Email verification workflow
+5. **Audit Logging**: Track user management actions
+6. **Rate Limiting**: Built-in login attempt limiting
+7. **API Token Support**: For API-only authentication
+8. **Role-Based Access**: Group permissions into roles
+
+---
+
+## 14. Implementation Order
+
+Recommended implementation sequence:
+
+1. **Models**: User, Session, Permission data classes with PropertyMappers
+2. **UsersConfiguration**: Configuration class with defaults
+3. **UserService**: Core user CRUD and password hashing
+4. **SessionService**: Session management and cookie handling
+5. **PermissionService**: Permission checking and management
+6. **UsersModule**: IoC registration and storage initialization
+7. **LoginFormComponent**: Basic login form
+8. **UserListItemComponent**: Individual user row component
+9. **UserListComponent**: User list with search and pagination
+10. **PermissionEditorComponent**: Permission management widget
+11. **UserFormComponent**: Create/edit user modal form
+12. **UserManagementPage**: Page orchestrating all components
+13. **Testing**: Unit tests and integration tests
+14. **Documentation**: Usage examples and API docs

+ 257 - 0
src/Users/Components/LoginFormComponent.vala

@@ -0,0 +1,257 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill.DataStructures;
+
+namespace Spry.Users.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
+     * - Creates sessions and sets cookies via SessionService
+     * - 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
+     */
+    public class LoginFormComponent : Component {
+
+        private UserService user_service = inject<UserService>();
+        private SessionService session_service = inject<SessionService>();
+        private PermissionService? permission_service = inject<PermissionService>();
+        private HttpContext http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * URL to redirect to after successful login.
+         * Default: "/"
+         */
+        public string redirect_url { get; set; default = "/"; }
+
+        /**
+         * Custom action name for the login form.
+         * This can be used to differentiate multiple login forms.
+         * Default: "login"
+         */
+        public string login_action_name { get; set; default = "login"; }
+
+        /**
+         * 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"/>
+            <div class="spry-login-form" sid="login-form" hx-swap="outerHTML">
+                <script spry-res="htmx.js"></script>
+
+                <form sid="form" spry-action=":login" spry-target="login-form">
+                    <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);
+            }
+
+            // If login was successful, add HX-Redirect header for client-side redirect
+            if (login_successful) {
+                // Set the redirect header - HTMX will handle the redirect
+                this["login-form"].set_attribute("hx-redirect", redirect_url);
+            }
+        }
+
+        public async override void handle_action(string action) throws Error {
+            // Normalize action name comparison
+            if (action.down() == login_action_name.down()) {
+                yield handle_login();
+            }
+        }
+
+        // =========================================================================
+        // Login Handler
+        // =========================================================================
+
+        private async void handle_login() throws Error {
+            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";
+
+            // Preserve username for re-display on error
+            preserved_username = username;
+
+            // Validate inputs
+            if (username.length == 0) {
+                error_message = "Please enter your username or email";
+                return;
+            }
+
+            if (password.length == 0) {
+                error_message = "Please enter your password";
+                return;
+            }
+
+            // Attempt authentication
+            var user = user_service.authenticate(username, password);
+
+            if (user == null) {
+                // Authentication failed - show generic error message
+                // (Don't reveal whether username or password was wrong for security)
+                error_message = "Invalid username or password";
+                return;
+            }
+
+            // Check if user is active (if the User model supports this)
+            // Note: Current User model doesn't have is_active, but we check anyway
+            // for future compatibility
+
+            // Get client info for session tracking
+            var ip_address = http_context.request.remote_address;
+            var user_agent = http_context.request.headers.get_any_or_default("User-Agent");
+
+            // Create session
+            Session? session;
+            if (remember_me) {
+                // Create session with extended duration for "remember me"
+                // Note: Current SessionService uses configured duration; 
+                // for extended sessions, we'd need to modify SessionService
+                // For now, create a regular session
+                session = session_service.create_session(user.id, ip_address, user_agent);
+            } else {
+                session = session_service.create_session(user.id, ip_address, user_agent);
+            }
+
+            if (session == null) {
+                error_message = "Failed to create session. Please try again.";
+                return;
+            }
+
+            // Generate session token
+            var token = session_service.generate_session_token(session);
+
+            // Set session cookie via HttpResult
+            // We need to set the cookie header on the response
+            var result = yield to_result();
+            session_service.set_session_cookie(result, token);
+
+            // Mark login as successful - prepare() will add redirect header
+            login_successful = true;
+            error_message = null;
+
+            // Optional: Check permissions if permission_service is available
+            // This can be used for post-login permission checks
+            if (permission_service != null) {
+                // Subclasses can override to add permission checks
+                // e.g., require certain permissions to access specific areas
+            }
+        }
+
+        // =========================================================================
+        // 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;
+        }
+    }
+}

+ 322 - 0
src/Users/Components/PermissionEditorComponent.vala

@@ -0,0 +1,322 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill.DataStructures;
+
+namespace Spry.Users.Components {
+
+    /**
+     * PermissionEditorComponent - Widget for managing user permissions.
+     *
+     * This component provides:
+     * - Checkboxes for common permissions
+     * - Display of user's current permissions
+     * - Add/remove custom permissions
+     * - Validation of permission string format
+     *
+     * The component maintains internal state for selected permissions and
+     * provides methods to get/set the permission list.
+     *
+     * Usage:
+     *   var editor = factory.create<PermissionEditorComponent>();
+     *   editor.set_permissions(user.permissions);
+     *   // After user interaction:
+     *   var selected = editor.get_selected();
+     */
+    public class PermissionEditorComponent : Component {
+
+        private HttpContext http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // Constants
+        // =========================================================================
+
+        /**
+         * Common permissions that are frequently used.
+         * These are displayed as checkboxes for easy selection.
+         */
+        private static string[] COMMON_PERMISSION_ARRAY = {
+            PermissionService.USER_MANAGEMENT,
+            PermissionService.USER_CREATE,
+            PermissionService.USER_READ,
+            PermissionService.USER_UPDATE,
+            PermissionService.USER_DELETE,
+            PermissionService.ADMIN
+        };
+
+        // =========================================================================
+        // State Properties
+        // =========================================================================
+
+        private Series<string> _selected_permissions = new Series<string>();
+        private Series<string> _custom_permissions = new Series<string>();
+        private string? _error_message = null;
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <div class="spry-permission-editor" sid="perm-editor" hx-swap="outerHTML">
+                <script spry-res="htmx.js"></script>
+
+                <!-- Error Message -->
+                <div spry-if="this._error_message != null" class="error-message" sid="error">
+                    <span content-expr="this._error_message"></span>
+                </div>
+
+                <!-- Common Permissions -->
+                <div class="permission-group" sid="common-group">
+                    <h4>Common Permissions</h4>
+                    <div class="permission-list" sid="common-list">
+                        <spry-outlet sid="common-items"/>
+                    </div>
+                </div>
+
+                <!-- Custom Permissions -->
+                <div class="permission-group" sid="custom-group">
+                    <h4>Custom Permissions</h4>
+                    <div class="custom-permissions" sid="custom-list">
+                        <spry-outlet sid="custom-items"/>
+                    </div>
+
+                    <!-- Add Custom Permission -->
+                    <div class="add-permission" sid="add-perm">
+                        <input type="text" 
+                               name="new_permission" 
+                               sid="new-perm-input"
+                               placeholder="e.g., reports.view"
+                               class="permission-input"/>
+                        <button sid="add-btn" 
+                                spry-action=":AddPermission"
+                                spry-target="perm-editor"
+                                class="btn btn-add">
+                            Add Permission
+                        </button>
+                    </div>
+
+                    <!-- Help Text -->
+                    <small class="help-text">
+                        Permission format: lowercase letters, numbers, dots, dashes, and underscores.
+                        Examples: "content.edit", "reports-view", "admin.users"
+                    </small>
+                </div>
+            </div>
+            """;
+        }}
+
+        public override async void prepare() throws Error {
+            // Create checkboxes for common permissions
+            var common_items = new Series<Renderable>();
+            foreach (var perm in COMMON_PERMISSION_ARRAY) {
+                var is_checked = is_permission_selected(perm);
+                var item = create_permission_checkbox(perm, is_checked);
+                common_items.add(item);
+            }
+            set_outlet_children("common-items", common_items);
+
+            // Create tags for custom permissions (not in common list)
+            var custom_items = new Series<Renderable>();
+            foreach (var perm in _custom_permissions) {
+                if (!is_common_permission(perm)) {
+                    var item = create_custom_permission_tag(perm);
+                    custom_items.add(item);
+                }
+            }
+            set_outlet_children("custom-items", custom_items);
+        }
+
+        public async override void handle_action(string action) throws Error {
+            var query = http_context.request.query_params;
+
+            if (action == "AddPermission") {
+                var new_perm = query.get_any_or_default("new_permission");
+                if (new_perm != null) {
+                    add_custom_permission((!)new_perm);
+                }
+            } else if (action.has_prefix("RemovePermission:")) {
+                var perm_to_remove = action.substring(17);
+                remove_permission(perm_to_remove);
+            }
+
+            // Update selected from checkboxes (read from query params)
+            // Note: Checkboxes only send values when checked
+            _selected_permissions.clear();
+            foreach (var perm in COMMON_PERMISSION_ARRAY) {
+                var safe_perm = get_safe_permission_name(perm);
+                var checkbox_value = query.get_any_or_default(@"perm_$safe_perm");
+                if (checkbox_value != null && ((!)checkbox_value) == perm) {
+                    _selected_permissions.add(perm);
+                }
+            }
+        }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Sets the permissions to display as selected.
+         *
+         * This method categorizes permissions into common (shown as checkboxes)
+         * and custom (shown as removable tags).
+         *
+         * @param permissions The permissions to set
+         */
+        public void set_permissions(Vector<string> permissions) {
+            _selected_permissions = new Series<string>();
+            _custom_permissions = new Series<string>();
+
+            foreach (var perm in permissions) {
+                if (is_common_permission(perm)) {
+                    _selected_permissions.add(perm);
+                } else {
+                    _custom_permissions.add(perm);
+                }
+            }
+        }
+
+        /**
+         * Gets all selected permissions (both common and custom).
+         *
+         * @return A Vector of all selected permission strings
+         */
+        public Vector<string> get_selected() {
+            var result = new Vector<string>();
+
+            // Add common permissions that are selected
+            foreach (var perm in _selected_permissions) {
+                result.add(perm);
+            }
+
+            // Add custom permissions
+            foreach (var perm in _custom_permissions) {
+                if (!result.contains(perm)) {
+                    result.add(perm);
+                }
+            }
+
+            return result;
+        }
+
+        /**
+         * Clears all selected permissions.
+         */
+        public void clear_all() {
+            _selected_permissions.clear();
+            _custom_permissions.clear();
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private bool is_common_permission(string permission) {
+            foreach (var perm in COMMON_PERMISSION_ARRAY) {
+                if (perm == permission) return true;
+            }
+            return false;
+        }
+
+        private bool is_permission_selected(string permission) {
+            foreach (var perm in _selected_permissions) {
+                if (perm == permission) return true;
+            }
+            foreach (var perm in _custom_permissions) {
+                if (perm == permission) return true;
+            }
+            return false;
+        }
+
+        private void add_custom_permission(string permission) {
+            var trimmed = permission.strip();
+
+            // Validate
+            if (trimmed.length == 0) {
+                _error_message = "Permission cannot be empty";
+                return;
+            }
+
+            if (!is_valid_permission(trimmed)) {
+                _error_message = @"Invalid permission format: $trimmed";
+                return;
+            }
+
+            // Check if already exists
+            if (is_permission_selected(trimmed)) {
+                _error_message = @"Permission already added: $trimmed";
+                return;
+            }
+
+            _error_message = null;
+
+            // Add to appropriate collection
+            if (is_common_permission(trimmed)) {
+                _selected_permissions.add(trimmed);
+            } else {
+                _custom_permissions.add(trimmed);
+            }
+        }
+
+        private void remove_permission(string permission) {
+            _selected_permissions.remove(permission);
+            _custom_permissions.remove(permission);
+        }
+
+        private bool is_valid_permission(string permission) {
+            // Must be non-empty, alphanumeric with dots, dashes, underscores
+            // Regex: ^[a-zA-Z0-9._-]+$
+            if (permission.length == 0) return false;
+
+            foreach (var c in permission.data) {
+                if (!((c >= 'a' && c <= 'z') ||
+                      (c >= 'A' && c <= 'Z') ||
+                      (c >= '0' && c <= '9') ||
+                      c == '.' || c == '-' || c == '_')) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        private string get_safe_permission_name(string permission) {
+            // Replace dots and special chars for use in form field names
+            return permission.replace(".", "-").replace("*", "star");
+        }
+
+        private Renderable create_permission_checkbox(string permission, bool is_checked) {
+            var safe_perm = get_safe_permission_name(permission);
+            var checked_attr = is_checked ? "checked" : "";
+            var escaped_perm = escape_html(permission);
+
+            var doc = new MarkupDocument();
+            doc.body.inner_html = @"<label class=\"permission-checkbox\">
+                <input type=\"checkbox\" name=\"perm_$safe_perm\" value=\"$escaped_perm\" $checked_attr/>
+                <span class=\"permission-label\">$escaped_perm</span>
+            </label>";
+
+            return new InlineRenderable(doc);
+        }
+
+        private Renderable create_custom_permission_tag(string permission) {
+            var escaped_perm = escape_html(permission);
+
+            var doc = new MarkupDocument();
+            doc.body.inner_html = @"<span class=\"permission-tag\">
+                <span class=\"permission-name\">$escaped_perm</span>
+                <button type=\"button\"
+                        spry-action=\":RemovePermission:$escaped_perm\"
+                        spry-target=\"perm-editor\"
+                        class=\"remove-tag\">×</button>
+            </span>";
+
+            return new InlineRenderable(doc);
+        }
+
+        private string escape_html(string text) {
+            // Use Markup.escape_text which properly escapes HTML entities
+            return GLib.Markup.escape_text(text);
+        }
+    }
+}

+ 392 - 0
src/Users/Components/UserFormComponent.vala

@@ -0,0 +1,392 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry.Users.Components {
+
+    /**
+     * UserFormComponent - Modal form for creating and editing users.
+     *
+     * This component provides:
+     * - Modal dialog for create/edit operations
+     * - Form fields: username, email, password (create only), confirm password
+     * - Validation with error messages
+     * - Permission editor integration
+     * - HTMX-based submission
+     *
+     * The component can operate in two modes:
+     * - Create mode: All fields including password are required
+     * - Edit mode: Password is optional; only set if changing
+     *
+     * Usage:
+     *   // Create mode:
+     *   var form = factory.create<UserFormComponent>();
+     *   form.show_create();
+     *
+     *   // Edit mode:
+     *   var form = factory.create<UserFormComponent>();
+     *   form.set_user(existing_user);
+     */
+    public class UserFormComponent : Component {
+
+        private UserService user_service = inject<UserService>();
+        private ComponentFactory factory = inject<ComponentFactory>();
+        private HttpContext http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // State Properties
+        // =========================================================================
+
+        private User? _editing_user = null;
+        private bool _is_visible = false;
+        private string? _error_message = null;
+        private string? _success_message = null;
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * Whether to display as a modal dialog.
+         * Default: true
+         */
+        public bool is_modal { get; set; default = true; }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Sets the user to edit and shows the form in edit mode.
+         *
+         * @param user The user to edit
+         */
+        public void set_user(User user) {
+            _editing_user = user;
+            _is_visible = true;
+            _error_message = null;
+            _success_message = null;
+        }
+
+        /**
+         * Shows the form in create mode.
+         */
+        public void show_create() {
+            _editing_user = null;
+            _is_visible = true;
+            _error_message = null;
+            _success_message = null;
+        }
+
+        /**
+         * Hides the form and clears state.
+         */
+        public void hide() {
+            _is_visible = false;
+            _editing_user = null;
+            _error_message = null;
+            _success_message = null;
+        }
+
+        /**
+         * Returns true if in create mode (not editing an existing user).
+         */
+        public bool is_creating {
+            get { return _editing_user == null; }
+        }
+
+        /**
+         * Returns true if the form is currently visible.
+         */
+        public bool visible {
+            get { return _is_visible; }
+        }
+
+        /**
+         * Gets the current error message, if any.
+         */
+        public string? error_message {
+            get { return _error_message; }
+        }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <div class="spry-user-form-container" sid="form-container" hx-swap="outerHTML">
+                <script spry-res="htmx.js"></script>
+
+                <!-- Hidden state (no modal visible) -->
+                <div spry-if="!this._is_visible" sid="hidden-state"></div>
+
+                <!-- Modal Overlay (shown when visible) -->
+                <div spry-if="this._is_visible" class="modal-overlay" sid="modal">
+                    <div class="modal-content" sid="modal-content">
+                        <div class="modal-header">
+                            <h3 content-expr="this.is_creating ? 'Create New User' : 'Edit User'"></h3>
+                            <button sid="close-btn" 
+                                    spry-action=":Cancel" 
+                                    spry-target="form-container"
+                                    class="close-btn">&times;</button>
+                        </div>
+
+                        <!-- Error Message -->
+                        <div spry-if="this._error_message != null" class="error alert" sid="error">
+                            <span content-expr="this._error_message"></span>
+                        </div>
+
+                        <!-- Form -->
+                        <form sid="form" spry-action=":Save" spry-target="form-container">
+                            <input type="hidden" name="user_id" sid="user-id"/>
+
+                            <div class="form-body">
+                                <!-- Username -->
+                                <div class="form-group">
+                                    <label for="username">Username *</label>
+                                    <input type="text" 
+                                           name="username" 
+                                           sid="form-username" 
+                                           required 
+                                           minlength="3" 
+                                           pattern="[a-zA-Z0-9_]+"
+                                           placeholder="Enter username"
+                                           class="form-input"/>
+                                    <small class="form-hint">Alphanumeric characters and underscores only, minimum 3 characters</small>
+                                </div>
+
+                                <!-- Email -->
+                                <div class="form-group">
+                                    <label for="email">Email *</label>
+                                    <input type="email" 
+                                           name="email" 
+                                           sid="form-email" 
+                                           required
+                                           placeholder="Enter email address"
+                                           class="form-input"/>
+                                </div>
+
+                                <!-- Password (Create Mode) -->
+                                <div class="form-group" spry-if="this.is_creating">
+                                    <label for="password">Password *</label>
+                                    <input type="password" 
+                                           name="password" 
+                                           sid="form-password"
+                                           required 
+                                           minlength="8"
+                                           placeholder="Enter password"
+                                           class="form-input"/>
+                                    <small class="form-hint">Minimum 8 characters</small>
+                                </div>
+
+                                <!-- Password (Edit Mode - Optional) -->
+                                <div class="form-group" spry-if="!this.is_creating">
+                                    <label for="new_password">New Password</label>
+                                    <input type="password" 
+                                           name="new_password" 
+                                           sid="form-new-password"
+                                           minlength="8"
+                                           placeholder="Leave blank to keep current password"
+                                           class="form-input"/>
+                                    <small class="form-hint">Leave blank to keep current password. Minimum 8 characters if changing.</small>
+                                </div>
+
+                                <!-- Permission Editor -->
+                                <div class="form-group">
+                                    <label>Permissions</label>
+                                    <spry-component name="PermissionEditorComponent" sid="permission-editor"/>
+                                </div>
+                            </div>
+
+                            <!-- Form Actions -->
+                            <div class="form-actions">
+                                <button type="submit" sid="save-btn" class="btn btn-primary">
+                                    <span spry-if="this.is_creating">Create User</span>
+                                    <span spry-else>Save Changes</span>
+                                </button>
+                                <button type="button" 
+                                        sid="cancel-btn" 
+                                        spry-action=":Cancel" 
+                                        spry-target="form-container"
+                                        class="btn btn-secondary">Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+            </div>
+            """;
+        }}
+
+        public override async void prepare() throws Error {
+            if (!_is_visible) {
+                return;
+            }
+
+            // Pre-populate form fields if editing
+            if (_editing_user != null) {
+                this["user-id"].set_attribute("value", _editing_user.id);
+                this["form-username"].set_attribute("value", _editing_user.username);
+                this["form-email"].set_attribute("value", _editing_user.email);
+
+                // Set up permission editor with user's current permissions
+                var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
+                perm_editor.set_permissions(_editing_user.permissions);
+            } else {
+                // Clear permission editor for create mode
+                var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
+                perm_editor.clear_all();
+            }
+        }
+
+        public async override void handle_action(string action) throws Error {
+            switch (action) {
+                case "Save":
+                    yield save_user();
+                    break;
+                case "Cancel":
+                    hide();
+                    break;
+            }
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private async void save_user() throws Error {
+            var query = http_context.request.query_params;
+
+            // Get form values
+            var user_id = get_query_value(query, "user_id");
+            var username = get_query_value(query, "username").strip();
+            var email = get_query_value(query, "email").strip();
+
+            // Validate username
+            if (username.length < 3) {
+                _error_message = "Username must be at least 3 characters";
+                return;
+            }
+
+            // Validate username format (alphanumeric + underscore)
+            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;
+            }
+
+            // Get permissions from editor
+            var perm_editor = get_component_child<PermissionEditorComponent>("permission-editor");
+            var permissions = perm_editor.get_selected();
+
+            try {
+                if (user_id.length == 0) {
+                    // Create new user
+                    var password = get_query_value(query, "password");
+
+                    if (password.length < 8) {
+                        _error_message = "Password must be at least 8 characters";
+                        return;
+                    }
+
+                    string? create_error;
+                    var user = user_service.create_user(username, email, password, out create_error);
+
+                    if (user == null) {
+                        _error_message = create_error ?? "Failed to create user";
+                        return;
+                    }
+
+                    // Set permissions if any were selected
+                    if (permissions.length > 0) {
+                        user.permissions = permissions;
+                        string? update_error;
+                        if (!user_service.update_user(user, out update_error)) {
+                            _error_message = update_error ?? "Failed to set permissions";
+                            return;
+                        }
+                    }
+
+                    _success_message = "User created successfully";
+                    hide();
+                } else {
+                    // Update existing user
+                    var user = user_service.get_user(user_id);
+                    if (user == null) {
+                        _error_message = "User not found";
+                        return;
+                    }
+
+                    // Update fields
+                    user.username = username;
+                    user.email = email;
+                    user.permissions = permissions;
+
+                    // Update password if provided
+                    var new_password = get_query_value(query, "new_password");
+                    if (new_password.length > 0) {
+                        if (new_password.length < 8) {
+                            _error_message = "New password must be at least 8 characters";
+                            return;
+                        }
+                        string? password_error;
+                        if (!user_service.set_password(user, new_password, out password_error)) {
+                            _error_message = password_error ?? "Failed to update password";
+                            return;
+                        }
+                    }
+
+                    // Save changes
+                    string? update_error;
+                    if (!user_service.update_user(user, out update_error)) {
+                        _error_message = update_error ?? "Failed to update user";
+                        return;
+                    }
+
+                    _success_message = "User updated successfully";
+                    hide();
+                }
+            } catch (Error e) {
+                _error_message = "Error: %s".printf(e.message);
+            }
+        }
+
+        private string get_query_value(Catalogue<string, string> query, string key) {
+            var value = query.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) {
+            // Basic email validation: contains @ and at least one . after @
+            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;
+        }
+    }
+}

+ 285 - 0
src/Users/Components/UserListComponent.vala

@@ -0,0 +1,285 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill.DataStructures;
+
+namespace Spry.Users.Components {
+
+    /**
+     * UserListComponent - Displays a list of users with search, filter, and pagination.
+     *
+     * This component provides:
+     * - Search by username/email
+     * - Filter by permission
+     * - Pagination controls
+     * - User table with UserListItemComponent for each row
+     *
+     * The component uses HTMX for dynamic updates without page reloads.
+     * Actions are handled locally (Search, ClearSearch, PrevPage, NextPage)
+     * while user management actions (Edit, Delete, ToggleActive) are delegated
+     * to the parent UserManagementPage.
+     *
+     * Usage:
+     *   <spry-component name="UserListComponent" sid="user-list"/>
+     */
+    public class UserListComponent : Component {
+
+        private UserService user_service = inject<UserService>();
+        private ComponentFactory factory = inject<ComponentFactory>();
+        private HttpContext http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // State Properties
+        // =========================================================================
+
+        private string _search_query = "";
+        private int _page = 0;
+        private int _page_size = 20;
+        private Vector<User> _users = new Vector<User>();
+        private int _total_count = 0;
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * Number of users per page.
+         * Default: 20
+         */
+        public int page_size {
+            get { return _page_size; }
+            set { _page_size = value; }
+        }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <div class="spry-user-list" sid="user-list" id="user-list-global" spry-global="user-list" hx-swap="outerHTML">
+                <script spry-res="htmx.js"></script>
+
+                <!-- Search Bar -->
+                <div class="search-bar" sid="search-bar">
+                    <form spry-action=":Search" spry-target="user-list" class="search-form">
+                        <input type="text" 
+                               name="search" 
+                               sid="search-input" 
+                               placeholder="Search users by username or email..."
+                               class="search-input"/>
+                        <button type="submit" sid="search-btn" class="btn btn-search">Search</button>
+                        <button type="button" 
+                                sid="clear-btn" 
+                                spry-action=":ClearSearch" 
+                                spry-target="user-list"
+                                class="btn btn-clear"
+                                spry-if="this._search_query.length > 0">Clear</button>
+                    </form>
+                </div>
+
+                <!-- User Table -->
+                <div class="table-container" sid="table-container">
+                    <table class="user-table" sid="table">
+                        <thead>
+                            <tr>
+                                <th>Username</th>
+                                <th>Email</th>
+                                <th>Status</th>
+                                <th>Created</th>
+                                <th>Last Login</th>
+                                <th>Permissions</th>
+                                <th>Actions</th>
+                            </tr>
+                        </thead>
+                        <tbody sid="table-body">
+                            <spry-outlet sid="users"/>
+                        </tbody>
+                    </table>
+                </div>
+
+                <!-- Pagination -->
+                <div class="pagination" sid="pagination" spry-if="this._total_count > this._page_size">
+                    <button sid="prev-btn" 
+                            spry-action=":PrevPage" 
+                            spry-target="user-list"
+                            class="btn btn-prev"
+                            disabled-expr="this._page == 0 ? 'disabled' : null">
+                        ← Previous
+                    </button>
+                    <span class="page-info" content-expr="format('Page %d of %d', this._page + 1, this.get_total_pages())"></span>
+                    <button sid="next-btn"
+                            spry-action=":NextPage"
+                            spry-target="user-list"
+                            class="btn btn-next"
+                            disabled-expr="(this._page + 1) * this._page_size >= this._total_count ? 'disabled' : null">
+                        Next →
+                    </button>
+                </div>
+
+                <!-- Empty State -->
+                <div spry-if="this._users.length == 0" class="empty-state" sid="empty">
+                    <div class="empty-state-content">
+                        <h3>No users found</h3>
+                        <p spry-if="this._search_query.length > 0">
+                            No users match your search "<span content-expr="this._search_query"></span>"
+                        </p>
+                        <p spry-else>There are no users in the system yet.</p>
+                    </div>
+                </div>
+
+                <!-- User Count -->
+                <div class="user-count" sid="user-count">
+                    <span content-expr="format('Showing %d of %d users', this._users.length, this._total_count)"></span>
+                </div>
+            </div>
+            """;
+        }}
+
+        public override async void prepare() throws Error {
+            // Load users based on current search and page
+            load_users();
+
+            // Create user item components
+            var items = new Series<Renderable>();
+            foreach (var user in _users) {
+                var item = factory.create<UserListItemComponent>();
+                item.set_user(user);
+                item.show_actions = true;
+                items.add(item);
+            }
+            set_outlet_children("users", items);
+
+            // Preserve search query in input
+            if (_search_query.length > 0) {
+                this["search-input"].set_attribute("value", _search_query);
+            }
+        }
+
+        public async override void handle_action(string action) throws Error {
+            var query = http_context.request.query_params;
+
+            switch (action) {
+                case "Search":
+                    var search_value = query.get_any_or_default("search");
+                    _search_query = search_value != null ? ((!)search_value).strip() : "";
+                    _page = 0; // Reset to first page on new search
+                    break;
+
+                case "ClearSearch":
+                    _search_query = "";
+                    _page = 0;
+                    break;
+
+                case "PrevPage":
+                    if (_page > 0) {
+                        _page--;
+                    }
+                    break;
+
+                case "NextPage":
+                    if ((_page + 1) * _page_size < _total_count) {
+                        _page++;
+                    }
+                    break;
+            }
+        }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Refreshes the user list (useful after external changes).
+         */
+        public void refresh() {
+            load_users();
+        }
+
+        /**
+         * Gets the current search query.
+         *
+         * @return The current search query string
+         */
+        public string get_search_query() {
+            return _search_query;
+        }
+
+        /**
+         * Sets the search query programmatically.
+         *
+         * @param query The search query to set
+         */
+        public void set_search_query(string query) {
+            _search_query = query;
+            _page = 0;
+        }
+
+        /**
+         * Gets the current page number (0-indexed).
+         *
+         * @return The current page number
+         */
+        public int get_page() {
+            return _page;
+        }
+
+        /**
+         * Sets the current page number (0-indexed).
+         *
+         * @param page The page number to set
+         */
+        public void set_page(int page) {
+            _page = page;
+        }
+
+        /**
+         * Gets the total number of pages.
+         *
+         * @return The total number of pages
+         */
+        public int get_total_pages() {
+            if (_total_count == 0) return 1;
+            return (_total_count + _page_size - 1) / _page_size;
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private void load_users() {
+            // Get total count first
+            _total_count = user_service.user_count();
+
+            if (_search_query.length > 0) {
+                // For search, we need to filter users
+                // Current UserService doesn't have search_users, so we load all and filter
+                // Future optimization: Add search_users method to UserService
+                var all_users = user_service.list_users(0, 1000); // Load all for filtering
+                _users = new Vector<User>();
+
+                var query_lower = _search_query.down();
+                foreach (var user in all_users) {
+                    if (user.username.down().contains(query_lower) ||
+                        user.email.down().contains(query_lower)) {
+                        _users.add(user);
+                    }
+                }
+
+                // Apply pagination to filtered results
+                _total_count = (int)_users.length;
+                var paginated = new Vector<User>();
+                int start = _page * _page_size;
+                int end = int.min(start + _page_size, (int)_users.length);
+
+                for (int i = start; i < end; i++) {
+                    paginated.add(_users.get(i));
+                }
+                _users = paginated;
+            } else {
+                // Load users with pagination
+                _users = user_service.list_users(_page * _page_size, _page_size);
+            }
+        }
+    }
+}

+ 179 - 0
src/Users/Components/UserListItemComponent.vala

@@ -0,0 +1,179 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill.DataStructures;
+
+namespace Spry.Users.Components {
+
+    /**
+     * UserListItemComponent - Individual user row with inline action buttons.
+     *
+     * This component displays a single user in a table row format with:
+     * - Username and email display
+     * - Status badges (active/inactive, verified)
+     * - Permission badges
+     * - Action buttons: Edit, Toggle Active, Delete
+     *
+     * Actions are delegated to the parent UserManagementPage using the
+     * cross-component action pattern (spry-action="UserManagementPage:ActionName").
+     *
+     * Usage:
+     *   var item = factory.create<UserListItemComponent>();
+     *   item.set_user(user);
+     *   item.show_actions = true;
+     */
+    public class UserListItemComponent : Component {
+
+        private User _user;
+
+        // =========================================================================
+        // Configuration Properties
+        // =========================================================================
+
+        /**
+         * Whether to show action buttons.
+         * Default: true
+         */
+        public bool show_actions { get; set; default = true; }
+
+        // =========================================================================
+        // Public API
+        // =========================================================================
+
+        /**
+         * Sets the user to display in this row.
+         *
+         * @param user The user to display
+         */
+        public void set_user(User user) {
+            _user = user;
+        }
+
+        /**
+         * Gets the user displayed in this row.
+         *
+         * @return The user being displayed
+         */
+        public User get_user() {
+            return _user;
+        }
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <tr class="user-row" sid="user-row">
+                <td class="username" sid="username"></td>
+                <td class="email" sid="email"></td>
+                <td class="status" sid="status">
+                    <span sid="active-badge" class="badge badge-active">Active</span>
+                    <span sid="inactive-badge" class="badge badge-inactive">Inactive</span>
+                </td>
+                <td class="created" sid="created"></td>
+                <td class="last-login" sid="last-login">Never</td>
+                <td class="permissions" sid="permissions">
+                    <spry-outlet sid="permission-badges"/>
+                </td>
+                <td class="actions" sid="actions">
+                    <button sid="edit-btn" 
+                            spry-action="UserManagementPage:EditUser"
+                            hx-target="#user-form-container" 
+                            hx-swap="innerHTML"
+                            class="btn btn-sm btn-edit">
+                        Edit
+                    </button>
+                    <button sid="toggle-btn" 
+                            spry-action="UserManagementPage:ToggleActive"
+                            hx-target="#user-list-global" 
+                            hx-swap="outerHTML"
+                            class="btn btn-sm btn-toggle">
+                        <span sid="toggle-text">Deactivate</span>
+                    </button>
+                    <button sid="delete-btn" 
+                            spry-action="UserManagementPage:DeleteUser"
+                            hx-target="#user-list-global" 
+                            hx-swap="outerHTML"
+                            class="btn btn-sm btn-delete">
+                        Delete
+                    </button>
+                </td>
+            </tr>
+            """;
+        }}
+
+        public override async void prepare() throws Error {
+            if (_user == null) {
+                // Hide the row if no user is set
+                this["user-row"].set_attribute("spry-hidden", "true");
+                return;
+            }
+
+            // Populate user information
+            this["username"].text_content = _user.username;
+            this["email"].text_content = _user.email;
+            this["created"].text_content = _user.created_at.format("%Y-%m-%d");
+
+            // Note: Current User model doesn't have last_login_at, but we can check updated_at
+            // For future compatibility, we'll display "Never" for now
+            this["last-login"].text_content = "Never";
+
+            // Set user ID on action buttons for cross-component actions
+            // Using hx-vals to pass the user_id parameter
+            var user_id_json = @"{\"user_id\":\"$(_user.id)\"}";
+            this["user-row"].set_attribute("hx-vals", user_id_json);
+
+            // Show/hide action buttons based on configuration
+            if (!show_actions) {
+                this["actions"].set_attribute("spry-hidden", "true");
+            }
+
+            // Create permission badges
+            var badges = new Series<Renderable>();
+            foreach (var permission in _user.permissions) {
+                var badge = create_permission_badge(permission);
+                badges.add(badge);
+            }
+            set_outlet_children("permission-badges", badges);
+
+            // Note: Active/inactive status display would require is_active field on User model
+            // For now, we show both badges and hide one based on a default
+            // Future: Add is_active to User model
+            this["inactive-badge"].set_attribute("spry-hidden", "true");
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private Renderable create_permission_badge(string permission) {
+            // Create a simple inline renderable for the permission badge
+            var doc = new MarkupDocument();
+            var escaped = escape_html(permission);
+            doc.body.inner_html = @"<span class=\"badge badge-permission\">$escaped</span>";
+            return new InlineRenderable(doc);
+        }
+
+        private string escape_html(string text) {
+            // Use Markup.escape_text which properly escapes HTML entities
+            return GLib.Markup.escape_text(text);
+        }
+    }
+
+    /**
+     * Helper class for inline HTML rendering.
+     * Used for dynamically generated permission badges.
+     */
+    internal class InlineRenderable : GLib.Object, Renderable {
+        private MarkupDocument _doc;
+
+        public InlineRenderable(MarkupDocument doc) {
+            _doc = doc;
+        }
+
+        public async MarkupDocument to_document() throws Error {
+            return _doc;
+        }
+    }
+}

+ 490 - 0
src/Users/Components/UserManagementPage.vala

@@ -0,0 +1,490 @@
+using Spry;
+using Inversion;
+using Astralis;
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Spry.Users.Components {
+
+    /**
+     * UserManagementPage - PageComponent orchestrating all user management components.
+     *
+     * This page provides a complete user management interface that includes:
+     * - Permission check: Only accessible with "user-management" permission
+     * - User list with search, filter, and pagination
+     * - User form modal for create/edit operations
+     * - Status messages (success/error alerts)
+     *
+     * Cross-Component Communication:
+     * - Child components trigger actions via "UserManagementPage:ActionName" pattern
+     * - The page handles these actions and updates child components accordingly
+     * - Uses add_globals_from() to share globals with child components
+     *
+     * Required Permission: "user-management"
+     *
+     * Usage:
+     *   // Register as a page:
+     *   spry_cfg.add_page<UserManagementPage>(new EndpointRoute("/admin/users"));
+     */
+    public class UserManagementPage : PageComponent {
+
+        private PermissionService permission_service = inject<PermissionService>();
+        private UserService user_service = inject<UserService>();
+        private SessionService session_service = inject<SessionService>();
+        private ComponentFactory factory = inject<ComponentFactory>();
+        private HttpContext http_context = inject<HttpContext>();
+
+        // =========================================================================
+        // State Properties
+        // =========================================================================
+
+        private string? _success_message = null;
+        private string? _error_message = null;
+        private bool _access_denied = false;
+
+        // =========================================================================
+        // Component Implementation
+        // =========================================================================
+
+        public override string markup { get {
+            return """
+            <!DOCTYPE html>
+            <html lang="en">
+            <head>
+                <meta charset="UTF-8"/>
+                <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+                <title>User Management - Admin</title>
+                <script spry-res="htmx.js"></script>
+                <style>
+                    /* Basic admin styles */
+                    * { box-sizing: border-box; }
+                    body {
+                        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+                        margin: 0;
+                        padding: 0;
+                        background: #f5f5f5;
+                        color: #333;
+                    }
+                    .admin-container {
+                        max-width: 1200px;
+                        margin: 0 auto;
+                        padding: 2rem;
+                    }
+                    .admin-header {
+                        display: flex;
+                        justify-content: space-between;
+                        align-items: center;
+                        margin-bottom: 2rem;
+                        padding-bottom: 1rem;
+                        border-bottom: 1px solid #e0e0e0;
+                    }
+                    .admin-header h1 {
+                        margin: 0;
+                        font-size: 1.75rem;
+                        color: #333;
+                    }
+                    .btn {
+                        display: inline-block;
+                        padding: 0.5rem 1rem;
+                        border: none;
+                        border-radius: 4px;
+                        cursor: pointer;
+                        font-size: 0.875rem;
+                        font-weight: 500;
+                        text-decoration: none;
+                        transition: background-color 0.2s;
+                    }
+                    .btn-primary { background: #007bff; color: white; }
+                    .btn-primary:hover { background: #0056b3; }
+                    .btn-secondary { background: #6c757d; color: white; }
+                    .btn-secondary:hover { background: #545b62; }
+                    .btn-edit { background: #17a2b8; color: white; }
+                    .btn-delete { background: #dc3545; color: white; }
+                    .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
+                    .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+                    /* Alerts */
+                    .alert {
+                        padding: 1rem;
+                        border-radius: 4px;
+                        margin-bottom: 1rem;
+                    }
+                    .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
+                    .alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
+
+                    /* Access Denied */
+                    .access-denied {
+                        text-align: center;
+                        padding: 4rem 2rem;
+                    }
+                    .access-denied h2 { color: #dc3545; }
+
+                    /* Modal */
+                    .modal-overlay {
+                        position: fixed;
+                        top: 0;
+                        left: 0;
+                        right: 0;
+                        bottom: 0;
+                        background: rgba(0,0,0,0.5);
+                        display: flex;
+                        align-items: center;
+                        justify-content: center;
+                        z-index: 1000;
+                    }
+                    .modal-content {
+                        background: white;
+                        padding: 2rem;
+                        border-radius: 8px;
+                        max-width: 500px;
+                        width: 90%;
+                        max-height: 90vh;
+                        overflow-y: auto;
+                        box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+                    }
+                    .modal-header {
+                        display: flex;
+                        justify-content: space-between;
+                        align-items: center;
+                        margin-bottom: 1.5rem;
+                        padding-bottom: 1rem;
+                        border-bottom: 1px solid #e0e0e0;
+                    }
+                    .modal-header h3 { margin: 0; }
+                    .close-btn {
+                        background: none;
+                        border: none;
+                        font-size: 1.5rem;
+                        cursor: pointer;
+                        color: #666;
+                    }
+                    .close-btn:hover { color: #333; }
+
+                    /* Form */
+                    .form-group { margin-bottom: 1rem; }
+                    .form-group label {
+                        display: block;
+                        margin-bottom: 0.25rem;
+                        font-weight: 500;
+                    }
+                    .form-input {
+                        width: 100%;
+                        padding: 0.5rem;
+                        border: 1px solid #ccc;
+                        border-radius: 4px;
+                        font-size: 1rem;
+                    }
+                    .form-input:focus { outline: none; border-color: #007bff; }
+                    .form-hint { font-size: 0.75rem; color: #666; margin-top: 0.25rem; }
+                    .form-actions {
+                        display: flex;
+                        gap: 0.5rem;
+                        margin-top: 1.5rem;
+                        padding-top: 1rem;
+                        border-top: 1px solid #e0e0e0;
+                    }
+
+                    /* Badges */
+                    .badge {
+                        display: inline-block;
+                        padding: 0.25rem 0.5rem;
+                        border-radius: 4px;
+                        font-size: 0.75rem;
+                        margin-right: 0.25rem;
+                        margin-bottom: 0.25rem;
+                    }
+                    .badge-active { background: #d4edda; color: #155724; }
+                    .badge-inactive { background: #f8d7da; color: #721c24; }
+                    .badge-permission { background: #e9ecef; color: #495057; }
+
+                    /* Table */
+                    .table-container { overflow-x: auto; }
+                    .user-table {
+                        width: 100%;
+                        border-collapse: collapse;
+                        background: white;
+                        border-radius: 4px;
+                        box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+                    }
+                    .user-table th,
+                    .user-table td {
+                        padding: 0.75rem;
+                        text-align: left;
+                        border-bottom: 1px solid #e0e0e0;
+                    }
+                    .user-table th {
+                        background: #f8f9fa;
+                        font-weight: 600;
+                    }
+                    .user-table tr:hover { background: #f8f9fa; }
+
+                    /* Search */
+                    .search-bar { margin-bottom: 1rem; }
+                    .search-form { display: flex; gap: 0.5rem; }
+                    .search-input {
+                        flex: 1;
+                        padding: 0.5rem;
+                        border: 1px solid #ccc;
+                        border-radius: 4px;
+                    }
+                    .btn-search { background: #28a745; color: white; }
+                    .btn-clear { background: #6c757d; color: white; }
+
+                    /* Pagination */
+                    .pagination {
+                        display: flex;
+                        justify-content: center;
+                        align-items: center;
+                        gap: 1rem;
+                        margin-top: 1rem;
+                    }
+                    .page-info { color: #666; }
+
+                    /* Empty State */
+                    .empty-state {
+                        text-align: center;
+                        padding: 3rem;
+                        background: white;
+                        border-radius: 4px;
+                    }
+                    .user-count {
+                        text-align: center;
+                        color: #666;
+                        margin-top: 1rem;
+                        font-size: 0.875rem;
+                    }
+
+                    /* Permission Editor */
+                    .permission-checkbox {
+                        display: inline-flex;
+                        align-items: center;
+                        margin-right: 1rem;
+                        margin-bottom: 0.5rem;
+                    }
+                    .permission-checkbox input { margin-right: 0.5rem; }
+                    .permission-tag {
+                        display: inline-flex;
+                        align-items: center;
+                        background: #e9ecef;
+                        padding: 0.25rem 0.5rem;
+                        border-radius: 4px;
+                        margin: 0.25rem;
+                    }
+                    .permission-tag .remove-tag {
+                        background: none;
+                        border: none;
+                        margin-left: 0.5rem;
+                        cursor: pointer;
+                        color: #666;
+                        font-size: 1rem;
+                        line-height: 1;
+                    }
+                    .permission-tag .remove-tag:hover { color: #dc3545; }
+                    .permission-input {
+                        padding: 0.5rem;
+                        border: 1px solid #ccc;
+                        border-radius: 4px;
+                    }
+                    .btn-add { background: #28a745; color: white; }
+                    .help-text { color: #666; font-size: 0.75rem; margin-top: 0.5rem; display: block; }
+                    .permission-group { margin-bottom: 1rem; }
+                    .permission-group h4 { margin-bottom: 0.5rem; font-size: 0.875rem; }
+                </style>
+            </head>
+            <body>
+                <div class="admin-container" sid="admin-container">
+                    <!-- Access Denied Message -->
+                    <div spry-if="this._access_denied" class="access-denied" sid="access-denied">
+                        <h2>Access Denied</h2>
+                        <p>You do not have permission to access the user management page.</p>
+                        <p>Please contact an administrator if you believe this is an error.</p>
+                    </div>
+
+                    <!-- Main Content (shown when authorized) -->
+                    <div spry-if="!this._access_denied">
+                        <header class="admin-header">
+                            <h1>User Management</h1>
+                            <div class="header-actions">
+                                <button sid="create-btn"
+                                        spry-action=":CreateUser"
+                                        hx-target="#user-form-container"
+                                        hx-swap="innerHTML"
+                                        class="btn btn-primary">
+                                    + Create User
+                                </button>
+                            </div>
+                        </header>
+
+                        <!-- Success Message -->
+                        <div spry-if="this._success_message != null"
+                             class="alert alert-success"
+                             sid="success-alert"
+                             spry-global="success-alert">
+                            <span content-expr="this._success_message"></span>
+                        </div>
+
+                        <!-- Error Message -->
+                        <div spry-if="this._error_message != null"
+                             class="alert alert-error"
+                             sid="error-alert"
+                             spry-global="error-alert">
+                            <span content-expr="this._error_message"></span>
+                        </div>
+
+                        <!-- User List Component -->
+                        <spry-component name="UserListComponent" sid="user-list"/>
+
+                        <!-- User Form Container (for modal) -->
+                        <div id="user-form-container" sid="user-form-container">
+                            <spry-component name="UserFormComponent" sid="user-form"/>
+                        </div>
+                    </div>
+                </div>
+            </body>
+            </html>
+            """;
+        }}
+
+        public override async void prepare() throws Error {
+            // Check permission
+            var auth_result = session_service.authenticate_request(http_context, user_service);
+
+            if (!auth_result.is_authenticated) {
+                _access_denied = true;
+                return;
+            }
+
+            var current_user = auth_result.user;
+            if (current_user == null) {
+                _access_denied = true;
+                return;
+            }
+
+            // Check for user-management permission
+            if (!permission_service.has_permission(current_user, PermissionService.USER_MANAGEMENT)) {
+                _access_denied = true;
+                return;
+            }
+
+            _access_denied = false;
+
+            // Ensure form is hidden initially
+            var user_form = get_component_child<UserFormComponent>("user-form");
+            user_form.hide();
+        }
+
+        public async override void handle_action(string action) throws Error {
+            // Don't process actions if access is denied
+            if (_access_denied) {
+                return;
+            }
+
+            var query = http_context.request.query_params;
+
+            switch (action) {
+                case "CreateUser":
+                    handle_create_user();
+                    break;
+
+                case "EditUser":
+                    var user_id = get_query_value(query, "user_id");
+                    handle_edit_user(user_id);
+                    break;
+
+                case "ToggleActive":
+                    var user_id = get_query_value(query, "user_id");
+                    yield handle_toggle_active(user_id);
+                    break;
+
+                case "DeleteUser":
+                    var user_id = get_query_value(query, "user_id");
+                    yield handle_delete_user(user_id);
+                    break;
+            }
+        }
+
+        // =========================================================================
+        // Action Handlers
+        // =========================================================================
+
+        private void handle_create_user() throws Error {
+            var user_form = get_component_child<UserFormComponent>("user-form");
+            user_form.show_create();
+            _success_message = null;
+            _error_message = null;
+        }
+
+        private void handle_edit_user(string user_id) throws Error {
+            if (user_id.length == 0) {
+                _error_message = "Invalid user ID";
+                return;
+            }
+
+            var user = user_service.get_user(user_id);
+            if (user == null) {
+                _error_message = "User not found";
+                return;
+            }
+
+            var user_form = get_component_child<UserFormComponent>("user-form");
+            user_form.set_user(user);
+            _success_message = null;
+            _error_message = null;
+        }
+
+        private async void handle_toggle_active(string user_id) throws Error {
+            // Note: Current User model doesn't have is_active field
+            // This is a placeholder for future implementation
+            _error_message = "Toggle active functionality not yet implemented";
+            _success_message = null;
+
+            // Refresh the list
+            var user_list = get_component_child<UserListComponent>("user-list");
+            add_globals_from(user_list);
+        }
+
+        private async void handle_delete_user(string user_id) throws Error {
+            if (user_id.length == 0) {
+                _error_message = "Invalid user ID";
+                return;
+            }
+
+            // Prevent self-deletion
+            var auth_result = session_service.authenticate_request(http_context, user_service);
+            if (auth_result.is_authenticated && auth_result.user != null) {
+                if (auth_result.user.id == user_id) {
+                    _error_message = "Cannot delete your own account";
+                    _success_message = null;
+
+                    // Refresh the list
+                    var user_list = get_component_child<UserListComponent>("user-list");
+                    add_globals_from(user_list);
+                    return;
+                }
+            }
+
+            // Delete the user
+            string? delete_error;
+            if (!user_service.delete_user(user_id, out delete_error)) {
+                _error_message = delete_error ?? "Failed to delete user";
+                _success_message = null;
+            } else {
+                _success_message = "User deleted successfully";
+                _error_message = null;
+            }
+
+            // Refresh the list
+            var user_list = get_component_child<UserListComponent>("user-list");
+            add_globals_from(user_list);
+        }
+
+        // =========================================================================
+        // Private Helpers
+        // =========================================================================
+
+        private string get_query_value(Catalogue<string, string> query, string key) {
+            var value = query.get_any_or_default(key);
+            return value != null ? ((!)value).strip() : "";
+        }
+    }
+}

+ 284 - 0
src/Users/PermissionService.vala

@@ -0,0 +1,284 @@
+using Invercargill.DataStructures;
+
+namespace Spry.Users {
+
+    /**
+     * PermissionService handles granular permissions keyed by string, with wildcard support.
+     *
+     * Features:
+     * - Check if user has exact or wildcard permission
+     * - Set and clear permissions on users
+     * - Support for "admin" super-user permission
+     * - Wildcard matching with trailing "*" (e.g., "user-*" matches "user-create")
+     */
+    public class PermissionService : GLib.Object {
+
+        // =========================================================================
+        // Permission Constants
+        // =========================================================================
+
+        /** Permission for general user management access */
+        public const string USER_MANAGEMENT = "user-management";
+
+        /** Permission to create new users */
+        public const string USER_CREATE = "user-create";
+
+        /** Permission to read/view user details */
+        public const string USER_READ = "user-read";
+
+        /** Permission to update existing users */
+        public const string USER_UPDATE = "user-update";
+
+        /** Permission to delete users */
+        public const string USER_DELETE = "user-delete";
+
+        /** Super-user permission that grants all other permissions */
+        public const string ADMIN = "admin";
+
+        // =========================================================================
+        // Dependencies
+        // =========================================================================
+
+        private UserService _user_service;
+
+        /**
+         * Creates a new PermissionService instance.
+         *
+         * @param user_service The UserService for user operations
+         */
+        public PermissionService(UserService user_service) {
+            _user_service = user_service;
+        }
+
+        // =========================================================================
+        // Permission Checking
+        // =========================================================================
+
+        /**
+         * Checks if a user has a specific permission.
+         *
+         * This method:
+         * - Returns true if user has "admin" permission (super-user)
+         * - Checks for exact permission match
+         * - Supports wildcard matching (e.g., "user-*" matches "user-create")
+         *
+         * @param user The user to check
+         * @param permission The permission to check for
+         * @return true if the user has the permission, false otherwise
+         */
+        public bool has_permission(User user, string permission) {
+            // Check each permission the user has
+            foreach (var user_perm in user.permissions) {
+                // Admin has all permissions
+                if (user_perm == ADMIN) {
+                    return true;
+                }
+
+                // Check for exact match
+                if (user_perm == permission) {
+                    return true;
+                }
+
+                // Check wildcard match
+                if (permission_matches(user_perm, permission)) {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        /**
+         * Checks if a user (by ID) has a specific permission.
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to check for
+         * @return true if the user has the permission, false otherwise
+         */
+        public bool has_permission_by_id(string user_id, string permission) {
+            var user = _user_service.get_user(user_id);
+            if (user == null) {
+                return false;
+            }
+            return has_permission((!)user, permission);
+        }
+
+        // =========================================================================
+        // Permission Setting
+        // =========================================================================
+
+        /**
+         * Sets (adds) a permission for a user.
+         *
+         * This method:
+         * - Adds the permission if not already present
+         * - Persists changes via UserService.update_user()
+         *
+         * @param user The user to update
+         * @param permission The permission to add
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool set_permission(User user, string permission, out string? error = null) {
+            error = null;
+
+            // Check if permission already exists
+            foreach (var existing_perm in user.permissions) {
+                if (existing_perm == permission) {
+                    // Already has this permission
+                    return true;
+                }
+            }
+
+            // Add the permission
+            user.permissions.add(permission);
+
+            // Persist changes
+            return _user_service.update_user(user, out error);
+        }
+
+        /**
+         * Sets (adds) a permission for a user (by ID).
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to add
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool set_permission_by_id(string user_id, string permission, out string? error = null) {
+            error = null;
+
+            var user = _user_service.get_user(user_id);
+            if (user == null) {
+                error = "User not found";
+                return false;
+            }
+
+            return set_permission((!)user, permission, out error);
+        }
+
+        // =========================================================================
+        // Permission Clearing
+        // =========================================================================
+
+        /**
+         * Clears (removes) a permission from a user.
+         *
+         * This method:
+         * - Removes the permission if present
+         * - Persists changes via UserService.update_user()
+         *
+         * @param user The user to update
+         * @param permission The permission to remove
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool clear_permission(User user, string permission, out string? error = null) {
+            error = null;
+
+            // Remove the permission
+            user.permissions.remove(permission);
+
+            // Persist changes
+            return _user_service.update_user(user, out error);
+        }
+
+        /**
+         * Clears (removes) a permission from a user (by ID).
+         *
+         * @param user_id The user's unique identifier
+         * @param permission The permission to remove
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool clear_permission_by_id(string user_id, string permission, out string? error = null) {
+            error = null;
+
+            var user = _user_service.get_user(user_id);
+            if (user == null) {
+                error = "User not found";
+                return false;
+            }
+
+            return clear_permission((!)user, permission, out error);
+        }
+
+        /**
+         * Clears all permissions from a user.
+         *
+         * @param user The user to update
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool clear_all_permissions(User user, out string? error = null) {
+            error = null;
+
+            // Clear all permissions
+            user.permissions.clear();
+
+            // Persist changes
+            return _user_service.update_user(user, out error);
+        }
+
+        // =========================================================================
+        // Permission Retrieval
+        // =========================================================================
+
+        /**
+         * Gets all permissions for a user.
+         *
+         * @param user The user to get permissions for
+         * @return A Vector of permission strings
+         */
+        public Vector<string> get_permissions(User user) {
+            // Return a copy of the permissions vector
+            var result = new Vector<string>();
+            foreach (var perm in user.permissions) {
+                result.add(perm);
+            }
+            return result;
+        }
+
+        // =========================================================================
+        // Wildcard Matching
+        // =========================================================================
+
+        /**
+         * Checks if a permission pattern matches a specific permission.
+         *
+         * Wildcard matching rules:
+         * - "*" matches everything
+         * - "prefix-*" matches any permission starting with "prefix-"
+         * - Without wildcard, requires exact match
+         *
+         * Examples:
+         * - permission_matches("*", "anything") → true
+         * - permission_matches("user-*", "user-create") → true
+         * - permission_matches("user-*", "user-delete") → true
+         * - permission_matches("user-*", "admin") → false
+         * - permission_matches("user-create", "user-create") → true
+         *
+         * @param pattern The pattern to match against (may contain wildcard)
+         * @param permission The permission to check
+         * @return true if the pattern matches the permission
+         */
+        public bool permission_matches(string pattern, string permission) {
+            // "*" matches everything
+            if (pattern == "*") {
+                return true;
+            }
+
+            // Check for trailing wildcard
+            if (pattern.has_suffix("*")) {
+                // Get the prefix before the wildcard
+                string prefix = pattern.substring(0, pattern.length - 1);
+
+                // Check if permission starts with the prefix
+                return permission.has_prefix(prefix);
+            }
+
+            // No wildcard - exact match required (handled by caller, but include here for completeness)
+            return pattern == permission;
+        }
+    }
+}

+ 90 - 0
src/Users/Session.vala

@@ -0,0 +1,90 @@
+using Invercargill.DataStructures;
+using Json;
+
+namespace Spry.Users {
+
+    public class Session : GLib.Object {
+        // Identity
+        public string id { get; set; }
+        public string user_id { get; set; }
+
+        // Timing
+        public DateTime created_at { get; set; }
+        public DateTime expires_at { get; set; }
+
+        // Optional tracking
+        public string? ip_address { get; set; }
+        public string? user_agent { get; set; }
+
+        public Session() {
+            id = "";
+            user_id = "";
+            created_at = new DateTime.now_utc();
+            expires_at = new DateTime.now_utc();
+        }
+
+        public bool is_expired() {
+            return expires_at.compare(new DateTime.now_utc()) <= 0;
+        }
+
+        public static Session from_json(Json.Object obj) {
+            var session = new Session();
+
+            session.id = obj.get_string_member("id");
+            session.user_id = obj.get_string_member("user_id");
+
+            // created_at
+            if (obj.has_member("created_at")) {
+                session.created_at = new DateTime.from_iso8601(
+                    obj.get_string_member("created_at"),
+                    new TimeZone.utc()
+                );
+            }
+
+            // expires_at
+            if (obj.has_member("expires_at")) {
+                session.expires_at = new DateTime.from_iso8601(
+                    obj.get_string_member("expires_at"),
+                    new TimeZone.utc()
+                );
+            }
+
+            // ip_address (optional)
+            if (obj.has_member("ip_address") && !obj.get_null_member("ip_address")) {
+                session.ip_address = obj.get_string_member("ip_address");
+            }
+
+            // user_agent (optional)
+            if (obj.has_member("user_agent") && !obj.get_null_member("user_agent")) {
+                session.user_agent = obj.get_string_member("user_agent");
+            }
+
+            return session;
+        }
+
+        public Json.Object to_json() {
+            var obj = new Json.Object();
+
+            obj.set_string_member("id", id);
+            obj.set_string_member("user_id", user_id);
+            obj.set_string_member("created_at", created_at.format_iso8601());
+            obj.set_string_member("expires_at", expires_at.format_iso8601());
+
+            // ip_address (optional)
+            if (ip_address != null) {
+                obj.set_string_member("ip_address", (!)ip_address);
+            } else {
+                obj.set_null_member("ip_address");
+            }
+
+            // user_agent (optional)
+            if (user_agent != null) {
+                obj.set_string_member("user_agent", (!)user_agent);
+            } else {
+                obj.set_null_member("user_agent");
+            }
+
+            return obj;
+        }
+    }
+}

+ 941 - 0
src/Users/SessionService.vala

@@ -0,0 +1,941 @@
+using Implexus.Core;
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Json;
+using Astralis;
+
+namespace Spry.Users {
+
+    /**
+     * Error domain for session-related operations.
+     */
+    public errordomain SessionError {
+        SESSION_NOT_FOUND,
+        SESSION_EXPIRED,
+        INVALID_SESSION_TOKEN,
+        COOKIE_NOT_FOUND,
+        STORAGE_ERROR
+    }
+
+    /**
+     * Result of session token validation containing session and user info.
+     */
+    public class SessionValidationResult : GLib.Object {
+        /**
+         * Whether the session token was successfully validated.
+         */
+        public bool is_valid { get; set; }
+
+        /**
+         * The session object if validation was successful.
+         */
+        public Session? session { get; set; }
+
+        /**
+         * The user object if validation was successful.
+         */
+        public User? user { get; set; }
+
+        /**
+         * Error message describing why validation failed.
+         */
+        public string? error_message { get; set; }
+
+        /**
+         * Creates a successful validation result.
+         */
+        public SessionValidationResult.success(Session session, User? user = null) {
+            GLib.Object(
+                is_valid: true,
+                session: session,
+                user: user,
+                error_message: null
+            );
+        }
+
+        /**
+         * Creates a failed validation result.
+         */
+        public SessionValidationResult.failure(string error_message) {
+            GLib.Object(
+                is_valid: false,
+                session: null,
+                user: null,
+                error_message: error_message
+            );
+        }
+    }
+
+    /**
+     * Result of authenticating a request.
+     */
+    public class AuthResult : GLib.Object {
+        /**
+         * Whether the request was successfully authenticated.
+         */
+        public bool is_authenticated { get; set; }
+
+        /**
+         * The authenticated user, or null if not authenticated.
+         */
+        public User? user { get; set; }
+
+        /**
+         * The session associated with the request, or null if not authenticated.
+         */
+        public Session? session { get; set; }
+
+        /**
+         * Error message describing why authentication failed.
+         */
+        public string? error_message { get; set; }
+
+        /**
+         * Creates a successful authentication result.
+         */
+        public AuthResult.success(User user, Session session) {
+            GLib.Object(
+                is_authenticated: true,
+                user: user,
+                session: session,
+                error_message: null
+            );
+        }
+
+        /**
+         * Creates a failed authentication result.
+         */
+        public AuthResult.failure(string error_message) {
+            GLib.Object(
+                is_authenticated: false,
+                user: null,
+                session: null,
+                error_message: error_message
+            );
+        }
+    }
+
+    /**
+     * SessionService handles session creation, validation, cookie management, and cleanup.
+     *
+     * Storage paths:
+     * - Sessions: /spry/users/sessions/{session_id}
+     * - User sessions index: /spry/users/sessions_by_user/{user_id} → stores list of session_ids
+     *
+     * Cookie Configuration:
+     * - Cookie name: spry_session (configurable)
+     * - HttpOnly: true
+     * - Secure: true (configurable for development)
+     * - SameSite: Strict
+     * - Path: /
+     */
+    public class SessionService : GLib.Object {
+
+        private Engine _engine;
+        private CryptographyProvider _crypto;
+
+        // Storage paths
+        private const string BASE_PATH = "/spry/users";
+        private const string SESSIONS_CONTAINER = "sessions";
+        private const string SESSIONS_BY_USER_CONTAINER = "sessions_by_user";
+        private const string SESSION_TYPE_LABEL = "Session";
+
+        // Cookie configuration
+        private string _cookie_name;
+        private bool _cookie_secure;
+        private TimeSpan _session_duration;
+
+        /**
+         * Creates a new SessionService instance.
+         *
+         * @param engine The Implexus engine for data storage
+         * @param crypto The cryptography provider for token operations
+         * @param session_duration Session expiry duration (default 24 hours)
+         * @param cookie_name Name of the session cookie (default "spry_session")
+         * @param cookie_secure Whether cookie should be Secure (default true)
+         */
+        public SessionService(
+            Engine engine,
+            CryptographyProvider crypto,
+            TimeSpan? session_duration = null,
+            string cookie_name = "spry_session",
+            bool cookie_secure = true
+        ) {
+            _engine = engine;
+            _crypto = crypto;
+            _cookie_name = cookie_name;
+            _cookie_secure = cookie_secure;
+
+            if (session_duration != null) {
+                _session_duration = (!)session_duration;
+            } else {
+                // Default: 24 hours
+                _session_duration = TimeSpan.HOUR * 24;
+            }
+        }
+
+        // =========================================================================
+        // Session Creation
+        // =========================================================================
+
+        /**
+         * Creates a new session for a user.
+         *
+         * This method:
+         * - Generates a UUID for the session
+         * - Sets expiry based on configured duration
+         * - Stores optional IP and user agent
+         * - Stores session document and updates user sessions index
+         *
+         * @param user_id The user's unique identifier
+         * @param ip_address Optional IP address for tracking
+         * @param user_agent Optional user agent for tracking
+         * @return The created Session, or null on failure
+         */
+        public Session? create_session(string user_id, string? ip_address = null, string? user_agent = null) {
+            try {
+                // Generate UUID for session
+                var session_id = generate_uuid();
+
+                // Create session object
+                var session = new Session();
+                session.id = session_id;
+                session.user_id = user_id;
+                session.created_at = new DateTime.now_utc();
+                session.expires_at = new DateTime.now_utc().add(_session_duration);
+                session.ip_address = ip_address;
+                session.user_agent = user_agent;
+
+                // Get or create storage containers
+                var sessions_container = get_or_create_sessions_container();
+
+                // Create session document
+                var session_doc = sessions_container.create_document(session_id, SESSION_TYPE_LABEL);
+
+                // Store session properties
+                store_session_in_document(session_doc, session);
+
+                // Update user sessions index
+                add_session_to_user_index(user_id, session_id);
+
+                return session;
+            } catch (EngineError e) {
+                warning("Failed to create session: %s", e.message);
+                return null;
+            } catch (Error e) {
+                warning("Failed to create session: %s", e.message);
+                return null;
+            }
+        }
+
+        // =========================================================================
+        // Session Token Generation
+        // =========================================================================
+
+        /**
+         * Generates a signed and encrypted session token.
+         *
+         * Creates a JSON payload with session_id, user_id, expires_at
+         * and uses CryptographyProvider.sign_then_seal_token().
+         *
+         * @param session The session to generate a token for
+         * @return The encrypted token string
+         */
+        public string generate_session_token(Session session) {
+            // Create JSON payload
+            var payload_obj = new Json.Object();
+            payload_obj.set_string_member("session_id", session.id);
+            payload_obj.set_string_member("user_id", session.user_id);
+            payload_obj.set_string_member("expires_at", session.expires_at.format_iso8601());
+
+            // Wrap object in a node for serialization
+            var node = new Json.Node(Json.NodeType.OBJECT);
+            node.set_object(payload_obj);
+            var payload = Json.to_string(node, false);
+
+            // Sign and seal the token with expiry
+            return _crypto.sign_then_seal_token(payload, session.expires_at);
+        }
+
+        // =========================================================================
+        // Session Validation
+        // =========================================================================
+
+        /**
+         * Validates a session token and returns the result.
+         *
+         * This method:
+         * - Uses CryptographyProvider.unseal_then_verify_token()
+         * - Checks expiry
+         * - Loads session from storage
+         * - Verifies session exists and matches token data
+         *
+         * @param token The encrypted token string
+         * @return A SessionValidationResult with session and user info
+         */
+        public SessionValidationResult validate_session_token(string token) {
+            // Decrypt and verify the token
+            var token_result = _crypto.unseal_then_verify_token(token);
+
+            if (!token_result.is_valid) {
+                return new SessionValidationResult.failure(
+                    token_result.error_message ?? "Invalid token"
+                );
+            }
+
+            // Check if token is expired (crypto provider handles this but double-check)
+            if (token_result.is_expired) {
+                return new SessionValidationResult.failure("Session has expired");
+            }
+
+            // Parse the payload
+            try {
+                var payload = token_result.payload;
+                if (payload == null) {
+                    return new SessionValidationResult.failure("Empty token payload");
+                }
+
+                var json = new JsonElement.from_string((!)payload);
+                var obj = json.as<JsonObject>();
+
+                var session_id = obj.get("session_id").as<string>();
+                var user_id = obj.get("user_id").as<string>();
+                var expires_at_str = obj.get("expires_at").as<string>();
+                var expires_at = new DateTime.from_iso8601(expires_at_str, new TimeZone.utc());
+
+                // Check expiry again
+                if (expires_at.compare(new DateTime.now_utc()) <= 0) {
+                    return new SessionValidationResult.failure("Session has expired");
+                }
+
+                // Load session from storage
+                var session = get_session(session_id);
+                if (session == null) {
+                    return new SessionValidationResult.failure("Session not found");
+                }
+
+                // Verify session matches token data
+                if (session.user_id != user_id) {
+                    return new SessionValidationResult.failure("Session user mismatch");
+                }
+
+                return new SessionValidationResult.success(session);
+            } catch (Error e) {
+                return new SessionValidationResult.failure("Invalid token format: %s".printf(e.message));
+            }
+        }
+
+        // =========================================================================
+        // Session Retrieval
+        // =========================================================================
+
+        /**
+         * Gets a session by its unique ID.
+         *
+         * @param session_id The session's unique identifier
+         * @return The Session, or null if not found or expired
+         */
+        public Session? get_session(string session_id) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_CONTAINER/$session_id");
+                var entity = _engine.get_entity_or_null(path);
+
+                if (entity == null || entity.entity_type != EntityType.DOCUMENT) {
+                    return null;
+                }
+
+                var session = load_session_from_document((!)entity);
+
+                // Don't return expired sessions
+                if (session != null && session.is_expired()) {
+                    return null;
+                }
+
+                return session;
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        /**
+         * Gets all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         * @return A Vector of active (non-expired) sessions
+         */
+        public Vector<Session> get_sessions_for_user(string user_id) {
+            var sessions = new Vector<Session>();
+
+            try {
+                var session_ids = get_session_ids_for_user(user_id);
+
+                foreach (var session_id in session_ids) {
+                    var session = get_session(session_id);
+                    if (session != null) {
+                        sessions.add(session);
+                    }
+                }
+            } catch (Error e) {
+                // Return empty list on error
+            }
+
+            return sessions;
+        }
+
+        // =========================================================================
+        // Session Deletion
+        // =========================================================================
+
+        /**
+         * Deletes a session by its unique ID.
+         *
+         * This method:
+         * - Removes the session document
+         * - Updates the user sessions index
+         *
+         * @param session_id The session's unique identifier
+         * @return true on success, false on failure
+         */
+        public bool delete_session(string session_id) {
+            try {
+                // Get session first to update user index
+                var session = get_session(session_id);
+                if (session == null) {
+                    return false;
+                }
+
+                var user_id = session.user_id;
+
+                // Delete session document
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_CONTAINER/$session_id");
+                var entity = _engine.get_entity_or_null(path);
+
+                if (entity != null) {
+                    entity.delete();
+                }
+
+                // Update user sessions index
+                remove_session_from_user_index(user_id, session_id);
+
+                return true;
+            } catch (Error e) {
+                warning("Failed to delete session: %s", e.message);
+                return false;
+            }
+        }
+
+        /**
+         * Deletes all sessions for a user.
+         *
+         * @param user_id The user's unique identifier
+         */
+        public void delete_all_sessions_for_user(string user_id) {
+            try {
+                var session_ids = get_session_ids_for_user(user_id);
+
+                foreach (var session_id in session_ids) {
+                    var path = new EntityPath(@"$BASE_PATH/$SESSIONS_CONTAINER/$session_id");
+                    var entity = _engine.get_entity_or_null(path);
+
+                    if (entity != null) {
+                        entity.delete();
+                    }
+                }
+
+                // Clear user sessions index
+                clear_user_sessions_index(user_id);
+            } catch (Error e) {
+                warning("Failed to delete all sessions for user: %s", e.message);
+            }
+        }
+
+        // =========================================================================
+        // Cookie Handling
+        // =========================================================================
+
+        /**
+         * Sets the session cookie on an HTTP response.
+         *
+         * Cookie configuration:
+         * - HttpOnly: true
+         * - Secure: configurable (default true)
+         * - SameSite: Strict
+         * - Path: /
+         *
+         * @param result The HttpResult to set the cookie header on
+         * @param token The session token to set
+         */
+        public void set_session_cookie(HttpResult result, string token) {
+            // Build the Set-Cookie header value manually
+            var max_age = (int)(_session_duration / TimeSpan.SECOND);
+            var cookie_value = @"$_cookie_name=$token; Path=/; Max-Age=$max_age; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        /**
+         * Clears the session cookie on an HTTP response.
+         *
+         * @param result The HttpResult to clear the cookie on
+         */
+        public void clear_session_cookie(HttpResult result) {
+            var cookie_value = @"$_cookie_name=; Path=/; Max-Age=0; HttpOnly";
+
+            if (_cookie_secure) {
+                cookie_value += "; Secure";
+            }
+
+            cookie_value += "; SameSite=Strict";
+
+            result.set_header("Set-Cookie", cookie_value);
+        }
+
+        /**
+         * Gets the session cookie value from an HTTP request.
+         *
+         * @param http_context The HttpContext containing the request
+         * @return The session cookie value, or null if not present
+         */
+        public string? get_session_cookie(HttpContext http_context) {
+            return http_context.request.get_cookie(_cookie_name);
+        }
+
+        // =========================================================================
+        // Session Cleanup
+        // =========================================================================
+
+        /**
+         * Removes all expired sessions from storage.
+         *
+         * This method iterates through all sessions and removes those
+         * that have passed their expiry time.
+         */
+        public void cleanup_expired_sessions() {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_CONTAINER");
+                var container = _engine.get_entity_or_null(path);
+
+                if (container == null) {
+                    return;
+                }
+
+                var expired_session_ids = new Vector<string>();
+
+                foreach (var child in container.get_children()) {
+                    if (child.entity_type != EntityType.DOCUMENT) {
+                        continue;
+                    }
+
+                    var session = load_session_from_document(child);
+                    if (session != null && session.is_expired()) {
+                        expired_session_ids.add(session.id);
+                    }
+                }
+
+                // Delete expired sessions
+                foreach (var session_id in expired_session_ids) {
+                    delete_session(session_id);
+                }
+            } catch (Error e) {
+                warning("Failed to cleanup expired sessions: %s", e.message);
+            }
+        }
+
+        // =========================================================================
+        // Authentication Helper
+        // =========================================================================
+
+        /**
+         * Authenticates an HTTP request using the session cookie.
+         *
+         * This method:
+         * - Gets session cookie from request
+         * - Validates token
+         * - Loads user via UserService
+         * - Returns result with user and session
+         *
+         * @param http_context The HttpContext containing the request to authenticate
+         * @param user_service The UserService to load users from
+         * @return An AuthResult with authentication status and user/session info
+         */
+        public AuthResult authenticate_request(HttpContext http_context, UserService user_service) {
+            // Get session cookie
+            var token = get_session_cookie(http_context);
+
+            if (token == null) {
+                return new AuthResult.failure("No session cookie found");
+            }
+
+            // Validate token
+            var validation = validate_session_token((!)token);
+
+            if (!validation.is_valid) {
+                return new AuthResult.failure(validation.error_message ?? "Invalid session");
+            }
+
+            var session = validation.session;
+            if (session == null) {
+                return new AuthResult.failure("Session not found");
+            }
+
+            // Load user
+            var user = user_service.get_user(session.user_id);
+            if (user == null) {
+                return new AuthResult.failure("User not found");
+            }
+
+            return new AuthResult.success(user, session);
+        }
+
+        // =========================================================================
+        // Private Helper Methods
+        // =========================================================================
+
+        private Entity get_or_create_sessions_container() throws EngineError {
+            var root = _engine.get_root();
+
+            // Get or create /spry
+            Entity? spry = root.get_child("spry");
+            if (spry == null) {
+                spry = root.create_container("spry");
+            }
+
+            // Get or create /spry/users
+            Entity? users_base = spry.get_child("users");
+            if (users_base == null) {
+                users_base = spry.create_container("users");
+            }
+
+            // Get or create /spry/users/sessions
+            Entity? sessions_container = users_base.get_child(SESSIONS_CONTAINER);
+            if (sessions_container == null) {
+                sessions_container = users_base.create_container(SESSIONS_CONTAINER);
+            }
+
+            return (!)sessions_container;
+        }
+
+        private Entity? get_sessions_by_user_container() {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_BY_USER_CONTAINER");
+                var container = _engine.get_entity_or_null(path);
+
+                if (container == null) {
+                    // Create it if it doesn't exist
+                    var root = _engine.get_root();
+                    Entity? spry = root.get_child("spry");
+                    if (spry == null) {
+                        spry = root.create_container("spry");
+                    }
+
+                    Entity? users_base = spry.get_child("users");
+                    if (users_base == null) {
+                        users_base = spry.create_container("users");
+                    }
+
+                    container = users_base.create_container(SESSIONS_BY_USER_CONTAINER);
+                }
+
+                return container;
+            } catch (Error e) {
+                warning("Failed to get sessions_by_user container: %s", e.message);
+                return null;
+            }
+        }
+
+        private void store_session_in_document(Entity doc, Session session) throws EngineError {
+            var props = session.to_json();
+
+            // Store each JSON member as a property
+            foreach (var member_name in props.get_members()) {
+                var element = json_to_element(props.get_member(member_name));
+                doc.set_entity_property(member_name, element);
+            }
+        }
+
+        private Session? load_session_from_document(Entity doc) {
+            try {
+                var props = doc.properties;
+                var json_obj = properties_to_json(props);
+                return Session.from_json(json_obj);
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private Element json_to_element(Json.Node node) {
+            switch (node.get_node_type()) {
+                case Json.NodeType.NULL:
+                    return new NullElement();
+                case Json.NodeType.VALUE:
+                    if (node.get_value_type() == typeof(string)) {
+                        return new NativeElement<string>(node.get_string());
+                    } else if (node.get_value_type() == typeof(bool)) {
+                        return new NativeElement<bool>(node.get_boolean());
+                    } else if (node.get_value_type() == typeof(int64)) {
+                        return new NativeElement<int64?>(node.get_int());
+                    } else if (node.get_value_type() == typeof(double)) {
+                        return new NativeElement<double?>(node.get_double());
+                    }
+                    return new NativeElement<string>(node.get_string());
+                case Json.NodeType.ARRAY:
+                    var arr = new Series<Element>();
+                    foreach (var element in node.get_array().get_elements()) {
+                        arr.add(json_to_element(element));
+                    }
+                    return new NativeElement<Series<Element>>(arr);
+                case Json.NodeType.OBJECT:
+                    var obj = new PropertyDictionary();
+                    foreach (var member in node.get_object().get_members()) {
+                        obj.set(member, json_to_element(node.get_object().get_member(member)));
+                    }
+                    return new NativeElement<Properties>(obj);
+                default:
+                    return new NullElement();
+            }
+        }
+
+        private Json.Object properties_to_json(Properties props) {
+            var obj = new Json.Object();
+            var iter = props.iterator();
+            while (iter.next()) {
+                var pair = iter.get();
+                obj.set_member(pair.key, element_to_json(pair.value));
+            }
+            return obj;
+        }
+
+        private Json.Node element_to_json(Element element) {
+            if (element is NullElement) {
+                return new Json.Node.alloc();
+            }
+
+            // Try to get as string
+            if (element.assignable_to<string>()) {
+                try {
+                    var str_value = element.as<string>();
+                    var node = new Json.Node.alloc();
+                    node.set_string(str_value);
+                    return node;
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+
+            // Try to get as bool
+            if (element.assignable_to<bool>()) {
+                try {
+                    var bool_value = element.as<bool>();
+                    var node = new Json.Node.alloc();
+                    node.set_boolean(bool_value);
+                    return node;
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+
+            // Try to get as int64 (boxed)
+            if (element.assignable_to<int64?>()) {
+                try {
+                    var int_value = element.as<int64?>();
+                    if (int_value != null) {
+                        var node = new Json.Node.alloc();
+                        node.set_int((!)int_value);
+                        return node;
+                    }
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+
+            // Try to get as double (boxed)
+            if (element.assignable_to<double?>()) {
+                try {
+                    var double_value = element.as<double?>();
+                    if (double_value != null) {
+                        var node = new Json.Node.alloc();
+                        node.set_double((!)double_value);
+                        return node;
+                    }
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+
+            // Try as Series for arrays
+            if (element.assignable_to<Series<Element>>()) {
+                try {
+                    var series = element.as<Series<Element>>();
+                    var arr = new Json.Array();
+                    foreach (var item in series) {
+                        arr.add_element(element_to_json(item));
+                    }
+                    var node = new Json.Node.alloc();
+                    node.set_array(arr);
+                    return node;
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+
+            // Try as Properties for objects
+            if (element is Properties) {
+                var node = new Json.Node.alloc();
+                node.set_object(properties_to_json((Properties)element));
+                return node;
+            }
+
+            // Default: return null
+            return new Json.Node.alloc();
+        }
+
+        private void add_session_to_user_index(string user_id, string session_id) throws EngineError {
+            var container = get_sessions_by_user_container();
+            if (container == null) return;
+
+            // Get or create the user's session list document
+            var path = new EntityPath(@"$BASE_PATH/$SESSIONS_BY_USER_CONTAINER/$user_id");
+            var existing = _engine.get_entity_or_null(path);
+
+            Vector<string> session_ids;
+            if (existing != null) {
+                session_ids = load_session_ids_from_document((!)existing);
+            } else {
+                session_ids = new Vector<string>();
+            }
+
+            // Add new session ID if not already present
+            var already_exists = false;
+            foreach (var id in session_ids) {
+                if (id == session_id) {
+                    already_exists = true;
+                    break;
+                }
+            }
+
+            if (!already_exists) {
+                session_ids.add(session_id);
+            }
+
+            // Store updated list
+            var doc = existing;
+            if (doc == null) {
+                doc = container.create_document(user_id, "UserSessionsIndex");
+            }
+
+            store_session_ids_in_document(doc, session_ids);
+        }
+
+        private void remove_session_from_user_index(string user_id, string session_id) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_BY_USER_CONTAINER/$user_id");
+                var existing = _engine.get_entity_or_null(path);
+
+                if (existing == null) return;
+
+                var session_ids = load_session_ids_from_document((!)existing);
+
+                // Remove the session ID
+                var new_ids = new Vector<string>();
+                foreach (var id in session_ids) {
+                    if (id != session_id) {
+                        new_ids.add(id);
+                    }
+                }
+
+                if (new_ids.length == 0) {
+                    // Delete the index document if no sessions left
+                    existing.delete();
+                } else {
+                    store_session_ids_in_document((!)existing, new_ids);
+                }
+            } catch (Error e) {
+                warning("Failed to remove session from user index: %s", e.message);
+            }
+        }
+
+        private void clear_user_sessions_index(string user_id) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_BY_USER_CONTAINER/$user_id");
+                var existing = _engine.get_entity_or_null(path);
+
+                if (existing != null) {
+                    existing.delete();
+                }
+            } catch (Error e) {
+                warning("Failed to clear user sessions index: %s", e.message);
+            }
+        }
+
+        private Vector<string> get_session_ids_for_user(string user_id) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$SESSIONS_BY_USER_CONTAINER/$user_id");
+                var existing = _engine.get_entity_or_null(path);
+
+                if (existing == null) {
+                    return new Vector<string>();
+                }
+
+                return load_session_ids_from_document((!)existing);
+            } catch (Error e) {
+                return new Vector<string>();
+            }
+        }
+
+        private Vector<string> load_session_ids_from_document(Entity doc) {
+            var session_ids = new Vector<string>();
+
+            try {
+                var props = doc.properties;
+                if (props.has("session_ids")) {
+                    var element = props.get("session_ids");
+                    if (element != null && element.assignable_to<Series<Element>>()) {
+                        var series = element.as<Series<Element>>();
+                        foreach (var item in series) {
+                            if (item.assignable_to<string>()) {
+                                session_ids.add(item.as<string>());
+                            }
+                        }
+                    }
+                }
+            } catch (Error e) {
+                // Return empty list on error
+            }
+
+            return session_ids;
+        }
+
+        private void store_session_ids_in_document(Entity doc, Vector<string> session_ids) throws EngineError {
+            var series = new Series<Element>();
+            foreach (var id in session_ids) {
+                series.add(new NativeElement<string>(id));
+            }
+            doc.set_entity_property("session_ids", new NativeElement<Series<Element>>(series));
+        }
+
+        private string generate_uuid() {
+            // Generate UUID v4 using libsodium random bytes
+            uint8[] bytes = new uint8[16];
+            Sodium.Random.random_bytes(bytes);
+
+            // Set version (4) and variant bits
+            bytes[6] = (bytes[6] & 0x0f) | 0x40;  // Version 4
+            bytes[8] = (bytes[8] & 0x3f) | 0x80;  // Variant 1
+
+            // Format as UUID string
+            return "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x".printf(
+                bytes[0], bytes[1], bytes[2], bytes[3],
+                bytes[4], bytes[5], bytes[6], bytes[7],
+                bytes[8], bytes[9], bytes[10], bytes[11],
+                bytes[12], bytes[13], bytes[14], bytes[15]
+            );
+        }
+    }
+}

+ 111 - 0
src/Users/User.vala

@@ -0,0 +1,111 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Json;
+
+namespace Spry.Users {
+
+    public class User : GLib.Object {
+        // Identity
+        public string id { get; set; }
+        public string username { get; set; }
+        public string email { get; set; }
+        public string password_hash { get; set; }
+
+        // Metadata
+        public DateTime created_at { get; set; }
+        public DateTime? updated_at { get; set; }
+
+        // Permissions - stored as JSON array of strings
+        public Vector<string> permissions { get; set; default = new Vector<string>(); }
+
+        // Application-specific data - stored as JSON object
+        public Dictionary<string, string> app_data { get; set; default = new Dictionary<string, string>(); }
+
+        public User() {
+            id = "";
+            username = "";
+            email = "";
+            password_hash = "";
+            created_at = new DateTime.now_utc();
+        }
+
+        public static User from_json(Json.Object obj) {
+            var user = new User();
+
+            user.id = obj.get_string_member("id");
+            user.username = obj.get_string_member("username");
+            user.email = obj.get_string_member("email");
+            user.password_hash = obj.get_string_member("password_hash");
+
+            // created_at
+            if (obj.has_member("created_at")) {
+                user.created_at = new DateTime.from_iso8601(
+                    obj.get_string_member("created_at"),
+                    new TimeZone.utc()
+                );
+            }
+
+            // updated_at (optional)
+            if (obj.has_member("updated_at") && !obj.get_null_member("updated_at")) {
+                user.updated_at = new DateTime.from_iso8601(
+                    obj.get_string_member("updated_at"),
+                    new TimeZone.utc()
+                );
+            }
+
+            // permissions (array of strings)
+            if (obj.has_member("permissions")) {
+                var perms_array = obj.get_array_member("permissions");
+                foreach (var perm in perms_array.get_elements()) {
+                    user.permissions.add(perm.get_string());
+                }
+            }
+
+            // app_data (object with string values)
+            if (obj.has_member("app_data") && !obj.get_null_member("app_data")) {
+                var app_data_obj = obj.get_object_member("app_data");
+                foreach (var key in app_data_obj.get_members()) {
+                    var value = app_data_obj.get_string_member(key);
+                    user.app_data.set(key, value);
+                }
+            }
+
+            return user;
+        }
+
+        public Json.Object to_json() {
+            var obj = new Json.Object();
+
+            obj.set_string_member("id", id);
+            obj.set_string_member("username", username);
+            obj.set_string_member("email", email);
+            obj.set_string_member("password_hash", password_hash);
+            obj.set_string_member("created_at", created_at.format_iso8601());
+
+            // updated_at (optional)
+            if (updated_at != null) {
+                obj.set_string_member("updated_at", ((!)updated_at).format_iso8601());
+            } else {
+                obj.set_null_member("updated_at");
+            }
+
+            // permissions array
+            var perms_array = new Json.Array();
+            foreach (var perm in permissions) {
+                perms_array.add_string_element(perm);
+            }
+            obj.set_array_member("permissions", perms_array);
+
+            // app_data object
+            var app_data_obj = new Json.Object();
+            var iter = app_data.iterator();
+            while (iter.next()) {
+                var pair = iter.get();
+                app_data_obj.set_string_member(pair.key, pair.value);
+            }
+            obj.set_object_member("app_data", app_data_obj);
+
+            return obj;
+        }
+    }
+}

+ 794 - 0
src/Users/UserService.vala

@@ -0,0 +1,794 @@
+using Implexus.Core;
+using Invercargill;
+using Invercargill.DataStructures;
+using Json;
+
+namespace Spry.Users {
+
+    /**
+     * Error domain for user-related operations.
+     */
+    public errordomain UserError {
+        USER_NOT_FOUND,
+        DUPLICATE_USERNAME,
+        DUPLICATE_EMAIL,
+        INVALID_PASSWORD,
+        INVALID_CREDENTIALS,
+        USER_INACTIVE,
+        PERMISSION_DENIED,
+        STORAGE_ERROR
+    }
+
+    /**
+     * UserService provides user management operations including CRUD,
+     * password hashing, and authentication.
+     *
+     * Storage paths:
+     * - Users: /spry/users/users/{user_id}
+     * - Username index: /spry/users/by_username/{username} → stores user_id
+     * - Email index: /spry/users/by_email/{email} → stores user_id
+     */
+    public class UserService : GLib.Object {
+
+        private Engine _engine;
+        private CryptographyProvider _crypto;
+
+        // Storage paths
+        private const string BASE_PATH = "/spry/users";
+        private const string USERS_CONTAINER = "users";
+        private const string BY_USERNAME_CONTAINER = "by_username";
+        private const string BY_EMAIL_CONTAINER = "by_email";
+        private const string USER_TYPE_LABEL = "User";
+
+        /**
+         * Creates a new UserService instance.
+         *
+         * @param engine The Implexus engine for data storage
+         * @param crypto The cryptography provider for token operations
+         */
+        public UserService(Engine engine, CryptographyProvider crypto) {
+            _engine = engine;
+            _crypto = crypto;
+        }
+
+        // =========================================================================
+        // User Creation
+        // =========================================================================
+
+        /**
+         * Creates a new user with the specified credentials.
+         *
+         * This method:
+         * - Validates username uniqueness (checks by_username index)
+         * - Validates email uniqueness (checks by_email index)
+         * - Hashes password with Argon2id via libsodium
+         * - Creates User with UUID and timestamps
+         * - Stores user document and index entries
+         *
+         * @param username The unique username
+         * @param email The unique email address
+         * @param password The plaintext password to hash
+         * @param error Output parameter for error message on failure
+         * @return The created User, or null on failure
+         */
+        public User? create_user(string username, string email, string password, out string? error = null) {
+            error = null;
+
+            try {
+                // Validate username uniqueness
+                if (username_exists(username)) {
+                    error = "Username already exists";
+                    return null;
+                }
+
+                // Validate email uniqueness
+                if (email_exists(email)) {
+                    error = "Email already exists";
+                    return null;
+                }
+
+                // Hash password with Argon2id
+                var password_hash = hash_password(password);
+                if (password_hash == null) {
+                    error = "Failed to hash password";
+                    return null;
+                }
+
+                // Generate UUID for user
+                var user_id = generate_uuid();
+
+                // Create user object
+                var user = new User();
+                user.id = user_id;
+                user.username = username;
+                user.email = email;
+                user.password_hash = (!)password_hash;
+                user.created_at = new DateTime.now_utc();
+
+                // Get or create storage containers
+                var users_container = get_or_create_users_container();
+
+                // Create user document
+                var user_doc = users_container.create_document(user_id, USER_TYPE_LABEL);
+
+                // Store user properties
+                store_user_in_document(user_doc, user);
+
+                // Update indexes
+                set_username_index(username, user_id);
+                set_email_index(email, user_id);
+
+                return user;
+            } catch (EngineError e) {
+                error = "Storage error: %s".printf(e.message);
+                return null;
+            } catch (Error e) {
+                error = "Error: %s".printf(e.message);
+                return null;
+            }
+        }
+
+        // =========================================================================
+        // User Retrieval
+        // =========================================================================
+
+        /**
+         * Gets a user by their unique ID.
+         *
+         * @param user_id The user's unique identifier
+         * @return The User, or null if not found
+         */
+        public User? get_user(string user_id) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$USERS_CONTAINER/$user_id");
+                var entity = _engine.get_entity_or_null(path);
+
+                if (entity == null || entity.entity_type != EntityType.DOCUMENT) {
+                    return null;
+                }
+
+                return load_user_from_document((!)entity);
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        /**
+         * Gets a user by their username.
+         *
+         * @param username The username to look up
+         * @return The User, or null if not found
+         */
+        public User? get_user_by_username(string username) {
+            var user_id = get_user_id_by_username(username);
+            if (user_id == null) {
+                return null;
+            }
+            return get_user((!)user_id);
+        }
+
+        /**
+         * Gets a user by their email address.
+         *
+         * @param email The email address to look up
+         * @return The User, or null if not found
+         */
+        public User? get_user_by_email(string email) {
+            var user_id = get_user_id_by_email(email);
+            if (user_id == null) {
+                return null;
+            }
+            return get_user((!)user_id);
+        }
+
+        // =========================================================================
+        // User Update
+        // =========================================================================
+
+        /**
+         * Updates an existing user.
+         *
+         * This method:
+         * - Updates the updated_at timestamp
+         * - Handles username/email changes (updates indexes)
+         *
+         * @param user The user to update
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool update_user(User user, out string? error = null) {
+            error = null;
+
+            try {
+                // Get existing user to check for index changes
+                var existing = get_user(user.id);
+                if (existing == null) {
+                    error = "User not found";
+                    return false;
+                }
+
+                // Check if username changed
+                if (existing.username != user.username) {
+                    // Check new username uniqueness
+                    var existing_with_username = get_user_id_by_username(user.username);
+                    if (existing_with_username != null && existing_with_username != user.id) {
+                        error = "Username already exists";
+                        return false;
+                    }
+                    // Update username index
+                    remove_username_index(existing.username);
+                    set_username_index(user.username, user.id);
+                }
+
+                // Check if email changed
+                if (existing.email != user.email) {
+                    // Check new email uniqueness
+                    var existing_with_email = get_user_id_by_email(user.email);
+                    if (existing_with_email != null && existing_with_email != user.id) {
+                        error = "Email already exists";
+                        return false;
+                    }
+                    // Update email index
+                    remove_email_index(existing.email);
+                    set_email_index(user.email, user.id);
+                }
+
+                // Update timestamp
+                user.updated_at = new DateTime.now_utc();
+
+                // Store updated user
+                var path = new EntityPath(@"$BASE_PATH/$USERS_CONTAINER/$(user.id)");
+                var entity = _engine.get_entity(path);
+
+                store_user_in_document(entity, user);
+
+                return true;
+            } catch (EngineError e) {
+                error = "Storage error: %s".printf(e.message);
+                return false;
+            } catch (Error e) {
+                error = "Error: %s".printf(e.message);
+                return false;
+            }
+        }
+
+        // =========================================================================
+        // User Deletion
+        // =========================================================================
+
+        /**
+         * Deletes a user by their unique ID.
+         *
+         * This method:
+         * - Removes the user document
+         * - Removes index entries
+         *
+         * @param user_id The user's unique identifier
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool delete_user(string user_id, out string? error = null) {
+            error = null;
+
+            try {
+                // Get user first to remove indexes
+                var user = get_user(user_id);
+                if (user == null) {
+                    error = "User not found";
+                    return false;
+                }
+
+                // Remove indexes
+                remove_username_index(user.username);
+                remove_email_index(user.email);
+
+                // Delete user document
+                var path = new EntityPath(@"$BASE_PATH/$USERS_CONTAINER/$user_id");
+                var entity = _engine.get_entity(path);
+                entity.delete();
+
+                return true;
+            } catch (EngineError e) {
+                error = "Storage error: %s".printf(e.message);
+                return false;
+            } catch (Error e) {
+                error = "Error: %s".printf(e.message);
+                return false;
+            }
+        }
+
+        // =========================================================================
+        // User Listing
+        // =========================================================================
+
+        /**
+         * Lists users with pagination support.
+         *
+         * @param offset The number of users to skip
+         * @param limit The maximum number of users to return
+         * @return A Vector of users
+         */
+        public Vector<User> list_users(int offset = 0, int limit = 100) {
+            var users = new Vector<User>();
+
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$USERS_CONTAINER");
+                var container = _engine.get_entity_or_null(path);
+
+                if (container == null) {
+                    return users;
+                }
+
+                int skipped = 0;
+                int taken = 0;
+
+                foreach (var child in container.get_children()) {
+                    if (child.entity_type != EntityType.DOCUMENT) {
+                        continue;
+                    }
+
+                    if (skipped < offset) {
+                        skipped++;
+                        continue;
+                    }
+
+                    if (taken >= limit) {
+                        break;
+                    }
+
+                    var user = load_user_from_document(child);
+                    if (user != null) {
+                        users.add(user);
+                        taken++;
+                    }
+                }
+            } catch (Error e) {
+                // Return empty list on error
+            }
+
+            return users;
+        }
+
+        // =========================================================================
+        // Password Management
+        // =========================================================================
+
+        /**
+         * Hashes a password using Argon2id via libsodium.
+         *
+         * @param password The plaintext password to hash
+         * @return The hashed password string, or null on failure
+         */
+        public string? hash_password(string password) {
+            return Sodium.PasswordHashing.hash(password);
+        }
+
+        /**
+         * Verifies a password against a stored hash.
+         *
+         * @param user The user to verify against
+         * @param password The plaintext password to verify
+         * @return true if the password matches, false otherwise
+         */
+        public bool verify_password(User user, string password) {
+            return Sodium.PasswordHashing.check(user.password_hash, password);
+        }
+
+        /**
+         * Sets a new password for a user.
+         *
+         * @param user The user to update
+         * @param new_password The new plaintext password
+         * @param error Output parameter for error message on failure
+         * @return true on success, false on failure
+         */
+        public bool set_password(User user, string new_password, out string? error = null) {
+            error = null;
+
+            var password_hash = hash_password(new_password);
+            if (password_hash == null) {
+                error = "Failed to hash password";
+                return false;
+            }
+
+            user.password_hash = (!)password_hash;
+            user.updated_at = new DateTime.now_utc();
+
+            return update_user(user, out error);
+        }
+
+        // =========================================================================
+        // Authentication
+        // =========================================================================
+
+        /**
+         * Authenticates a user by username/email and password.
+         *
+         * This method:
+         * - Looks up user by username or email
+         * - Verifies the password
+         * - Returns the user if valid
+         *
+         * @param username_or_email The username or email address
+         * @param password The plaintext password
+         * @return The authenticated User, or null if authentication failed
+         */
+        public User? authenticate(string username_or_email, string password) {
+            // Try to find user by username first, then by email
+            User? user = get_user_by_username(username_or_email);
+
+            if (user == null) {
+                user = get_user_by_email(username_or_email);
+            }
+
+            if (user == null) {
+                return null;
+            }
+
+            // Verify password
+            if (!verify_password(user, password)) {
+                return null;
+            }
+
+            return user;
+        }
+
+        // =========================================================================
+        // Utility Methods
+        // =========================================================================
+
+        /**
+         * Checks if a username already exists.
+         *
+         * @param username The username to check
+         * @return true if the username exists
+         */
+        public bool username_exists(string username) {
+            return get_user_id_by_username(username) != null;
+        }
+
+        /**
+         * Checks if an email already exists.
+         *
+         * @param email The email to check
+         * @return true if the email exists
+         */
+        public bool email_exists(string email) {
+            return get_user_id_by_email(email) != null;
+        }
+
+        /**
+         * Gets the total count of users.
+         *
+         * @return The number of users
+         */
+        public int user_count() {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$USERS_CONTAINER");
+                var container = _engine.get_entity_or_null(path);
+
+                if (container == null) {
+                    return 0;
+                }
+
+                int count = 0;
+                foreach (var child in container.get_children()) {
+                    if (child.entity_type == EntityType.DOCUMENT) {
+                        count++;
+                    }
+                }
+                return count;
+            } catch (Error e) {
+                return 0;
+            }
+        }
+
+        // =========================================================================
+        // Private Helper Methods
+        // =========================================================================
+
+        private Entity get_or_create_users_container() throws EngineError {
+            var root = _engine.get_root();
+
+            // Get or create /spry
+            Entity? spry = root.get_child("spry");
+            if (spry == null) {
+                spry = root.create_container("spry");
+            }
+
+            // Get or create /spry/users
+            Entity? users_base = spry.get_child("users");
+            if (users_base == null) {
+                users_base = spry.create_container("users");
+            }
+
+            // Get or create /spry/users/users (where user docs are stored)
+            Entity? users_container = users_base.get_child(USERS_CONTAINER);
+            if (users_container == null) {
+                users_container = users_base.create_container(USERS_CONTAINER);
+            }
+
+            return (!)users_container;
+        }
+
+        private Entity? get_index_container(string container_name) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$container_name");
+                return _engine.get_entity_or_null(path);
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private void ensure_index_containers_exist() throws EngineError {
+            var root = _engine.get_root();
+
+            // Get or create /spry
+            Entity? spry = root.get_child("spry");
+            if (spry == null) {
+                spry = root.create_container("spry");
+            }
+
+            // Get or create /spry/users
+            Entity? users_base = spry.get_child("users");
+            if (users_base == null) {
+                users_base = spry.create_container("users");
+            }
+
+            // Get or create index containers
+            if (users_base.get_child(BY_USERNAME_CONTAINER) == null) {
+                users_base.create_container(BY_USERNAME_CONTAINER);
+            }
+
+            if (users_base.get_child(BY_EMAIL_CONTAINER) == null) {
+                users_base.create_container(BY_EMAIL_CONTAINER);
+            }
+        }
+
+        private void store_user_in_document(Entity doc, User user) throws EngineError {
+            var props = user.to_json();
+
+            // Store each JSON member as a property
+            foreach (var member_name in props.get_members()) {
+                var element = json_to_element(props.get_member(member_name));
+                doc.set_entity_property(member_name, element);
+            }
+        }
+
+        private User? load_user_from_document(Entity doc) {
+            try {
+                var props = doc.properties;
+                var json_obj = properties_to_json(props);
+                return User.from_json(json_obj);
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private Element json_to_element(Json.Node node) {
+            switch (node.get_node_type()) {
+                case Json.NodeType.NULL:
+                    return new NullElement();
+                case Json.NodeType.VALUE:
+                    if (node.get_value_type() == typeof(string)) {
+                        return new NativeElement<string>(node.get_string());
+                    } else if (node.get_value_type() == typeof(bool)) {
+                        return new NativeElement<bool>(node.get_boolean());
+                    } else if (node.get_value_type() == typeof(int64)) {
+                        return new NativeElement<int64?>(node.get_int());
+                    } else if (node.get_value_type() == typeof(double)) {
+                        return new NativeElement<double?>(node.get_double());
+                    }
+                    return new NativeElement<string>(node.get_string());
+                case Json.NodeType.ARRAY:
+                    var arr = new Series<Element>();
+                    foreach (var element in node.get_array().get_elements()) {
+                        arr.add(json_to_element(element));
+                    }
+                    return new NativeElement<Series<Element>>(arr);
+                case Json.NodeType.OBJECT:
+                    var obj = new PropertyDictionary();
+                    foreach (var member in node.get_object().get_members()) {
+                        obj.set(member, json_to_element(node.get_object().get_member(member)));
+                    }
+                    return new NativeElement<Properties>(obj);
+                default:
+                    return new NullElement();
+            }
+        }
+
+        private Json.Object properties_to_json(Properties props) {
+            var obj = new Json.Object();
+            var iter = props.iterator();
+            while (iter.next()) {
+                var pair = iter.get();
+                obj.set_member(pair.key, element_to_json(pair.value));
+            }
+            return obj;
+        }
+
+        private Json.Node element_to_json(Element element) {
+            if (element is NullElement) {
+                return new Json.Node.alloc();
+            }
+            
+            // Try to get as string
+            if (element.assignable_to<string>()) {
+                try {
+                    var str_value = element.as<string>();
+                    var node = new Json.Node.alloc();
+                    node.set_string(str_value);
+                    return node;
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+            
+            // Try to get as bool
+            if (element.assignable_to<bool>()) {
+                try {
+                    var bool_value = element.as<bool>();
+                    var node = new Json.Node.alloc();
+                    node.set_boolean(bool_value);
+                    return node;
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+            
+            // Try to get as int64 (boxed)
+            if (element.assignable_to<int64?>()) {
+                try {
+                    var int_value = element.as<int64?>();
+                    if (int_value != null) {
+                        var node = new Json.Node.alloc();
+                        node.set_int((!)int_value);
+                        return node;
+                    }
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+            
+            // Try to get as double (boxed)
+            if (element.assignable_to<double?>()) {
+                try {
+                    var double_value = element.as<double?>();
+                    if (double_value != null) {
+                        var node = new Json.Node.alloc();
+                        node.set_double((!)double_value);
+                        return node;
+                    }
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+            
+            // Try as Series for arrays
+            if (element.assignable_to<Series<Element>>()) {
+                try {
+                    var series = element.as<Series<Element>>();
+                    var arr = new Json.Array();
+                    foreach (var item in series) {
+                        arr.add_element(element_to_json(item));
+                    }
+                    var node = new Json.Node.alloc();
+                    node.set_array(arr);
+                    return node;
+                } catch (Error e) {
+                    // Fall through
+                }
+            }
+            
+            // Try as Properties for objects
+            if (element is Properties) {
+                var node = new Json.Node.alloc();
+                node.set_object(properties_to_json((Properties)element));
+                return node;
+            }
+            
+            // Default: return null
+            return new Json.Node.alloc();
+        }
+
+        private void set_username_index(string username, string user_id) throws EngineError {
+            ensure_index_containers_exist();
+            var path = new EntityPath(@"$BASE_PATH/$BY_USERNAME_CONTAINER/$username");
+            var existing = _engine.get_entity_or_null(path);
+
+            if (existing != null) {
+                existing.delete();
+            }
+
+            var container = _engine.get_entity(new EntityPath(@"$BASE_PATH/$BY_USERNAME_CONTAINER"));
+            var index_doc = container.create_document(username, "UsernameIndex");
+            index_doc.set_entity_property("user_id", new NativeElement<string>(user_id));
+        }
+
+        private void set_email_index(string email, string user_id) throws EngineError {
+            ensure_index_containers_exist();
+            var path = new EntityPath(@"$BASE_PATH/$BY_EMAIL_CONTAINER/$email");
+            var existing = _engine.get_entity_or_null(path);
+
+            if (existing != null) {
+                existing.delete();
+            }
+
+            var container = _engine.get_entity(new EntityPath(@"$BASE_PATH/$BY_EMAIL_CONTAINER"));
+            var index_doc = container.create_document(email, "EmailIndex");
+            index_doc.set_entity_property("user_id", new NativeElement<string>(user_id));
+        }
+
+        private void remove_username_index(string username) throws EngineError {
+            var path = new EntityPath(@"$BASE_PATH/$BY_USERNAME_CONTAINER/$username");
+            var existing = _engine.get_entity_or_null(path);
+            if (existing != null) {
+                existing.delete();
+            }
+        }
+
+        private void remove_email_index(string email) throws EngineError {
+            var path = new EntityPath(@"$BASE_PATH/$BY_EMAIL_CONTAINER/$email");
+            var existing = _engine.get_entity_or_null(path);
+            if (existing != null) {
+                existing.delete();
+            }
+        }
+
+        private string? get_user_id_by_username(string username) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$BY_USERNAME_CONTAINER/$username");
+                var entity = _engine.get_entity_or_null(path);
+
+                if (entity == null || entity.entity_type != EntityType.DOCUMENT) {
+                    return null;
+                }
+
+                var user_id_element = entity.get_entity_property("user_id");
+                if (user_id_element != null && user_id_element.assignable_to<string>()) {
+                    return user_id_element.as<string>();
+                }
+                return null;
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private string? get_user_id_by_email(string email) {
+            try {
+                var path = new EntityPath(@"$BASE_PATH/$BY_EMAIL_CONTAINER/$email");
+                var entity = _engine.get_entity_or_null(path);
+
+                if (entity == null || entity.entity_type != EntityType.DOCUMENT) {
+                    return null;
+                }
+
+                var user_id_element = entity.get_entity_property("user_id");
+                if (user_id_element != null && user_id_element.assignable_to<string>()) {
+                    return user_id_element.as<string>();
+                }
+                return null;
+            } catch (Error e) {
+                return null;
+            }
+        }
+
+        private string generate_uuid() {
+            // Generate UUID v4 using libsodium random bytes
+            uint8[] bytes = new uint8[16];
+            Sodium.Random.random_bytes(bytes);
+
+            // Set version (4) and variant bits
+            bytes[6] = (bytes[6] & 0x0f) | 0x40;  // Version 4
+            bytes[8] = (bytes[8] & 0x3f) | 0x80;  // Variant 1
+
+            // Format as UUID string
+            return "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x".printf(
+                bytes[0], bytes[1], bytes[2], bytes[3],
+                bytes[4], bytes[5], bytes[6], bytes[7],
+                bytes[8], bytes[9], bytes[10], bytes[11],
+                bytes[12], bytes[13], bytes[14], bytes[15]
+            );
+        }
+    }
+}

+ 24 - 0
src/Users/meson.build

@@ -0,0 +1,24 @@
+users_sources = files(
+    'User.vala',
+    'Session.vala',
+    'UserService.vala',
+    'SessionService.vala',
+    'PermissionService.vala',
+    'Components/LoginFormComponent.vala',
+    'Components/UserListItemComponent.vala',
+    'Components/UserListComponent.vala',
+    'Components/PermissionEditorComponent.vala',
+    'Components/UserFormComponent.vala',
+    'Components/UserManagementPage.vala'
+)
+
+libspry_users = static_library('spry-users',
+    users_sources,
+    dependencies: [spry_dep, implexus_dep, sodium_deps, invercargill_dep, astralis_dep],
+    include_directories: include_directories('..')
+)
+
+spry_users_dep = declare_dependency(
+    link_with: libspry_users,
+    dependencies: [spry_dep, implexus_dep]
+)

+ 1 - 1
tools/spry-mkssr/meson.build

@@ -4,6 +4,6 @@ spry_mkssr_sources = files(
 
 spry_mkssr = executable('spry-mkssr',
     spry_mkssr_sources,
-    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, astralis_dep, inversion_dep, libxml_dep],
+    dependencies: [glib_dep, gobject_dep, gio_dep, invercargill_dep, json_glib_dep, astralis_dep, inversion_dep, libxml_dep],
     install: true
 )

+ 56 - 9
vapi/libsodium.vapi

@@ -226,12 +226,59 @@
              if(seal_open(message, ciphertext, public_key, secret_key) != 0){
                  return null;
              }
-             return message;
-         }
-         
-     }
- 
-   }
-   
- 
- }
+              return message;
+          }
+          
+      }
+  
+    }
+    
+    namespace PasswordHashing {
+      [CCode (cname = "crypto_pwhash_STRBYTES")]
+      public const size_t STR_BYTES;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_INTERACTIVE")]
+      public const size_t OPSLIMIT_INTERACTIVE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_INTERACTIVE")]
+      public const size_t MEMLIMIT_INTERACTIVE;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_MODERATE")]
+      public const size_t OPSLIMIT_MODERATE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_MODERATE")]
+      public const size_t MEMLIMIT_MODERATE;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_SENSITIVE")]
+      public const size_t OPSLIMIT_SENSITIVE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_SENSITIVE")]
+      public const size_t MEMLIMIT_SENSITIVE;
+    
+      [CCode (cname = "crypto_pwhash_str")]
+      private int pwhash_str(
+        [CCode (array_length = false)] uint8[] out,
+        string passwd,
+        size_t passwdlen,
+        ulong opslimit,
+        size_t memlimit
+      );
+    
+      public string? hash(string password, ulong opslimit = OPSLIMIT_MODERATE, size_t memlimit = MEMLIMIT_MODERATE) {
+        uint8[] out_buf = new uint8[STR_BYTES];
+        if (pwhash_str(out_buf, password, password.length, opslimit, memlimit) != 0) {
+          return null;
+        }
+        // Null-terminated string
+        return (string)out_buf;
+      }
+    
+      [CCode (cname = "crypto_pwhash_str_verify")]
+      private int pwhash_str_verify(string hash, string password, size_t passwdlen);
+    
+      public bool check(string hash, string password) {
+        return pwhash_str_verify(hash, password, password.length) == 0;
+      }
+    }
+  
+  }