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:
@@ -291,6 +291,54 @@ pub async fn admin_safe_delete(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Admin-initiated user creation. Wraps the INSERT + audit row in a
|
||||
/// single transaction so a rolled-back create never leaves an orphan
|
||||
/// audit entry. Caller (HTTP handler) is responsible for validating
|
||||
/// `username`/`password` and hashing — this fn assumes both are
|
||||
/// already vetted by the same `validate_*` rules used by self-
|
||||
/// registration.
|
||||
pub async fn admin_create_user(
|
||||
pool: &PgPool,
|
||||
actor_id: Uuid,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
is_admin: bool,
|
||||
) -> AppResult<User> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let user: User = match sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, $3) \
|
||||
RETURNING id, username, password_hash, created_at, is_admin",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(is_admin)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
|
||||
return Err(AppError::Conflict("username is already taken".into()));
|
||||
}
|
||||
Err(e) => return Err(AppError::Database(e)),
|
||||
};
|
||||
|
||||
crate::repo::admin_audit::insert(
|
||||
&mut *tx,
|
||||
actor_id,
|
||||
"create_user",
|
||||
"user",
|
||||
Some(user.id),
|
||||
serde_json::json!({
|
||||
"username": user.username,
|
||||
"is_admin": user.is_admin,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn bootstrap_admin(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
|
||||
Reference in New Issue
Block a user