ソースを参照

Users page, profile page, role gates (admin-gated user/saddle/capability management)

Admin-only /users statum page (newest-first table, create/edit/disable/enable, grant/revoke admin, delete with self-protection and last-admin guards); /profile page for every logged-in user with a change-own-password form (current/new/confirm, wrong current refused); saddle+capability mutations admin-gated per the roles decision (viewing stays open, controls hidden via is_admin on the saddles slot); nav gains the display-guarded Users link and a Profile link in the auth corner. New users test bundle (39 checks) registered as its own shard.
clanker 1 ヶ月 前
親
コミット
cc160d76e3

+ 18 - 0
meson.build

@@ -110,6 +110,18 @@ rules_page = custom_target('rules-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'RulesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+users_page = custom_target('users-page',
+    input: 'src/pages/users.html',
+    output: 'UsersPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'UsersPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+profile_page = custom_target('profile-page',
+    input: 'src/pages/profile.html',
+    output: 'ProfilePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ProfilePage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 # spry:pages-end
 
 # =============================================================================
@@ -172,6 +184,8 @@ app_sources = files(
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/CapabilityActions.vala',
+    'src/actions/UserActions.vala',
+    'src/actions/ProfileActions.vala',
     'src/actions/PushActions.vala',
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/LoginEntrypoint.vala',
@@ -182,6 +196,8 @@ app_sources = files(
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
+    'src/entrypoints/UsersEntrypoint.vala',
+    'src/entrypoints/ProfileEntrypoint.vala',
     # spry:sources-end
 )
 
@@ -202,6 +218,8 @@ app_generated = [
     goal_detail_page,
     saddles_page,
     rules_page,
+    users_page,
+    profile_page,
     # spry:generated-end
 ]
 

+ 7 - 2
src/actions/CapabilityActions.vala

@@ -33,7 +33,10 @@ namespace ClankerSaddle {
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_login(directives(), request, users);
+            // The roles decision: capabilities are admin-managed (the
+            // catalogue feeds the dispatcher's routing).
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
             if (guard != null) {
                 return (!)guard;
             }
@@ -66,7 +69,9 @@ namespace ClankerSaddle {
         protected override Type private_type { get { return typeof(CapabilityActionPrivate); } }
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_login(directives(), request, users);
+            // The roles decision: capabilities are admin-managed.
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
             if (guard != null) {
                 return (!)guard;
             }

+ 14 - 0
src/actions/ProfileActions.vala

@@ -0,0 +1,14 @@
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * The shipped Spry change-own-password action with this app's
+     * post-change destination (the profile page) — the form fields and
+     * verification (current/new/confirm) are Spry's.
+     */
+    public class ChangeMyPasswordAction : Spry.Actions.ChangePasswordAction {
+
+        protected override string login_uri { get { return "/login"; } }
+    }
+}

+ 352 - 0
src/actions/UserActions.vala

@@ -0,0 +1,352 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /**
+     * Sealed target identity carried by the user-management row actions.
+     * `enabled` is only read by SetManagedUserEnabledAction: the
+     * entrypoint seals the row's NEXT state, so the enable/disable button
+     * needs no form field and can never go stale against the table.
+     */
+    public class ManagedUserPrivate : Object {
+        public int64 user_id { get; set; }
+        public bool enabled { get; set; }
+    }
+
+    /** Maps a shared profile-field rejection onto its message. */
+    private static string profile_rejection_message(Spry.Actions.ProfileValidation.Rejection rejection) {
+        switch (rejection) {
+            case Spry.Actions.ProfileValidation.Rejection.USERNAME_TOO_SHORT:
+                return "Username must be at least 3 characters";
+            case Spry.Actions.ProfileValidation.Rejection.INVALID_EMAIL:
+                return "Please enter a valid email address";
+            case Spry.Actions.ProfileValidation.Rejection.NAME_REQUIRED:
+                return "Please enter a first and last name";
+            case Spry.Actions.ProfileValidation.Rejection.DATE_OF_BIRTH_REQUIRED:
+                return "Please enter a date of birth";
+            case Spry.Actions.ProfileValidation.Rejection.INVALID_DATE_OF_BIRTH:
+            default:
+                return "Please enter a valid date of birth (YYYY-MM-DD)";
+        }
+    }
+
+    /**
+     * Shared plumbing for the users page's row actions: the admin guard
+     * (authoritative, re-validated against the user store) and the
+     * last-enabled-admin protection the shipped Spry actions lack.
+     *
+     * Spry's ready-made user management actions cover the store calls but
+     * none of the deployment-safety guards this app's roles decision
+     * needs (self-disable, self-demotion, the last working admin), so the
+     * page owns its actions and reuses UserService + ProfileValidation
+     * underneath.
+     */
+    public abstract class ManagedUserAction : TypedStatumAction<ManagedUserPrivate> {
+
+        protected UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ManagedUserPrivate); } }
+
+        /** Admin-only: every users page mutation is an administrator's. */
+        protected async DirectiveBuilder? admin_guard() throws GLib.Error {
+            return yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+        }
+
+        /** The sealed target id (the user_id form field is the fallback). */
+        protected int64 target_id() {
+            var ctx = request_private;
+            return ctx != null ? ((!)ctx).user_id : Forms.integer(request, "user_id", 0);
+        }
+
+        /**
+         * True when `user_id` is an enabled admin and no other enabled
+         * admin exists: deleting, disabling or demoting them would leave
+         * the deployment without a working administrator.
+         *
+         * In practice the acting admin is themselves an enabled admin, so
+         * this only bites on the self-directed paths and any future
+         * non-page caller that skips the per-row self guards. Defence in
+         * depth, not the primary gate.
+         */
+        protected async bool is_last_enabled_admin(int64 user_id) throws GLib.Error {
+            bool target_is_enabled_admin = false;
+            int enabled_admins = 0;
+            foreach (var user in yield users.list_users(0, 1000)) {
+                if (!user.enabled
+                        || !SpryAuth.permission_matches(user.permissions.to_array(),
+                                SpryAuth.ADMIN_PERMISSION)) {
+                    continue;
+                }
+                enabled_admins++;
+                if (user.id == user_id) {
+                    target_is_enabled_admin = true;
+                }
+            }
+            return target_is_enabled_admin && enabled_admins <= 1;
+        }
+    }
+
+    /**
+     * Creates an account from the users page's New user form (admin
+     * only): the profile fields are validated by Spry's shared
+     * ProfileValidation (identical rules to self-registration), and the
+     * optional `admin` checkbox grants the admin permission on create.
+     */
+    public class CreateUserAction : StatumAction {
+
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            // Passwords are compared and stored verbatim (never trimmed).
+            string password = Forms.field_raw(request, "password");
+            string confirm = Forms.field_raw(request, "confirm_password");
+
+            Spry.Actions.ProfileValidation.Rejection rejection;
+            var fields = Spry.Actions.ProfileValidation.validate(request, out rejection);
+            if (fields == null) {
+                return directives().notify("error", profile_rejection_message(rejection)).navigate("/users");
+            }
+            var profile = (!)fields;
+
+            if (password.length < 6) {
+                return directives().notify("error", "Password must be at least 6 characters").navigate("/users");
+            }
+            if (password != confirm) {
+                return directives().notify("error", "Passwords do not match").navigate("/users");
+            }
+
+            bool admin = Forms.field(request, "admin").length > 0;
+            try {
+                var user = yield users.register_user(profile.username, profile.email,
+                    profile.forename, profile.surname, profile.date_of_birth, password, true);
+                if (admin) {
+                    yield users.set_user_permission(user.id, SpryAuth.ADMIN_PERMISSION);
+                }
+                return directives().notify("success", @"User '$(user.username)' created").navigate("/users");
+            } catch (Error e) {
+                var duplicate = Spry.Actions.ProfileValidation.duplicate_message(e,
+                    "Username already exists. Please choose another.",
+                    "Email already registered.",
+                    "A user with this information already exists.");
+                if (duplicate != null) {
+                    return directives().notify("error", (!)duplicate).navigate("/users");
+                }
+                return directives().notify("error", @"Could not create the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Saves the profile fields of a managed user (the edit expander on
+     * the users page): validated profile fields, the `enabled` checkbox
+     * and an optional `new_password` (the reset-password path; empty
+     * keeps the current hash). Refuses the invoking admin's own account
+     * (self-edits belong on /profile) and disabling the last enabled
+     * admin.
+     */
+    public class AlterManagedUserAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error",
+                        "You cannot edit your own account here. Use your profile page.").navigate("/users");
+            }
+
+            // An absent checkbox means disabled (HTML omits unchecked
+            // boxes), matching Spry's own AlterUserAction semantics.
+            bool enabled = Forms.field(request, "enabled").length > 0;
+            if (!enabled && yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot disable the last enabled administrator").navigate("/users");
+            }
+
+            string new_password = Forms.field_raw(request, "new_password");
+            if (new_password.length > 0 && new_password.length < 6) {
+                return directives().notify("error", "Password must be at least 6 characters").navigate("/users");
+            }
+
+            Spry.Actions.ProfileValidation.Rejection rejection;
+            var fields = Spry.Actions.ProfileValidation.validate(request, out rejection);
+            if (fields == null) {
+                return directives().notify("error", profile_rejection_message(rejection)).navigate("/users");
+            }
+            var profile = (!)fields;
+
+            try {
+                yield users.alter_user(user_id, profile.username, profile.email,
+                    profile.forename, profile.surname, profile.date_of_birth, enabled);
+                if (new_password.length > 0) {
+                    yield users.set_password(user_id, new_password.dup());
+                }
+                return directives().notify("success", "User saved").navigate("/users");
+            } catch (Error e) {
+                var duplicate = Spry.Actions.ProfileValidation.duplicate_message(e,
+                    "Username already exists. Please choose another.",
+                    "Email already registered.",
+                    "A user with this information already exists.");
+                if (duplicate != null) {
+                    return directives().notify("error", (!)duplicate).navigate("/users");
+                }
+                return directives().notify("error", @"Could not save the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Enables or disables a managed user; the next state arrives sealed
+     * (the entrypoint computed it from the row), so the button cannot
+     * race the table. Self-disable and disabling the last enabled admin
+     * are refused.
+     */
+    public class SetManagedUserEnabledAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/users");
+            }
+            int64 user_id = ((!)ctx).user_id;
+            bool enable = ((!)ctx).enabled;
+
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error", "You cannot disable your own account").navigate("/users");
+            }
+            if (!enable && yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot disable the last enabled administrator").navigate("/users");
+            }
+
+            try {
+                yield users.set_user_enabled(user_id, enable);
+                return directives().notify("success", enable ? "User enabled" : "User disabled")
+                    .navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not update the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Grants the admin permission (the role badge on the users page).
+     * Idempotent via UserService; granting admin is always safe. The
+     * guards live on the destructive actions.
+     */
+    public class GrantUserAdminAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+
+            try {
+                var target = yield users.get_user(user_id);
+                if (target == null) {
+                    return directives().notify("error", @"No user exists with id $(user_id)").navigate("/users");
+                }
+                yield users.set_user_permission(user_id, SpryAuth.ADMIN_PERMISSION);
+                return directives().notify("success", "Admin granted").navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not grant admin: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Revokes the admin permission. Refuses the invoking admin's own
+     * account (they would lock themselves out of the page mid-session)
+     * and demoting the last enabled admin.
+     */
+    public class RevokeUserAdminAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error",
+                        "You cannot revoke your own admin permission").navigate("/users");
+            }
+            if (yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot demote the last enabled administrator").navigate("/users");
+            }
+
+            try {
+                yield users.remove_user_permission(user_id, SpryAuth.ADMIN_PERMISSION);
+                return directives().notify("success", "Admin revoked").navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not revoke admin: $(e.message)").navigate("/users");
+            }
+        }
+    }
+
+    /**
+     * Deletes a managed user (their permission rows go with them, via
+     * UserService). Refuses the invoking admin's own account and
+     * deleting the last enabled admin.
+     */
+    public class DeleteManagedUserAction : ManagedUserAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield admin_guard();
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 user_id = target_id();
+            if (user_id <= 0) {
+                return directives().notify("error", "A user is required").navigate("/users");
+            }
+            if (SpryAuth.current(request).user_id == user_id) {
+                return directives().notify("error", "You cannot delete your own account").navigate("/users");
+            }
+            if (yield is_last_enabled_admin(user_id)) {
+                return directives().notify("error",
+                        "Cannot delete the last enabled administrator").navigate("/users");
+            }
+
+            try {
+                yield users.delete_user(user_id);
+                return directives().notify("success", "User deleted").navigate("/users");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the user: $(e.message)").navigate("/users");
+            }
+        }
+    }
+}

+ 64 - 0
src/entrypoints/ProfileEntrypoint.vala

@@ -0,0 +1,64 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /** The logged-in user's own profile card on /profile. */
+    public class ProfileView : Object {
+        public string username { get; set; default = ""; }
+        public string email { get; set; default = ""; }
+        public string name_display { get; set; default = ""; }
+        public bool is_admin { get; set; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto change_password { get; set; }
+    }
+
+    /**
+     * Entrypoint for /profile: the logged-in user's own account (name,
+     * email, username, role) and the change-own-password form. Open to
+     * every logged-in user; the store row (not the signed login
+     * snapshot) is read so a renamed account shows its current profile.
+     */
+    public class ProfileEntrypoint : StatumEntrypoint {
+
+        private UserService users_service = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users_service);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            var user = yield users_service.get_user(me.user_id);
+            if (user == null) {
+                // The session slot outlives a deleted account; the guard
+                // re-validation above already handled disabled ones, so
+                // this only trips a mid-request delete. Bounce to login.
+                return directives().notify("error", "Your account no longer exists.").navigate("/login");
+            }
+
+            var view = new ProfileView() {
+                username = ((!)user).username,
+                email = ((!)user).email,
+                name_display = @"$(((!)user).forename) $(((!)user).surname)".strip(),
+                is_admin = SpryAuth.permission_matches(((!)user).permissions.to_array(),
+                    SpryAuth.ADMIN_PERMISSION),
+                created_display = format_stamp(((!)user).created),
+                change_password = action_registry.author<ChangeMyPasswordAction>()
+            };
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(view);
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "profile",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 11 - 2
src/entrypoints/SaddlesEntrypoint.vala

@@ -71,6 +71,9 @@ namespace ClankerSaddle {
 
     /** Public data of the `saddles` slot. */
     public class SaddlesPublic : Object {
+        /** True when the viewer may see the admin controls (the actions
+         * re-check the permission; this only shapes the page). */
+        public bool is_admin { get; set; }
         public Statum.Model.ActionDto register { get; set; }
         public Statum.Model.ActionDto create_capability { get; set; }
     }
@@ -90,11 +93,16 @@ namespace ClankerSaddle {
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_permission(directives(), request, users,
-                SpryAuth.ADMIN_PERMISSION, "/login");
+            // Viewing is open to every logged-in user (the roles
+            // decision); the page's controls are admin-only, so the
+            // public slot carries the admin flag the template's stm-if
+            // guards bind against. The actions themselves enforce the
+            // permission server-side.
+            var guard = yield SpryAuth.require_login(directives(), request, users);
             if (guard != null) {
                 return (!)guard;
             }
+            bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION);
 
             var capabilities = yield capability_service.list_capabilities();
             var capability_names = new Dictionary<string, string>();
@@ -217,6 +225,7 @@ namespace ClankerSaddle {
             }
 
             var pub = new SaddlesPublic();
+            pub.is_admin = is_admin;
             pub.register = action_registry.author<RegisterSaddleAction>();
             pub.create_capability = action_registry.author<CreateCapabilityAction>();
 

+ 123 - 0
src/entrypoints/UsersEntrypoint.vala

@@ -0,0 +1,123 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+
+namespace ClankerSaddle {
+
+    /** One row of the users table, with its row actions. */
+    public class ManagedUserRowView : Object {
+        public int64 id { get; set; }
+        public string username { get; set; default = ""; }
+        public string email { get; set; default = ""; }
+        public string forename { get; set; default = ""; }
+        public string surname { get; set; default = ""; }
+        public string name_display { get; set; default = ""; }
+        public string date_of_birth { get; set; default = ""; }
+        public bool enabled { get; set; }
+        public bool is_admin { get; set; }
+        public bool is_me { get; set; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? alter { get; set; }
+        public Statum.Model.ActionDto? set_enabled { get; set; }
+        public Statum.Model.ActionDto? grant_admin { get; set; }
+        public Statum.Model.ActionDto? revoke_admin { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** Public data of the `users` slot. */
+    public class UsersPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /users: the account table every row of which
+     * carries its admin role badge, enabled state and row actions (edit
+     * expander, enable/disable, grant/revoke admin, delete). Admin-only
+     * (the authoritative guard; the nav link is hidden for non-admins
+     * but the guard is what keeps the page closed).
+     *
+     * Rows are newest-first: the goal brief's list convention. The
+     * enable/disable action seals the row's NEXT state so the button
+     * never races the table, and the edit expander is prefilled from the
+     * same row (one round trip, no separate fetch).
+     */
+    public class UsersEntrypoint : StatumEntrypoint {
+
+        private UserService users_service = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users_service,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+
+            var rows = new Vector<ManagedUserRowView>();
+            foreach (var user in yield users_service.list_users(0, 1000)) {
+                bool is_admin = SpryAuth.permission_matches(user.permissions.to_array(),
+                    SpryAuth.ADMIN_PERMISSION);
+                var sealed_row = new ManagedUserPrivate() {
+                    user_id = user.id,
+                    enabled = !user.enabled
+                };
+                var view = new ManagedUserRowView() {
+                    id = user.id,
+                    username = user.username,
+                    email = user.email,
+                    forename = user.forename,
+                    surname = user.surname,
+                    name_display = @"$(user.forename) $(user.surname)".strip(),
+                    date_of_birth = user.date_of_birth.format("%Y-%m-%d"),
+                    enabled = user.enabled,
+                    is_admin = is_admin,
+                    is_me = me.user_id == user.id,
+                    created_display = format_stamp(user.created),
+                    alter = action_registry.author_private<AlterManagedUserAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id }),
+                    set_enabled = action_registry.author_private<SetManagedUserEnabledAction, ManagedUserPrivate>(sealed_row),
+                    delete = action_registry.author_private<DeleteManagedUserAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id })
+                };
+                if (is_admin) {
+                    view.revoke_admin = action_registry.author_private<RevokeUserAdminAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id });
+                } else {
+                    view.grant_admin = action_registry.author_private<GrantUserAdminAction, ManagedUserPrivate>(
+                        new ManagedUserPrivate() { user_id = user.id });
+                }
+                rows.add(view);
+            }
+
+            // Newest first. The ids are autoincrement, so id-descending
+            // IS created-descending — including the same-minute ties the
+            // display stamp (minute resolution) cannot distinguish.
+            var sorted = rows.to_array();
+            for (int i = 1; i < sorted.length; i++) {
+                var row = sorted[i];
+                int j = i - 1;
+                while (j >= 0 && sorted[j].id < row.id) {
+                    sorted[j + 1] = sorted[j];
+                    j--;
+                }
+                sorted[j + 1] = row;
+            }
+
+            var pub = new UsersPublic();
+            pub.create = action_registry.author<CreateUserAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["users"] = StateJson.list_array<ManagedUserRowView>(sorted);
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "users",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 9 - 0
src/main.vala

@@ -103,6 +103,8 @@ int main(string[] args) {
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
+        statum.add_page<UsersPage, UsersEntrypoint>();
+        statum.add_page<ProfilePage, ProfileEntrypoint>();
         // spry:pages-end
 
         // spry:actions-begin
@@ -157,6 +159,13 @@ int main(string[] args) {
         statum.action<DetachSaddleCapabilityAction>();
         statum.action<AttachTaskCapabilityAction>();
         statum.action<DetachTaskCapabilityAction>();
+        statum.action<CreateUserAction>();
+        statum.action<AlterManagedUserAction>();
+        statum.action<SetManagedUserEnabledAction>();
+        statum.action<GrantUserAdminAction>();
+        statum.action<RevokeUserAdminAction>();
+        statum.action<DeleteManagedUserAction>();
+        statum.action<ChangeMyPasswordAction>();
         // spry:actions-end
 
         // spry:resources-begin

+ 7 - 0
src/pages/app.css

@@ -872,3 +872,10 @@ dialog#quote-comment-dialog blockquote {
     .grid-2 { grid-template-columns: 1fr; }
     th, td { padding: 0.4rem 0.5rem; font-size: 0.9rem; }
 }
+
+/* Admin role badge (users + profile pages): the plan-family violet the
+ * app already reserves for privileged markings. */
+.badge-admin {
+    background: #f3e8ff;
+    color: var(--plan);
+}

+ 5 - 0
src/pages/main.html

@@ -344,8 +344,13 @@
             <a href="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
+            <!-- The users link is display-guarded (the page's entrypoint
+                 guard is authoritative); auth.permissions rides the
+                 signed auth slot, so an admin sees it everywhere. -->
+            <a stm-if="auth != null && auth.permissions.indexOf('admin') != -1" href="/users">Users</a>
             <!-- spry:nav-end -->
             <span stm-if="auth != null"><strong stm-text="auth.username">user</strong>
+                <a href="/profile" title="Your profile">Profile</a>
                 <button type="button" class="inline" stm-action="auth.logout">Log out</button>
                 <!-- spry:nav-auth-begin -->
                 <!-- spry:nav-auth-end -->

+ 38 - 0
src/pages/profile.html

@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/profile</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Your profile</h2>
+        <p stm-if="profile == null">Loading…</p>
+
+        <div stm-if="profile != null">
+            <div class="card">
+                <h3 stm-text="profile.name_display">name</h3>
+                <p><span stm-attribute.class="profile.is_admin ? 'badge badge-admin' : 'badge'"
+                         stm-text="profile.is_admin ? 'admin' : 'user'">role</span></p>
+                <p class="muted">Username: <strong stm-text="profile.username">username</strong></p>
+                <p class="muted">Email: <span stm-text="profile.email">email</span></p>
+                <p class="muted">Member since <span stm-text="profile.created_display">when</span></p>
+            </div>
+
+            <h3>Change password</h3>
+            <form stm-action="profile.change_password" style="max-width: 24rem">
+                <div class="form-row"><label for="current-password">Current password</label>
+                    <input id="current-password" type="password" name="current_password"
+                           required autocomplete="current-password"></div>
+                <div class="form-row"><label for="new-password">New password</label>
+                    <input id="new-password" type="password" name="new_password"
+                           required autocomplete="new-password"></div>
+                <div class="form-row"><label for="confirm-password">Confirm new password</label>
+                    <input id="confirm-password" type="password" name="confirm_password"
+                           required autocomplete="new-password"></div>
+                <button type="submit">Change password</button>
+            </form>
+        </div>
+    </section>
+</body>
+</html>

+ 17 - 11
src/pages/saddles.html

@@ -10,7 +10,7 @@
         <p stm-if="saddles == null">Loading…</p>
 
         <div stm-if="saddles != null">
-            <details>
+            <details stm-if="saddles.is_admin">
                 <summary><strong>Register saddle</strong></summary>
                 <form stm-action="saddles.register">
                     <div class="form-row"><label>Name</label>
@@ -40,9 +40,9 @@
                         <span class="muted" stm-if="saddle.daemon_state.length > 0"
                               stm-text="'daemon ' + saddle.daemon_state + (saddle.uptime_display.length > 0 ? ' · up ' + saddle.uptime_display : '')">daemon</span>
                         <span class="muted" stm-text="'contacted ' + saddle.last_contact_display + ' ago'">contacted</span></p>
-                    <p stm-if="saddle.token.length > 0" class="muted">API token:
+                    <p stm-if="saddles.is_admin && saddle.token.length > 0" class="muted">API token:
                         <code stm-text="saddle.token">token</code></p>
-                    <details stm-if="saddle.ssh_public_key.length > 0">
+                    <details stm-if="saddles.is_admin && saddle.ssh_public_key.length > 0">
                         <summary>SSH public key</summary>
                         <p class="muted">Add this as a <strong>deploy key</strong> on the git server
                             (with write access) so this saddle can clone and push.</p>
@@ -51,9 +51,9 @@
                                 stm-attribute.data-clipboard="saddle.ssh_public_key"
                                 onclick="navigator.clipboard.writeText(this.getAttribute('data-clipboard')).then(function () { this.textContent = 'Copied'; }.bind(this))">Copy key</button>
                     </details>
-                    <p stm-if="saddle.ssh_public_key.length == 0" class="muted">No SSH key reported yet —
+                    <p stm-if="saddles.is_admin && saddle.ssh_public_key.length == 0" class="muted">No SSH key reported yet —
                         it appears here once the daemon generates one.</p>
-                    <details>
+                    <details stm-if="saddles.is_admin">
                         <summary>Git identity</summary>
                         <form stm-action="saddle.update_identity" class="inline">
                             <label>Committed as</label>
@@ -65,15 +65,20 @@
                             <span class="muted">Empty fields fall back to the daemon's default identity.</span>
                         </form>
                     </details>
-                    <p stm-if="saddle.capabilities.length > 0">Capabilities:
+                    <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                             <span class="badge"><span stm-text="capability.name">tag</span>
                                 <button type="button" class="inline chip-remove"
                                         stm-action="capability.detach" stm-confirm="Remove this capability?">×</button></span>
                         </span>
                     </p>
+                    <p stm-if="!saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
+                        <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
+                            <span class="badge"><span stm-text="capability.name">tag</span></span>
+                        </span>
+                    </p>
                     <p stm-if="saddle.capabilities.length == 0" class="muted">No capabilities — matches only tasks with none required.</p>
-                    <form class="inline" stm-if="saddles.capability_options.length > 0"
+                    <form class="inline" stm-if="saddles.is_admin && saddles.capability_options.length > 0"
                           stm-action="saddle.attach_capability">
                         <label stm-attribute.for="'cap-' + saddle.id">Add capability</label>
                         <select stm-attribute.id="'cap-' + saddle.id" name="capability_id" required>
@@ -83,8 +88,9 @@
                         <button type="submit">Add</button>
                     </form>
                     <form class="inline">
-                        <button type="button" stm-action="saddle.refresh">Refresh status</button>
-                        <button type="button" stm-action="saddle.unregister" stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
+                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.refresh">Refresh status</button>
+                        <button type="button" stm-if="saddles.is_admin" stm-action="saddle.unregister"
+                                stm-confirm="Remove this saddle? A running task is re-queued.">Remove</button>
                     </form>
                 </section>
                 <section class="card" stm-if="saddles.saddles.length == 0">
@@ -95,7 +101,7 @@
             <h3>Capabilities</h3>
             <p class="muted">Tags the dispatcher routes by: a task is only assigned to a saddle carrying every
                 tag the task requires. Manage a saddle's tags on its card above; require tags on a task's page.</p>
-            <details>
+            <details stm-if="saddles.is_admin">
                 <summary><strong>New capability</strong></summary>
                 <form stm-action="saddles.create_capability" class="inline">
                     <label for="new-capability">Name</label>
@@ -110,7 +116,7 @@
                         <td><span class="badge" stm-text="capability.name">tag</span></td>
                         <td stm-text="capability.saddle_count">0</td>
                         <td stm-text="capability.task_count">0</td>
-                        <td><button type="button" stm-action="capability.delete"
+                        <td stm-if="saddles.is_admin"><button type="button" stm-action="capability.delete"
                                     stm-confirm="Delete this capability everywhere?">Delete</button></td>
                     </tr>
                     <tr stm-if="saddles.capabilities.length == 0">

+ 93 - 0
src/pages/users.html

@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/users</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Users</h2>
+        <p class="muted">Accounts and their roles. Normal users manage projects, tasks,
+            goals and rules; admins additionally manage users, saddles and capabilities.</p>
+        <p stm-if="users == null">Loading…</p>
+
+        <div stm-if="users != null">
+            <details>
+                <summary><strong>New user</strong></summary>
+                <form stm-action="users.create">
+                    <div class="form-row"><label>Username</label>
+                        <input type="text" name="username" required placeholder="ada"></div>
+                    <div class="form-row"><label>Email</label>
+                        <input type="email" name="email" required placeholder="ada@example.com"></div>
+                    <div class="form-row"><label>Forename</label>
+                        <input type="text" name="forename" required placeholder="Ada"></div>
+                    <div class="form-row"><label>Surname</label>
+                        <input type="text" name="surname" required placeholder="Lovelace"></div>
+                    <div class="form-row"><label>Date of birth</label>
+                        <input type="text" name="date_of_birth" required placeholder="1990-01-31"></div>
+                    <div class="form-row"><label>Password</label>
+                        <input type="password" name="password" required placeholder="At least 6 characters"></div>
+                    <div class="form-row"><label>Confirm password</label>
+                        <input type="password" name="confirm_password" required></div>
+                    <div class="form-row"><label>Role</label>
+                        <label class="inline"><input type="checkbox" name="admin" value="1"> Administrator</label></div>
+                    <button type="submit">Create user</button>
+                </form>
+            </details>
+
+            <table>
+                <thead><tr>
+                    <th>Username</th><th>Email</th><th>Name</th><th>Role</th>
+                    <th>Enabled</th><th>Created</th><th></th>
+                </tr></thead>
+                <tbody>
+                    <tr stm-for-user-in="users.users" stm-key="user.id">
+                        <td><strong stm-text="user.username">username</strong></td>
+                        <td stm-text="user.email">email</td>
+                        <td stm-text="user.name_display">name</td>
+                        <td><span stm-attribute.class="user.is_admin ? 'badge badge-admin' : 'badge'"
+                                  stm-text="user.is_admin ? 'admin' : 'user'">role</span></td>
+                        <td><span stm-attribute.class="user.enabled ? 'badge badge-done' : 'badge badge-offline'"
+                                  stm-text="user.enabled ? 'enabled' : 'disabled'">enabled</span></td>
+                        <td class="muted" stm-text="user.created_display">when</td>
+                        <td>
+                            <details>
+                                <summary>Edit</summary>
+                                <form stm-action="user.alter">
+                                    <div class="form-row"><label>Username</label>
+                                        <input type="text" name="username" required stm-attribute.value="user.username"></div>
+                                    <div class="form-row"><label>Email</label>
+                                        <input type="email" name="email" required stm-attribute.value="user.email"></div>
+                                    <div class="form-row"><label>Forename</label>
+                                        <input type="text" name="forename" required stm-attribute.value="user.forename"></div>
+                                    <div class="form-row"><label>Surname</label>
+                                        <input type="text" name="surname" required stm-attribute.value="user.surname"></div>
+                                    <div class="form-row"><label>Date of birth</label>
+                                        <input type="text" name="date_of_birth" required stm-attribute.value="user.date_of_birth"></div>
+                                    <div class="form-row"><label>Enabled</label>
+                                        <label class="inline"><input type="checkbox" name="enabled" value="1"
+                                               stm-attribute.checked="user.enabled ? '' : undefined"> Account enabled</label></div>
+                                    <div class="form-row"><label>New password</label>
+                                        <input type="password" name="new_password"
+                                               placeholder="Leave empty to keep the current password"></div>
+                                    <button type="submit">Save</button>
+                                    <span class="muted">Your own account is edited on your profile page.</span>
+                                </form>
+                            </details>
+                            <form class="inline">
+                                <button type="button" stm-action="user.set_enabled"
+                                        stm-text="user.enabled ? 'Disable' : 'Enable'">toggle</button>
+                                <button type="button" stm-if="user.is_admin && !user.is_me" stm-action="user.revoke_admin">Revoke admin</button>
+                                <button type="button" stm-if="!user.is_admin" stm-action="user.grant_admin">Make admin</button>
+                                <button type="button" stm-action="user.delete"
+                                        stm-confirm="Delete this user? Their sessions outlive the account.">Delete</button>
+                            </form>
+                        </td>
+                    </tr>
+                    <tr stm-if="users.users.length == 0"><td colspan="7" class="muted">No users.</td></tr>
+                </tbody>
+            </table>
+        </div>
+    </section>
+</body>
+</html>

+ 2 - 1
test/bundles/__init__.py

@@ -4,7 +4,7 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, pages, push, replay, subtasks)
+               lifecycle, pages, push, replay, subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -18,6 +18,7 @@ BUNDLES = {
     "goals": goals,
     "replay": replay,
     "goalui": goalui,
+    "users": users,
 }
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

+ 151 - 0
test/bundles/users.py

@@ -0,0 +1,151 @@
+"""The users bundle: the user-management page, roles and the profile
+page (the roles decision: normal users manage projects, tasks, goals and
+rules; admins additionally manage users, saddles and capabilities)."""
+
+from harness import *  # noqa: F401,F403
+
+
+def run(ctx):
+    admin = ctx.auth
+
+    check(True, "login yields an auth slot")
+
+    print("== users page: admin CRUD + newest-first ==")
+    users_state = slot_public(admin, "/users", "users")
+    check(users_state["create"] is not None, "admin sees the create action")
+    seeded = [u for u in users_state["users"] if u["username"] == "admin"]
+    check(len(seeded) == 1 and seeded[0]["is_admin"] and seeded[0]["is_me"],
+          "seeded admin row carries the admin badge and is_me")
+
+    result = act(admin, users_state["create"], {
+        "username": "bob", "email": "bob@example.com",
+        "forename": "Bob", "surname": "Builder",
+        "date_of_birth": "1990-05-05",
+        "password": "bobpass1", "confirm_password": "bobpass1"})
+    check("created" in (action_notify(result) or ""), "create bob")
+    result = act(admin, users_state["create"], {
+        "username": "carol", "email": "carol@example.com",
+        "forename": "Carol", "surname": "Coda",
+        "date_of_birth": "1985-03-03",
+        "password": "carolpass", "confirm_password": "carolpass",
+        "admin": "1"})
+    check("created" in (action_notify(result) or ""), "create carol as admin")
+
+    users_state = slot_public(admin, "/users", "users")
+    order = [u["username"] for u in users_state["users"]]
+    check(order == ["carol", "bob", "admin"], "users table is newest-first: %s" % order)
+
+    bob = [u for u in users_state["users"] if u["username"] == "bob"][0]
+    carol = [u for u in users_state["users"] if u["username"] == "carol"][0]
+    me_row = [u for u in users_state["users"] if u["is_me"]][0]
+    check(bob["is_admin"] is False, "bob has the user badge")
+    check(carol["is_admin"] is True, "carol has the admin badge")
+    # The serialiser drops null properties: a row carries exactly one
+    # of grant/revoke, whichever applies to its current role.
+    check(bob.get("grant_admin") is not None and "revoke_admin" not in bob,
+          "non-admin row carries grant (not revoke)")
+    check(carol.get("revoke_admin") is not None and "grant_admin" not in carol,
+          "admin row carries revoke (not grant)")
+    check(me_row["revoke_admin"] is not None, "own row carries revoke (server refuses it)")
+
+    print("== roles: non-admin refused on /users and the admin mutations ==")
+    bob_auth = login("bob", "bobpass1")
+    frames = directives("/users", bob_auth)
+    navs = [d.get("uri") for d in frames if d.get("type") == "navigate"]
+    check(navs == ["/login"], "non-admin /users entrypoint navigates to /login")
+
+    # The saddles page itself stays OPEN to the non-admin; only its
+    # mutations are admin-gated (the roles decision).
+    saddles_state = slot_public(bob_auth, "/saddles", "saddles")
+    check(saddles_state["is_admin"] is False, "saddles page open, controls hidden for non-admin")
+    result = act(bob_auth, saddles_state["register"], {"name": "x", "host": "h:1"})
+    check(any(d.get("type") == "navigate" for d in result), "non-admin register saddle refused")
+    result = act(bob_auth, saddles_state["create_capability"], {"name": "nope"})
+    check(any(d.get("type") == "navigate" for d in result), "non-admin create capability refused")
+
+    # The admin path stays green after the gates.
+    saddles_state = slot_public(admin, "/saddles", "saddles")
+    result = act(admin, saddles_state["create_capability"], {"name": "gpu"})
+    check("created" in (action_notify(result) or ""), "admin creates a capability")
+
+    print("== self-protection guards ==")
+    result = act(admin, me_row["delete"], {})
+    check("own account" in (action_notify(result) or ""), "self-delete refused")
+    result = act(admin, me_row["set_enabled"], {})
+    check("own account" in (action_notify(result) or ""), "self-disable refused")
+    result = act(admin, me_row["revoke_admin"], {})
+    check("own admin" in (action_notify(result) or ""), "self-demote refused")
+    result = act(admin, me_row["alter"], {
+        "username": "admin", "email": "admin@example.com",
+        "forename": "Admin", "surname": "User",
+        "date_of_birth": "1980-01-01", "enabled": "1"})
+    check("own account" in (action_notify(result) or ""), "self-edit refused (belongs on /profile)")
+
+    print("== admin edits another user: rename + reset password ==")
+    result = act(admin, bob["alter"], {
+        "username": "bob", "email": "bob2@example.com",
+        "forename": "Bob", "surname": "B",
+        "date_of_birth": "1990-05-05", "enabled": "1",
+        "new_password": "bobnewpass"})
+    check("saved" in (action_notify(result) or ""), "edit bob (rename + reset password)")
+    login("bob", "bobnewpass")
+    check(True, "bob logs in with the reset password")
+
+    # disable: the session dies on the next guarded request and the
+    # login is refused.
+    users_state = slot_public(admin, "/users", "users")
+    bob = [u for u in users_state["users"] if u["username"] == "bob"][0]
+    result = act(admin, bob["set_enabled"], {})
+    check("disabled" in (action_notify(result) or ""), "disable bob")
+    frames = directives("/projects", bob_auth)
+    navs = [d.get("uri") for d in frames if d.get("type") == "navigate"]
+    check(navs == ["/login"], "disabled bob's entrypoints bounce to /login")
+
+    result = act(admin, bob["delete"], {})
+    check("deleted" in (action_notify(result) or ""), "delete bob")
+
+    print("== role toggles on another user ==")
+    result = act(admin, carol["revoke_admin"], {})
+    check("revoked" in (action_notify(result) or ""), "demote carol (admin remains)")
+    users_state = slot_public(admin, "/users", "users")
+    carol = [u for u in users_state["users"] if u["username"] == "carol"][0]
+    check(carol["is_admin"] is False, "carol's badge flips to user")
+    result = act(admin, carol["grant_admin"], {})
+    check("granted" in (action_notify(result) or ""), "re-grant carol admin")
+
+    print("== profile: every user changes their own password ==")
+    profile = slot_public(admin, "/profile", "profile")
+    check(profile["username"] == "admin" and profile["is_admin"] is True,
+          "profile shows the own row with the role")
+    result = act(admin, profile["change_password"], {
+        "current_password": "WRONG", "new_password": "newpass9",
+        "confirm_password": "newpass9"})
+    check("incorrect" in (action_notify(result) or ""), "wrong current password refused")
+    result = act(admin, profile["change_password"], {
+        "current_password": "adminpass123", "new_password": "newpass9",
+        "confirm_password": "newpass9"})
+    check("changed" in (action_notify(result) or ""), "password changed")
+    login("admin", "newpass9")
+    check(True, "admin logs in with the new password")
+
+    carol_auth = login("carol", "carolpass")
+    carol_profile = slot_public(carol_auth, "/profile", "profile")
+    check(carol_profile["username"] == "carol", "carol's profile shows her row")
+    result = act(carol_auth, carol_profile["change_password"], {
+        "current_password": "carolpass", "new_password": "carolnew1",
+        "confirm_password": "carolnew1"})
+    check("changed" in (action_notify(result) or ""), "carol changes her password")
+    login("carol", "carolnew1")
+    check(True, "carol logs in with the new password")
+
+    # The seeded admin password has changed mid-bundle; later login
+    # helpers (if any re-login) use the new one.
+    print("== cleanup: single admin remains ==")
+    users_state = slot_public(admin, "/users", "users")
+    carol = [u for u in users_state["users"] if u["username"] == "carol"][0]
+    result = act(admin, carol["revoke_admin"], {})
+    check("revoked" in (action_notify(result) or ""), "demote carol before delete")
+    result = act(admin, carol["delete"], {})
+    check("deleted" in (action_notify(result) or ""), "delete carol")
+    check([u["username"] for u in slot_public(admin, "/users", "users")["users"]] == ["admin"],
+          "the admin remains the sole user")

+ 1 - 1
test/harness.py

@@ -1270,7 +1270,7 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui")
+                 "replay", "goalui", "users")
 
 
 def run_bundle(name):