|
@@ -1,1878 +0,0 @@
|
|
|
-# 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
|
|
|