diff mrjunejune/admin_api.c @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/admin_api.c	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,704 @@
+#include "mrjunejune/admin_api.h"
+#include "mrjunejune/auth_api.h"
+#include "mrjunejune/template_renderer.h"
+
+#include "auth/auth_store.h"
+#include "auth/auth_crypto.h"
+#include "seobeo/seobeo.h"
+#include "dowa/dowa.h"
+
+#include <openssl/crypto.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+/* ------------------------------------------------------------------ */
+/* Constants                                                            */
+/* ------------------------------------------------------------------ */
+
+#define ADMIN_BODY_MAX_BYTES   4096
+#define ADMIN_UUID_SIZE        37
+
+/* ------------------------------------------------------------------ */
+/* Internal helpers                                                     */
+/* ------------------------------------------------------------------ */
+
+static const char *admin_req_value(
+    Seobeo_Request_Entry *p_req,
+    const char           *key)
+{
+  void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key);
+  return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
+}
+
+static boolean admin_extract_secret_field(
+    Dowa_JSON_Entry *obj,
+    const char      *key,
+    char            *out_buf,
+    size_t           max_len)
+{
+  char *arena_ptr = Dowa_JSON_Get_String(obj, key);
+  if (!arena_ptr || arena_ptr[0] == '\0')
+    return FALSE;
+
+  size_t field_len = strlen(arena_ptr);
+  if (field_len > max_len)
+  {
+    OPENSSL_cleanse(arena_ptr, field_len);
+    return FALSE;
+  }
+
+  memcpy(out_buf, arena_ptr, field_len);
+  out_buf[field_len] = '\0';
+  OPENSSL_cleanse(arena_ptr, field_len);
+  return TRUE;
+}
+
+static Seobeo_Request_Entry *admin_json_response(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *body)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena);
+  Dowa_HashMap_Push_Arena(
+      resp, "content-type", "application/json; charset=utf-8", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena);
+  return resp;
+}
+
+static Seobeo_Request_Entry *admin_error(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *code,
+    const char *message)
+{
+  char buf[512];
+  snprintf(buf, sizeof(buf),
+           "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
+           code, message);
+  char *body = Dowa_Arena_Allocate(p_arena, strlen(buf) + 1);
+  if (body) strcpy(body, buf);
+  return admin_json_response(p_arena, status, body ? body : "{}");
+}
+
+static Seobeo_Request_Entry *admin_html_redirect(
+    Dowa_Arena *p_arena,
+    const char *location)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  Dowa_HashMap_Push_Arena(resp, "status",   "302",          p_arena);
+  Dowa_HashMap_Push_Arena(resp, "Location", (char *)location, p_arena);
+  Dowa_HashMap_Push_Arena(resp, "body",     "",             p_arena);
+  return resp;
+}
+
+/*
+ * Gate: resolve principal, enforce admin+active, reject forced-password.
+ * On failure sets *pp_err_resp and returns FALSE.
+ * On success fills *p_principal and returns TRUE.
+ *
+ * For page requests (is_page==TRUE), unauthenticated → 302 redirect.
+ * For API requests (is_page==FALSE), unauthenticated → 401 JSON.
+ */
+static boolean admin_require_admin(
+    Seobeo_Request_Entry  *p_req,
+    Auth_Principal        *p_principal,
+    Dowa_Arena            *p_arena,
+    boolean                is_page,
+    Seobeo_Request_Entry **pp_err_resp)
+{
+  Auth_Store *store = Auth_API_Get_Store();
+  if (!store)
+  {
+    *pp_err_resp = admin_error(p_arena, "503", "service_unavailable",
+                               "Auth not initialised");
+    return FALSE;
+  }
+
+  boolean found = FALSE;
+  if (!Auth_API_Resolve_Existing_Principal(p_req, p_principal, p_arena, &found))
+  {
+    *pp_err_resp = admin_error(p_arena, "500", "internal_error",
+                               "Session error");
+    return FALSE;
+  }
+
+  if (!found || p_principal->kind != AUTH_PRINCIPAL_USER)
+  {
+    *pp_err_resp = is_page
+        ? admin_html_redirect(p_arena, "/login")
+        : admin_error(p_arena, "401", "unauthenticated",
+                      "Authentication required");
+    return FALSE;
+  }
+
+  if (p_principal->must_change_password)
+  {
+    *pp_err_resp = is_page
+        ? admin_html_redirect(p_arena, "/account/password")
+        : admin_error(p_arena, "403", "password_change_required",
+                      "Password change required");
+    return FALSE;
+  }
+
+  if (strcmp(p_principal->role, "admin") != 0)
+  {
+    *pp_err_resp = admin_error(p_arena, "403", "forbidden",
+                               "Admin access required");
+    return FALSE;
+  }
+
+  *pp_err_resp = NULL;
+  return TRUE;
+}
+
+/*
+ * Validate that `:id` param is a non-empty UUID-shaped string.
+ * Fills id_out (capacity >= 37).  Returns FALSE on invalid/missing.
+ */
+static boolean admin_get_id_param(
+    Seobeo_Request_Entry *p_req,
+    char                 *id_out,
+    size_t                capacity)
+{
+  void *kv = Dowa_HashMap_Get_Ptr(p_req, ":id");
+  if (!kv) return FALSE;
+  const char *val = ((Seobeo_Request_Entry *)kv)->value;
+  if (!val || val[0] == '\0') return FALSE;
+  size_t vlen = strlen(val);
+  if (vlen != 36) return FALSE;  /* UUID is 36 chars + NUL */
+  if (vlen >= capacity) return FALSE;
+  memcpy(id_out, val, vlen);
+  id_out[vlen] = '\0';
+  return TRUE;
+}
+
+/*
+ * Append one JSON user object to buf (returns new offset, -1 on error).
+ * Never includes password_hash, session digests, or guest IDs.
+ * p_arena is used for temporary string escaping.
+ */
+static int admin_append_user_json(
+    char                  *buf,
+    size_t                 capacity,
+    size_t                 offset,
+    boolean                is_first,
+    const Auth_User_Record *u,
+    Dowa_Arena            *p_arena)
+{
+  char *safe_id   = Dowa_JSON_Escape_String(u->id,       0, p_arena);
+  char *safe_user = Dowa_JSON_Escape_String(u->username, 0, p_arena);
+  char *safe_role = Dowa_JSON_Escape_String(u->role,     0, p_arena);
+  char *safe_stat = Dowa_JSON_Escape_String(u->status,   0, p_arena);
+  if (!safe_id || !safe_user || !safe_role || !safe_stat)
+    return -1;
+
+  int n = snprintf(buf + offset, capacity - offset,
+                   "%s{"
+                   "\"id\":\"%s\","
+                   "\"username\":\"%s\","
+                   "\"role\":\"%s\","
+                   "\"status\":\"%s\","
+                   "\"mustChangePassword\":%s,"
+                   "\"createdAt\":%lld,"
+                   "\"updatedAt\":%lld,"
+                   "\"passwordChangedAt\":%lld"
+                   "}",
+                   is_first ? "" : ",",
+                   safe_id, safe_user, safe_role, safe_stat,
+                   u->must_change_password ? "true" : "false",
+                   (long long)u->created_at,
+                   (long long)u->updated_at,
+                   (long long)u->password_changed_at);
+
+  if (n < 0 || (size_t)n >= capacity - offset)
+    return -1;
+  return (int)(offset + (size_t)n);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: GET /admin/users  (page)                                      */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *admin_page_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  Auth_Principal principal;
+  Seobeo_Request_Entry *err = NULL;
+  if (!admin_require_admin(p_req, &principal, p_arena, TRUE, &err))
+    return err;
+
+  char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024);
+  if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/admin/users/index.html", p_arena))
+  {
+    return admin_error(p_arena, "500", "internal_error", "Render failed");
+  }
+
+  Seobeo_Request_Entry *resp = NULL;
+  Dowa_HashMap_Push_Arena(resp, "status", "200", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "content-type", "text/html; charset=utf-8", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena);
+  Dowa_HashMap_Push_Arena(
+      resp, "content-security-policy", "frame-ancestors 'none'", p_arena);
+  Dowa_HashMap_Push_Arena(resp, "body", body, p_arena);
+  return resp;
+}
+
+static Seobeo_Request_Entry *admin_list_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  Auth_Principal principal;
+  Seobeo_Request_Entry *err = NULL;
+  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
+    return err;
+
+  Auth_Store *store = Auth_API_Get_Store();
+
+  /* Pagination params from query string; defaults applied. */
+  int64 page  = 1;
+  int64 limit = ADMIN_API_DEFAULT_LIMIT;
+  const char *qpage  = admin_req_value(p_req, "Query-page");
+  const char *qlimit = admin_req_value(p_req, "Query-limit");
+  if (qpage  && qpage[0]  != '\0') page  = atol(qpage);
+  if (qlimit && qlimit[0] != '\0') limit = atol(qlimit);
+  if (page  < 1)                   page  = 1;
+  if (limit < 1)                   limit = 1;
+  if (limit > ADMIN_API_MAX_LIMIT) limit = ADMIN_API_MAX_LIMIT;
+
+  Dowa_Arena *list_arena = Dowa_Arena_Create(128 * 1024);
+  if (!list_arena)
+    return admin_error(p_arena, "500", "internal_error", "OOM");
+
+  Auth_User_Record *records = NULL;
+  Auth_Store_Result res = Auth_Store_List_Users(store, &records, list_arena);
+  if (res != AUTH_STORE_OK)
+  {
+    Dowa_Arena_Free(list_arena);
+    return admin_error(p_arena, "500", "internal_error", "List users failed");
+  }
+
+  int64 total = (int64)Dowa_Array_Length(records);
+  int64 offset_start = total;
+  if (page - 1 <= total / limit)
+    offset_start = (page - 1) * limit;
+  if (offset_start > total)
+    offset_start = total;
+  int64 offset_end = total - offset_start < limit
+      ? total
+      : offset_start + limit;
+
+  /* Build JSON response in the request arena. */
+  size_t resp_size = 64 + (size_t)(total > 0 ? total : 1) * 400;
+  char *body = Dowa_Arena_Allocate(p_arena, resp_size);
+  if (!body)
+  {
+    Dowa_Arena_Free(list_arena);
+    return admin_error(p_arena, "500", "internal_error", "OOM");
+  }
+
+  int n = snprintf(body, resp_size,
+                   "{\"total\":%lld,\"page\":%lld,\"limit\":%lld,\"users\":[",
+                   (long long)total, (long long)page, (long long)limit);
+  if (n < 0)
+  {
+    Dowa_Arena_Free(list_arena);
+    return admin_error(p_arena, "500", "internal_error", "Encode error");
+  }
+  size_t pos = (size_t)n;
+  boolean first = TRUE;
+
+  for (int64 i = offset_start; i < offset_end && pos < resp_size - 2; i++)
+  {
+    int nw = admin_append_user_json(body, resp_size, pos, first, &records[i], p_arena);
+    if (nw < 0)
+    {
+      Dowa_Arena_Free(list_arena);
+      return admin_error(p_arena, "500", "internal_error", "Encode error");
+    }
+    pos   = (size_t)nw;
+    first = FALSE;
+  }
+
+  if (pos + 2 >= resp_size)
+  {
+    Dowa_Arena_Free(list_arena);
+    return admin_error(p_arena, "500", "internal_error", "Buffer too small");
+  }
+  body[pos++] = ']';
+  body[pos++] = '}';
+  body[pos]   = '\0';
+
+  Dowa_Arena_Free(list_arena);
+  return admin_json_response(p_arena, "200", body);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: POST /api/admin/users  (create)                               */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *admin_create_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  Auth_Principal principal;
+  Seobeo_Request_Entry *err = NULL;
+  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
+    return err;
+
+  if (!Auth_API_Verify_CSRF(p_req, &principal))
+    return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");
+
+  const char *body_str = admin_req_value(p_req, "Body");
+  if (!body_str)
+    return admin_error(p_arena, "400", "bad_request", "Invalid request body");
+
+  size_t body_len = strlen(body_str);
+  if (body_len > ADMIN_BODY_MAX_BYTES)
+  {
+    OPENSSL_cleanse((char *)body_str, body_len);
+    return admin_error(p_arena, "400", "bad_request", "Invalid request body");
+  }
+  Dowa_JSON_Value jv =
+      Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
+  OPENSSL_cleanse((char *)body_str, body_len);
+  if (jv.type != DOWA_JSON_OBJECT)
+    return admin_error(p_arena, "400", "bad_request", "Expected JSON object");
+
+  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
+  char *username_raw  = Dowa_JSON_Get_String(obj, "username");
+  char *role_raw      = Dowa_JSON_Get_String(obj, "role");
+  char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
+  memset(password_buf, 0, sizeof(password_buf));
+  boolean have_password = admin_extract_secret_field(
+      obj, "temporaryPassword", password_buf,
+      AUTH_CRYPTO_PASSWORD_MAX_BYTES);
+  char *password_raw = password_buf;
+
+  if (!username_raw || username_raw[0] == '\0' ||
+      !have_password)
+  {
+    OPENSSL_cleanse(password_buf, sizeof(password_buf));
+    return admin_error(p_arena, "400", "bad_request",
+                       "username and temporaryPassword required");
+  }
+
+  const char *role = "member";
+  if (role_raw && role_raw[0] != '\0')
+  {
+    if (strcmp(role_raw, "admin") != 0 && strcmp(role_raw, "member") != 0)
+    {
+      OPENSSL_cleanse(password_raw, strlen(password_raw));
+      return admin_error(p_arena, "400", "invalid_role",
+                         "role must be admin or member");
+    }
+    role = role_raw;
+  }
+
+  size_t pw_len = strlen(password_raw);
+  if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
+  {
+    OPENSSL_cleanse(password_raw, pw_len);
+    return admin_error(p_arena, "400", "password_policy",
+                       "Password must be 12 to 1024 characters");
+  }
+
+  char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+  Auth_Crypto_Result hr =
+      Auth_Crypto_Password_Hash(password_raw, encoded_hash, sizeof(encoded_hash));
+  OPENSSL_cleanse(password_raw, pw_len);
+
+  if (hr != AUTH_CRYPTO_OK)
+  {
+    OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
+    return admin_error(p_arena, "500", "internal_error", "Hash error");
+  }
+
+  char new_id[ADMIN_UUID_SIZE];
+  Auth_Store *store = Auth_API_Get_Store();
+  Auth_Store_Result cr = Auth_Store_Create_User_Audited(
+      store,
+      username_raw,
+      encoded_hash,
+      role,
+      TRUE,   /* must_change_password */
+      principal.user_id,
+      new_id);
+  OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
+
+  if (cr == AUTH_STORE_CONFLICT)
+    return admin_error(p_arena, "409", "conflict", "Username already exists");
+  if (cr != AUTH_STORE_OK)
+    return admin_error(p_arena, "500", "internal_error", "Create user failed");
+
+  /* Fetch the created record for response. */
+  Auth_User_Record rec;
+  memset(&rec, 0, sizeof(rec));
+  if (Auth_Store_Get_User(store, new_id, &rec) != AUTH_STORE_OK)
+    return admin_error(p_arena, "500", "internal_error", "Fetch failed");
+
+  char *safe_id   = Dowa_JSON_Escape_String(rec.id, 0, p_arena);
+  char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena);
+  char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena);
+  char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena);
+  if (!safe_id || !safe_user || !safe_role || !safe_stat)
+    return admin_error(p_arena, "500", "internal_error", "Encode error");
+
+  char body_buf[512];
+  snprintf(body_buf, sizeof(body_buf),
+           "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\","
+           "\"status\":\"%s\",\"mustChangePassword\":true,"
+           "\"createdAt\":%lld}",
+           safe_id, safe_user, safe_role, safe_stat,
+           (long long)rec.created_at);
+
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
+  if (!body_copy)
+    return admin_error(p_arena, "500", "internal_error", "OOM");
+  strcpy(body_copy, body_buf);
+
+  return admin_json_response(p_arena, "201", body_copy);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: PATCH /api/admin/users/:id  (update)                          */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *admin_update_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  Auth_Principal principal;
+  Seobeo_Request_Entry *err = NULL;
+  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
+    return err;
+
+  if (!Auth_API_Verify_CSRF(p_req, &principal))
+    return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");
+
+  char target_id[ADMIN_UUID_SIZE];
+  if (!admin_get_id_param(p_req, target_id, sizeof(target_id)))
+    return admin_error(p_arena, "400", "bad_request", "Invalid or missing id");
+
+  const char *body_str = admin_req_value(p_req, "Body");
+  if (!body_str)
+    return admin_error(p_arena, "400", "bad_request", "Invalid request body");
+
+  size_t body_len = strlen(body_str);
+  if (body_len > ADMIN_BODY_MAX_BYTES)
+  {
+    OPENSSL_cleanse((char *)body_str, body_len);
+    return admin_error(p_arena, "400", "bad_request", "Invalid request body");
+  }
+  Dowa_JSON_Value jv =
+      Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
+  OPENSSL_cleanse((char *)body_str, body_len);
+  if (jv.type != DOWA_JSON_OBJECT)
+    return admin_error(p_arena, "400", "bad_request", "Expected JSON object");
+
+  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
+  char *op_raw         = Dowa_JSON_Get_String(obj, "op");
+  char temporary_password[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
+  memset(temporary_password, 0, sizeof(temporary_password));
+  boolean have_temporary_password = admin_extract_secret_field(
+      obj, "temporaryPassword", temporary_password,
+      AUTH_CRYPTO_PASSWORD_MAX_BYTES);
+  if (!op_raw || op_raw[0] == '\0')
+  {
+    OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
+    return admin_error(p_arena, "400", "bad_request", "op field required");
+  }
+
+  Auth_Store *store = Auth_API_Get_Store();
+  Auth_Store_Result res = AUTH_STORE_OK;
+
+  if (strcmp(op_raw, "enable") == 0)
+  {
+    res = Auth_Store_Enable_User(store, target_id, principal.user_id);
+  }
+  else if (strcmp(op_raw, "disable") == 0)
+  {
+    res = Auth_Store_Disable_User_And_Revoke_Sessions(
+        store, target_id, principal.user_id);
+  }
+  else if (strcmp(op_raw, "set_role") == 0)
+  {
+    char *new_role = Dowa_JSON_Get_String(obj, "role");
+    if (!new_role || new_role[0] == '\0')
+    {
+      OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
+      return admin_error(p_arena, "400", "bad_request",
+                         "role required for set_role op");
+    }
+    if (strcmp(new_role, "admin") != 0 && strcmp(new_role, "member") != 0)
+    {
+      OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
+      return admin_error(p_arena, "400", "invalid_role",
+                         "role must be admin or member");
+    }
+    res = Auth_Store_Update_Role_And_Revoke_Sessions(
+        store, target_id, new_role, principal.user_id);
+  }
+  else if (strcmp(op_raw, "temp_reset") == 0)
+  {
+    if (!have_temporary_password)
+    {
+      OPENSSL_cleanse(
+          temporary_password, sizeof(temporary_password));
+      return admin_error(p_arena, "400", "bad_request",
+                         "temporaryPassword required for temp_reset op");
+    }
+    char *pw_raw = temporary_password;
+
+    size_t pw_len = strlen(pw_raw);
+    if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
+    {
+      OPENSSL_cleanse(pw_raw, pw_len);
+      return admin_error(p_arena, "400", "password_policy",
+                         "Password must be 12 to 1024 characters");
+    }
+
+    char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
+    Auth_Crypto_Result hr =
+        Auth_Crypto_Password_Hash(pw_raw, encoded_hash, sizeof(encoded_hash));
+    OPENSSL_cleanse(pw_raw, pw_len);
+
+    if (hr != AUTH_CRYPTO_OK)
+    {
+      OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
+      return admin_error(p_arena, "500", "internal_error", "Hash error");
+    }
+
+    res = Auth_Store_Admin_Reset_Password(store, target_id, encoded_hash,
+                                          principal.user_id);
+    OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
+  }
+  else
+  {
+    OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
+    return admin_error(p_arena, "400", "invalid_op",
+                       "op must be enable, disable, set_role, or temp_reset");
+  }
+
+  OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
+
+  if (res == AUTH_STORE_NOT_FOUND)
+    return admin_error(p_arena, "404", "not_found", "User not found");
+  if (res == AUTH_STORE_LAST_ADMIN)
+    return admin_error(p_arena, "409", "last_admin",
+                       "Cannot remove the last active admin");
+  if (res == AUTH_STORE_INVALID_ARG)
+    return admin_error(p_arena, "400", "bad_request", "Invalid argument");
+  if (res != AUTH_STORE_OK)
+    return admin_error(p_arena, "500", "internal_error", "Update failed");
+
+  /* Return updated user record. */
+  Auth_User_Record rec;
+  memset(&rec, 0, sizeof(rec));
+  if (Auth_Store_Get_User(store, target_id, &rec) != AUTH_STORE_OK)
+    return admin_json_response(p_arena, "200", "{\"ok\":true}");
+
+  char *safe_id   = Dowa_JSON_Escape_String(rec.id, 0, p_arena);
+  char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena);
+  char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena);
+  char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena);
+  if (!safe_id || !safe_user || !safe_role || !safe_stat)
+    return admin_error(p_arena, "500", "internal_error", "Encode error");
+
+  char body_buf[512];
+  snprintf(body_buf, sizeof(body_buf),
+           "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\","
+           "\"status\":\"%s\",\"mustChangePassword\":%s,"
+           "\"updatedAt\":%lld}",
+           safe_id, safe_user, safe_role, safe_stat,
+           rec.must_change_password ? "true" : "false",
+           (long long)rec.updated_at);
+
+  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
+  if (!body_copy)
+    return admin_error(p_arena, "500", "internal_error", "OOM");
+  strcpy(body_copy, body_buf);
+
+  return admin_json_response(p_arena, "200", body_copy);
+}
+
+/* ------------------------------------------------------------------ */
+/* Route: DELETE /api/admin/users/:id/sessions  (revoke sessions)       */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *admin_revoke_sessions_handler(
+    Seobeo_Request_Entry *p_req,
+    Dowa_Arena            *p_arena)
+{
+  Auth_Principal principal;
+  Seobeo_Request_Entry *err = NULL;
+  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
+    return err;
+
+  if (!Auth_API_Verify_CSRF(p_req, &principal))
+    return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");
+
+  char target_id[ADMIN_UUID_SIZE];
+  if (!admin_get_id_param(p_req, target_id, sizeof(target_id)))
+    return admin_error(p_arena, "400", "bad_request", "Invalid or missing id");
+
+  Auth_Store *store = Auth_API_Get_Store();
+
+  Auth_Store_Result res = Auth_Store_Revoke_All_Sessions_Audited(
+      store, target_id, NULL, principal.user_id);
+  if (res == AUTH_STORE_NOT_FOUND)
+    return admin_error(p_arena, "404", "not_found", "User not found");
+  if (res != AUTH_STORE_OK)
+    return admin_error(p_arena, "500", "internal_error", "Revoke failed");
+
+  return admin_json_response(p_arena, "200", "{\"ok\":true}");
+}
+
+/* ------------------------------------------------------------------ */
+/* Public API                                                           */
+/* ------------------------------------------------------------------ */
+
+void Admin_API_Register_Routes(void)
+{
+  Seobeo_Router_Register("GET",    "/admin/users",                   admin_page_handler);
+  Seobeo_Router_Register("GET",    "/api/admin/users",               admin_list_handler);
+  Seobeo_Router_Register("POST",   "/api/admin/users",               admin_create_handler);
+  Seobeo_Router_Register("PATCH",  "/api/admin/users/:id",           admin_update_handler);
+  Seobeo_Router_Register("DELETE", "/api/admin/users/:id/sessions",  admin_revoke_sessions_handler);
+}
+
+/* ------------------------------------------------------------------ */
+/* Test hooks                                                           */
+/* ------------------------------------------------------------------ */
+
+#ifdef ADMIN_API_TEST_HOOKS
+Seobeo_Request_Entry *Admin_API_Test_Page_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return admin_page_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Admin_API_Test_List_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return admin_list_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Admin_API_Test_Create_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return admin_create_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Admin_API_Test_Update_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return admin_update_handler(p_req, p_arena); }
+
+Seobeo_Request_Entry *Admin_API_Test_Revoke_Sessions_Handler(
+    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
+{ return admin_revoke_sessions_handler(p_req, p_arena); }
+#endif /* ADMIN_API_TEST_HOOKS */