feat(api): admin-initiated user creation via POST /admin/users (0.43.0)
Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
This commit is contained in:
@@ -12,16 +12,18 @@ use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::auth::{validate_password, validate_username};
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::domain::User;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/users", get(list_users))
|
||||
.route("/admin/users", get(list_users).post(create_user))
|
||||
.route(
|
||||
"/admin/users/:id",
|
||||
delete(delete_user).patch(update_user),
|
||||
@@ -90,3 +92,37 @@ async fn delete_user(
|
||||
repo::user::admin_safe_delete(&state.db, actor.id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserInput {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Defaults to false; admins may mint other admins in a single
|
||||
/// call. Doing it as one POST avoids a second audit row for the
|
||||
/// common "invite a co-admin" flow.
|
||||
#[serde(default)]
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(actor): RequireAdmin,
|
||||
Json(input): Json<CreateUserInput>,
|
||||
) -> AppResult<(StatusCode, Json<User>)> {
|
||||
let username = input.username.trim();
|
||||
// Reuse the canonical self-register validators so the admin-create
|
||||
// path can never produce a username that self-register would
|
||||
// reject (and vice versa).
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
let pwhash = hash_password(&input.password)?;
|
||||
let user = repo::user::admin_create_user(
|
||||
&state.db,
|
||||
actor.id,
|
||||
username,
|
||||
&pwhash,
|
||||
input.is_admin,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(user)))
|
||||
}
|
||||
|
||||
@@ -397,7 +397,11 @@ fn check_auth_rate_limit(state: &AppState, endpoint: &'static str) -> AppResult<
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_username(u: &str) -> AppResult<()> {
|
||||
// Exposed pub(crate) so the admin user-create handler can apply the
|
||||
// same rules as self-registration. Keeping the lone canonical
|
||||
// implementation here avoids the two paths drifting on min length /
|
||||
// allowed character set.
|
||||
pub(crate) fn validate_username(u: &str) -> AppResult<()> {
|
||||
if u.is_empty() {
|
||||
return Err(AppError::InvalidInput("username is required".into()));
|
||||
}
|
||||
@@ -414,7 +418,7 @@ fn validate_username(u: &str) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(p: &str) -> AppResult<()> {
|
||||
pub(crate) fn validate_password(p: &str) -> AppResult<()> {
|
||||
if p.len() < 8 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"password must be at least 8 characters".into(),
|
||||
|
||||
Reference in New Issue
Block a user