|
|
@@ -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");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|