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.
/spry/userssrc/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 | 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 |
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());
});
}
}
}
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());
});
}
}
}
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);
}
}
}
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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() │
└───────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 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()
┌───────────────────┐ ┌───────────────────┐
│ 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 │
└───────────────────────────────────────────────────┘
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();
}
}
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;
}
}
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;
}
}
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);
}
}
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
{
"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"
}
}
}
{
"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 ..."
}
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");
}
}
Session tokens are created using the existing CryptographyProvider pattern:
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());
});
}
}
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;
}
}
}
Uses Argon2id via libsodium (already available through existing dependencies):
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);
}
}
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.
A self-contained login form component with HTMX-based submission:
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";
}
}
}
}
}
Displays the list of users with search and filter capabilities. Creates UserListItemComponent instances for each user:
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;
}
}
}
}
Individual user row with inline action buttons. Actions are delegated to the parent UserManagementPage:
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);
}
}
}
Modal form for creating and editing users. Contains PermissionEditorComponent:
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;
}
}
}
}
Widget for managing user permissions with checkboxes for common permissions and input for custom ones:
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;
}
}
}
Orchestrates all user management components. Handles cross-component actions:
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;
}
}
}
}
Applications integrate the Users system by registering the module:
// 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");
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);
}
}
}
Applications can protect routes using the PermissionService:
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");
}
}
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)!";
}
}
// 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");
+ → - and / → _ substitution^[a-zA-Z0-9._-]+$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
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]
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]
# 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]
)
| 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 |
Add to root meson.build:
implexus_dep = dependency('implexus-0.1')
subdir('src/Users')
Recommended implementation sequence: