# 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 permissions { get; set; default = new Series(); } // Application-specific data - stored as JSON object public Properties? application_data { get; set; } // PropertyMapper for Implexus serialization public static PropertyMapper get_mapper() { return PropertyMapper.build_for(cfg => { cfg.map("id", u => u.id, (u, v) => u.id = v); cfg.map("username", u => u.username, (u, v) => u.username = v); cfg.map("email", u => u.email, (u, v) => u.email = v); cfg.map("password_hash", u => u.password_hash, (u, v) => u.password_hash = v); cfg.map("created", u => u.created_at.format_iso8601(), (u, v) => u.created_at = new DateTime.from_iso8601(v, new TimeZone.utc())); cfg.map("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("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("active", u => u.is_active, (u, v) => u.is_active = v); cfg.map("verified", u => u.is_verified, (u, v) => u.is_verified = v); cfg.map>("permissions", u => u.permissions, (u, v) => u.permissions = v); cfg.map("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 get_mapper() { return PropertyMapper.build_for(cfg => { cfg.map("id", s => s.id, (s, v) => s.id = v); cfg.map("user", s => s.user_id, (s, v) => s.user_id = v); cfg.map("created", s => s.created_at.format_iso8601(), (s, v) => s.created_at = new DateTime.from_iso8601(v, new TimeZone.utc())); cfg.map("expires", s => s.expires_at.format_iso8601(), (s, v) => s.expires_at = new DateTime.from_iso8601(v, new TimeZone.utc())); cfg.map("ip", s => s.ip_address, (s, v) => s.ip_address = v); cfg.map("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(); private UsersConfiguration config = inject(); // User CRUD public User create_user(string username, string email, string password, Series? 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 list_users(int offset = 0, int limit = 100); public Series 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(); private CryptographyProvider crypto = inject(); private UsersConfiguration config = inject(); private HttpContext http_context = inject(); // 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(); private SessionService session_service = inject(); // Permission Checking public bool has_permission(string user_id, string permission) throws Error; public bool has_any_permission(string user_id, Series permissions) throws Error; public bool has_all_permissions(string user_id, Series permissions) throws Error; // Current User Shortcuts public bool current_user_has(string permission) throws Error; public bool current_user_has_any(Series permissions) throws Error; public bool current_user_has_all(Series 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 permissions) throws Error; public void clear_all_permissions(string user_id) throws Error; // Querying public Series get_permissions(string user_id) throws Error; public Series get_users_with_permission(string permission) throws Error; // Validation public void require_permission(string permission) throws Error; public void require_any(Series permissions) throws Error; public void require_all(Series 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 default_permissions { get; set; default = new Series(); } // 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(); var config = inject(); 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 get_mapper() { return PropertyMapper.build_for(cfg => { cfg.map("sid", t => t.session_id, (t, v) => t.session_id = v); cfg.map("uid", t => t.user_id, (t, v) => t.user_id = v); cfg.map("cat", t => t.created_at.format_iso8601(), (t, v) => t.created_at = new DateTime.from_iso8601(v, new TimeZone.utc())); cfg.map("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(); 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()); // 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(); private UserService user_service = inject(); private HttpContext http_context = inject(); // Properties for configuration public string redirect_url { get; set; default = "/"; } public string? error_message { get; private set; } public override string markup { get { return """ """; }} 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(); private ComponentFactory factory = inject(); private HttpContext http_context = inject(); // State private string _search_query = ""; private int _page = 0; private int _page_size = 20; private Series _users = new Series(); private int _total_count = 0; public override string markup { get { return """
Username Email Status Created Last Login Actions

No users found

"""; }} 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(); foreach (var user in _users) { var item = factory.create(); 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 """ Active Inactive Verified """; }} 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(); private ComponentFactory factory = inject(); private HttpContext http_context = inject(); // 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 """
"""; }} 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("permission-editor"); perm_editor.set_permissions(_editing_user.permissions); } else { var perm_editor = get_component_child("permission-editor"); perm_editor.set_permissions(new Series()); } } 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("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 COMMON_PERMISSIONS = new Series.from({ Permission.USER_MANAGEMENT, Permission.ADMIN, "content.read", "content.edit", "content.delete" }); private Series _selected_permissions = new Series(); private Series _custom_permissions = new Series(); public void set_permissions(Series permissions) { _selected_permissions = new Series(); _custom_permissions = new Series(); foreach (var perm in permissions) { if (COMMON_PERMISSIONS.contains(perm)) { _selected_permissions.add(perm); } else { _custom_permissions.add(perm); } } } public Series get_selected() { var result = new Series(); result.add_all(_selected_permissions); result.add_all(_custom_permissions); return result; } public override string markup { get { return """

Common Permissions

Custom Permissions

"""; }} public override async void prepare() throws Error { var common_items = new Series(); 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(); 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 = @""; return new InlineRenderable(doc); } private Renderable create_custom_permission_tag(string permission) { var doc = new MarkupDocument(); doc.body.inner_html = @" $permission "; 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(); private UserService user_service = inject(); private SessionService session_service = inject(); private ComponentFactory factory = inject(); private HttpContext http_context = inject(); // State private string? _success_message = null; private string? _error_message = null; public override string markup { get { return """ User Management

User Management

"""; }} public override async void prepare() throws Error { permission_service.require_permission(Permission.USER_MANAGEMENT); var user_form = get_component_child("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("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("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("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("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(); application.add_module(); // Optional: Configure settings var config = application.resolve(); 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(); // Register services (scoped for per-request isolation) application.add_scoped(); application.add_scoped(); application.add_scoped(); // Register components (transient) var spry_cfg = application.configure_with(); spry_cfg.add_component(); spry_cfg.add_component(); spry_cfg.add_component(); spry_cfg.add_component(); spry_cfg.add_component(); // Register pages (scoped) spry_cfg.add_page( new EndpointRoute("/admin/users")); } public void initialize(Inversion.Application application) { // Initialize storage structure try { var engine = application.resolve(); 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(); 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(); 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("dark"); user.application_data["notifications"] = new NativeElement(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