|
|
@@ -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">×</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\">×</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
|