This document outlines the redesign of the Spry Authentication user management components. The redesign focuses on:
UserManagementPage to UserManagementComponent for better placement control<details>/<summary> pattern for user displayPermissionEditorComponent)graph TD
A[UserManagementPage - PageComponent] --> B[UserListComponent]
A --> C[UserFormComponent]
B --> D[UserListItemComponent - per user]
C --> E[PermissionEditorComponent]
| Component | Type | Purpose |
|---|---|---|
UserManagementPage |
PageComponent | Full HTML page with embedded CSS, permission check, orchestrates child components |
UserListComponent |
Component | Table of users with search/pagination, creates UserListItemComponent per row |
UserListItemComponent |
Component | Single table row with user info and action buttons |
UserFormComponent |
Component | Modal form for create/edit with validation |
PermissionEditorComponent |
Component | Checkbox-based permission selection |
UserManagementPage is a PageComponent that generates a full HTML document, limiting where it can be placed.admin-container, .btn, .modal-overlay, .spry-user-list may conflict with application stylesgraph TD
A[UserManagementComponent - Container] --> B[UserDetailsComponent - view mode per user]
A --> C[UserDetailEditComponent - edit mode swaps in]
A --> D[NewUserComponent - for creation]
C --> E[Integrated Permission Editing]
D --> E
| Component | Type | Purpose |
|---|---|---|
UserManagementComponent |
Component | Container with header, new user button, user list outlet |
UserDetailsComponent |
Component | View-only <details> element for displaying one user |
UserDetailEditComponent |
Component | Edit mode <details> element with form fields and integrated permission editing |
NewUserComponent |
Component | Create form using same pattern as edit, with integrated permission editing |
UserManagementPage → UserManagementComponent:
PageComponent to ComponentSeparate View/Edit Components:
UserDetailsComponent handles read-only displayUserDetailEditComponent handles editing (swapped in via HTMX)Remove PermissionEditorComponent:
UserDetailEditComponent and NewUserComponentRemove UserFormComponent, UserListComponent, UserListItemComponent:
<div sid="user-management" id="user-management" hx-swap="outerHTML">
<script spry-res="htmx.js"></script>
<!-- Header with Create Button -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">Users</h3>
<button sid="create-btn"
spry-action=":ShowCreateUser"
spry-target="user-management"
style="padding: 0.5rem 1rem; cursor: pointer;">
+ Create User
</button>
</div>
<!-- Success/Error Messages -->
<div spry-if="this.success_message != null"
style="padding: 0.75rem; margin-bottom: 1rem; background: #d4edda; color: #155724; border-radius: 4px;">
<span content-expr="this.success_message"></span>
</div>
<div spry-if="this.error_message != null"
style="padding: 0.75rem; margin-bottom: 1rem; background: #f8d7da; color: #721c24; border-radius: 4px;">
<span content-expr="this.error_message"></span>
</div>
<!-- New User Form (conditionally visible) -->
<div spry-if="this.show_create_form" sid="new-user-container">
<spry-component name="Spry.Authentication.Components.NewUserComponent" sid="new-user"/>
</div>
<!-- User List -->
<div sid="user-list" style="display: flex; flex-direction: column; gap: 0.5rem;">
<spry-outlet sid="users"/>
</div>
</div>
This component handles read-only display of a single user. When the user clicks "Edit", the entire component is swapped out for UserDetailEditComponent.
<details sid="user-details"
id-expr="'user-' + this.user_id"
hx-swap="outerHTML"
open>
<!-- Summary: Always visible, shows key info -->
<summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; cursor: pointer;">
<span content-expr="this.username" style="font-weight: 500; min-width: 120px;"></span>
<span content-expr="this.email" style="color: #6c757d;"></span>
<span spry-if="this.permissions.length > 0" style="margin-left: auto; font-size: 0.85rem; color: #495057;">
<span content-expr="this.permissions.length"></span> permission(s)
</span>
</summary>
<!-- Details: Visible when expanded - VIEW ONLY -->
<div style="padding: 1rem; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 4px 4px;">
<table style="width: 100%; border-collapse: collapse;">
<tbody>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">User ID</td>
<td style="padding: 0.5rem 0;"><code content-expr="this.user_id"></code></td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Username</td>
<td style="padding: 0.5rem 0;"><span content-expr="this.username"></span></td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Email</td>
<td style="padding: 0.5rem 0;"><span content-expr="this.email"></span></td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Created</td>
<td style="padding: 0.5rem 0;"><span content-expr="this.created_at"></span></td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
<td style="padding: 0.5rem 0;">
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
<spry-outlet sid="permission-badges"/>
</div>
</td>
</tr>
</tbody>
</table>
<!-- View Mode Actions -->
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button sid="edit-btn"
spry-action=":StartEdit"
spry-target="user-details"
style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem;">
Edit
</button>
<button sid="delete-btn"
spry-action=":DeleteUser"
spry-target="user-management"
style="padding: 0.25rem 0.75rem; cursor: pointer; font-size: 0.875rem; background: #dc3545; color: white; border: none; border-radius: 4px;">
Delete
</button>
</div>
</div>
</details>
This component is swapped in when editing. It includes inline permission editing (no separate component).
<details sid="user-details-edit"
id-expr="'user-' + this.user_id"
hx-swap="outerHTML"
open>
<!-- Summary: Shows user being edited -->
<summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #fff3cd; border-radius: 4px; cursor: pointer; border: 2px solid #ffc107;">
<span content-expr="this.username" style="font-weight: 500; min-width: 120px;"></span>
<span style="color: #856404; font-style: italic;">Editing...</span>
</summary>
<!-- Edit Form -->
<div style="padding: 1rem; border: 2px solid #ffc107; border-top: none; border-radius: 0 0 4px 4px; background: #fffdf5;">
<form sid="edit-form"
spry-action=":SaveEdit"
spry-target="user-details-edit">
<input type="hidden" name="user_id" sid="user-id-input" value-expr="this.user_id"/>
<table style="width: 100%; border-collapse: collapse;">
<tbody>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">User ID</td>
<td style="padding: 0.5rem 0;"><code content-expr="this.user_id"></code></td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Username *</td>
<td style="padding: 0.5rem 0;">
<input type="text"
name="username"
sid="username-input"
required
minlength="3"
pattern="[a-zA-Z0-9_]+"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
value-expr="this.username"/>
</td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
<td style="padding: 0.5rem 0;">
<input type="email"
name="email"
sid="email-input"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
value-expr="this.email"/>
</td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">New Password</td>
<td style="padding: 0.5rem 0;">
<input type="password"
name="new_password"
sid="password-input"
minlength="8"
placeholder="Leave blank to keep current"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
<small style="color: #6c757d;">Minimum 8 characters if changing</small>
</td>
</tr>
<!-- Integrated Permission Editing -->
<tr>
<td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
<td style="padding: 0.5rem 0;">
<!-- Common Permissions as Checkboxes -->
<div style="margin-bottom: 0.75rem;">
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Common:</div>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-management" value="user-management"/>
User Management
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-create" value="user.create"/>
Create Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-read" value="user.read"/>
Read Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-update" value="user.update"/>
Update Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-delete" value="user.delete"/>
Delete Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_admin" value="admin"/>
Admin
</label>
</div>
</div>
<!-- Custom Permissions -->
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Custom Permissions:</div>
<div sid="custom-perms" style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem;">
<spry-outlet sid="custom-permission-tags"/>
</div>
<div style="display: flex; gap: 0.25rem;">
<input type="text"
name="new_permission"
sid="new-perm-input"
placeholder="e.g., reports.view"
style="flex: 1; padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem;"/>
<button type="button"
sid="add-perm-btn"
spry-action=":AddPermission"
spry-target="user-details-edit"
style="padding: 0.25rem 0.5rem; font-size: 0.875rem; cursor: pointer;">
Add
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Error Message -->
<div spry-if="this.error_message != null"
style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
<span content-expr="this.error_message"></span>
</div>
<!-- Edit Actions -->
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button type="submit" style="padding: 0.5rem 1rem; cursor: pointer;">Save Changes</button>
<button type="button"
sid="cancel-btn"
spry-action=":CancelEdit"
spry-target="user-details-edit"
style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
Cancel
</button>
</div>
</form>
</div>
</details>
Uses the same pattern as edit mode with integrated permission editing.
<details sid="new-user"
id="new-user"
hx-swap="outerHTML"
open>
<summary style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: #e7f3ff; border-radius: 4px; cursor: pointer; border: 2px dashed #007bff;">
<span style="font-weight: 500; color: #007bff;">+ New User</span>
</summary>
<div style="padding: 1rem; border: 2px dashed #007bff; border-top: none; border-radius: 0 0 4px 4px; background: #f8faff;">
<form sid="create-form"
spry-action=":CreateUser"
spry-target="new-user">
<table style="width: 100%; border-collapse: collapse;">
<tbody>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500; width: 150px;">Username *</td>
<td style="padding: 0.5rem 0;">
<input type="text"
name="username"
sid="username-input"
required
minlength="3"
pattern="[a-zA-Z0-9_]+"
placeholder="Enter username"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
<small style="color: #6c757d;">Alphanumeric and underscores only, min 3 chars</small>
</td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Email *</td>
<td style="padding: 0.5rem 0;">
<input type="email"
name="email"
sid="email-input"
required
placeholder="Enter email address"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
</td>
</tr>
<tr>
<td style="padding: 0.5rem 0; font-weight: 500;">Password *</td>
<td style="padding: 0.5rem 0;">
<input type="password"
name="password"
sid="password-input"
required
minlength="8"
placeholder="Enter password"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"/>
<small style="color: #6c757d;">Minimum 8 characters</small>
</td>
</tr>
<!-- Integrated Permission Editing (same as edit component) -->
<tr>
<td style="padding: 0.5rem 0; font-weight: 500; vertical-align: top;">Permissions</td>
<td style="padding: 0.5rem 0;">
<div style="margin-bottom: 0.75rem;">
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Common:</div>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-management" value="user-management"/>
User Management
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-create" value="user.create"/>
Create Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-read" value="user.read"/>
Read Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-update" value="user.update"/>
Update Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_user-delete" value="user.delete"/>
Delete Users
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input type="checkbox" name="perm_admin" value="admin"/>
Admin
</label>
</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Custom Permissions:</div>
<div sid="custom-perms" style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem;">
<spry-outlet sid="custom-permission-tags"/>
</div>
<div style="display: flex; gap: 0.25rem;">
<input type="text"
name="new_permission"
sid="new-perm-input"
placeholder="e.g., reports.view"
style="flex: 1; padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.875rem;"/>
<button type="button"
sid="add-perm-btn"
spry-action=":AddPermission"
spry-target="new-user"
style="padding: 0.25rem 0.5rem; font-size: 0.875rem; cursor: pointer;">
Add
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Error Message -->
<div spry-if="this.error_message != null"
style="padding: 0.5rem; margin: 0.5rem 0; background: #f8d7da; color: #721c24; border-radius: 4px;">
<span content-expr="this.error_message"></span>
</div>
<!-- Actions -->
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button type="submit" style="padding: 0.5rem 1rem; cursor: pointer; background: #007bff; color: white; border: none; border-radius: 4px;">
Create User
</button>
<button type="button"
sid="cancel-btn"
spry-action=":CancelCreate"
spry-target="user-management"
style="padding: 0.5rem 1rem; cursor: pointer; background: #6c757d; color: white; border: none; border-radius: 4px;">
Cancel
</button>
</div>
</form>
</div>
</details>
All actions must properly target the correct DOM elements for HTMX swapping. The key insight is that view and edit components swap each other out using the same element ID:
| Action | Source Component | Target | Swap | Result |
|---|---|---|---|---|
:StartEdit |
UserDetailsComponent | user-{user_id} |
outerHTML | Swaps in UserDetailEditComponent |
:SaveEdit |
UserDetailEditComponent | user-{user_id} |
outerHTML | Swaps in UserDetailsComponent - view |
:CancelEdit |
UserDetailEditComponent | user-{user_id} |
outerHTML | Swaps in UserDetailsComponent - view |
:AddPermission |
UserDetailEditComponent | user-{user_id} |
outerHTML | Re-renders edit component with new permission |
:RemovePermission |
UserDetailEditComponent | user-{user_id} |
outerHTML | Re-renders edit component without permission |
:DeleteUser |
UserDetailsComponent | user-management |
outerHTML | Refreshes entire user list |
:CreateUser |
NewUserComponent | user-management |
outerHTML | Refreshes list with new user added |
:CancelCreate |
NewUserComponent | user-management |
outerHTML | Hides create form |
:ShowCreateUser |
UserManagementComponent | user-management |
outerHTML | Shows NewUserComponent |
UserManagementComponent: id="user-management" (static, for cross-component targeting)UserDetailsComponent: id="user-{user_id}" (dynamic per user)UserDetailEditComponent: id="user-{user_id}" (same ID - they swap each other)NewUserComponent: id="new-user" (static)sequenceDiagram
participant U as User
participant UV as UserDetailsComponent - View
participant UE as UserDetailEditComponent - Edit
participant UM as UserManagementComponent
participant S as Server/Service
Note over U,S: Edit Flow - Component Swap
U->>UV: Click Edit button
UV->>S: POST with :StartEdit action
S->>S: Create UserDetailEditComponent with user data
S->>UE: Return UserDetailEditComponent - swaps in at same ID
U->>UE: Modify fields, click Save
UE->>S: POST with :SaveEdit action
S->>S: Validate and save to UserService
S->>S: Create UserDetailsComponent with updated data
S->>UV: Return UserDetailsComponent - swaps back at same ID
Note over U,S: Delete Flow
U->>UV: Click Delete button
UV->>S: POST with :DeleteUser action targeting user-management
S->>S: Delete user via UserService
S->>UM: Return refreshed UserManagementComponent
Note over U,S: Create Flow
U->>UM: Click Create User button
UM->>S: POST with :ShowCreateUser action
S->>UM: Return UserManagementComponent with NewUserComponent
U->>UM: Fill form, click Create
UM->>S: POST with :CreateUser action targeting user-management
S->>S: Create user via UserService
S->>UM: Return refreshed UserManagementComponent without form
Each UserDetailsComponent maintains its own editing state:
public class UserDetailsComponent : Component {
// State
private User _user;
public bool is_editing { get; private set; default = false; }
public string? error_message { get; private set; default = null; }
// Exposed for template
public string user_id { get { return _user.id; } }
public string username { get { return _user.username; } }
public string email { get { return _user.email; } }
public string created_at { get { return _user.created_at.format("%Y-%m-%d"); } }
public string[] permissions { get { return _user.permissions; } }
// Actions
public void start_edit() {
is_editing = true;
error_message = null;
}
public void cancel_edit() {
is_editing = false;
error_message = null;
}
}
:StartEdit action, sets is_editing = true, re-rendersis_editing = falseis_editing = false, discards changesThe UserManagementComponent controls whether the create form is visible:
public class UserManagementComponent : Component {
public bool show_create_form { get; private set; default = false; }
public string? success_message { get; private set; default = null; }
public string? error_message { get; private set; default = null; }
public async override void handle_action(string action) throws Error {
switch (action) {
case "ShowCreateUser":
show_create_form = true;
break;
case "CancelCreate":
show_create_form = false;
break;
case "CreateUser":
// Handled by NewUserDetailsComponent, but we refresh here
show_create_form = false;
success_message = "User created successfully";
yield refresh_users_async();
break;
case "DeleteUser":
// Handle delete, refresh list
yield handle_delete_async();
break;
}
}
}
UserManagementComponent sets show_create_form = trueNewUserDetailsComponent renders at top of list with empty fields<div class="admin-container">
<div class="admin-header">
<h1>User Management</h1>
<button class="btn btn-primary">Create User</button>
</div>
<div class="alert alert-success">...</div>
<table class="user-table">...</table>
</div>
<div style="max-width: 1200px; margin: 0 auto; padding: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">User Management</h3>
<button style="padding: 0.5rem 1rem; cursor: pointer;">Create User</button>
</div>
<div style="padding: 0.75rem; margin-bottom: 1rem; background: #d4edda; color: #155724; border-radius: 4px;">...</div>
<!-- Table uses minimal inline styles -->
</div>
<details>, <summary>, <table>)Use CSS custom properties if the application wants to override defaults:
<div style="background: var(--spry-alert-success-bg, #d4edda);">
The redesign uses the existing Spry component action pattern:
handle_action()UserService and PermissionService methods are sufficientComponents continue to use:
UserService: create_user_async, get_user_async, update_user_async, delete_user_async, list_users_asyncPermissionService: set_permission_async, has_permission_by_id_asyncSessionService: authenticate_request_async (for permission checks)ComponentFactory: create child componentsdelete_users_async(string[] ids) for multi-select deletesearch_users_async(string query) to UserService for efficient searchingsrc/Authentication/Components/UserManagementComponent.vala - New container componentsrc/Authentication/Components/UserDetailsComponent.vala - View-only details/summary componentsrc/Authentication/Components/UserDetailEditComponent.vala - Edit mode with integrated permissionssrc/Authentication/Components/NewUserComponent.vala - Create form with integrated permissionssrc/Authentication/meson.build - Add new component files, remove old onesexamples/UsersExample.vala - Update to use new componentsrc/Authentication/Components/UserManagementPage.vala - Replace with UserManagementComponentsrc/Authentication/Components/UserListComponent.vala - Merged into UserManagementComponentsrc/Authentication/Components/UserListItemComponent.vala - Replaced by UserDetailsComponentsrc/Authentication/Components/UserFormComponent.vala - Functionality moved to UserDetailEditComponent and NewUserComponentsrc/Authentication/Components/PermissionEditorComponent.vala - Functionality integrated directly into edit/create components// Register as a page - generates full HTML document
application.add_transient<UserManagementPage>();
application.add_endpoint<UserManagementPage>(new EndpointRoute("/admin/users"));
// Option 1: Use in your own PageComponent
public class AdminPage : PageComponent {
public override string markup {
return """
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<nav>...</nav>
<main>
<!-- Place component anywhere in your layout -->
<spry-component name="Spry.Authentication.Components.UserManagementComponent" sid="user-mgmt"/>
</main>
</body>
</html>
""";
}
}
// Option 2: Create a simple wrapper page
public class UserAdminPage : PageComponent {
private ComponentFactory _factory = inject<ComponentFactory>();
public override string markup {
return """
<!DOCTYPE html>
<html>
<head>
<title>User Management</title>
<script spry-res="htmx.js"></script>
</head>
<body>
<spry-outlet sid="content"/>
</body>
</html>
""";
}
public override async void prepare() throws Error {
var component = _factory.create<UserManagementComponent>();
add_outlet_child("content", component);
add_globals_from(component);
}
}
This redesign simplifies the user management component architecture from 5 components to 3, removes all CSS class dependencies, and implements a modern <details>/<summary> pattern with inline editing. The key benefits are:
UserManagementComponent anywhere in their layout