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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.42.0"
|
version = "0.43.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.42.0"
|
version = "0.43.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ use axum::{Json, Router};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::api::auth::{validate_password, validate_username};
|
||||||
use crate::api::pagination::PagedResponse;
|
use crate::api::pagination::PagedResponse;
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::auth::extractor::RequireAdmin;
|
use crate::auth::extractor::RequireAdmin;
|
||||||
|
use crate::auth::password::hash_password;
|
||||||
use crate::domain::User;
|
use crate::domain::User;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin/users", get(list_users))
|
.route("/admin/users", get(list_users).post(create_user))
|
||||||
.route(
|
.route(
|
||||||
"/admin/users/:id",
|
"/admin/users/:id",
|
||||||
delete(delete_user).patch(update_user),
|
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?;
|
repo::user::admin_safe_delete(&state.db, actor.id, id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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() {
|
if u.is_empty() {
|
||||||
return Err(AppError::InvalidInput("username is required".into()));
|
return Err(AppError::InvalidInput("username is required".into()));
|
||||||
}
|
}
|
||||||
@@ -414,7 +418,7 @@ fn validate_username(u: &str) -> AppResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_password(p: &str) -> AppResult<()> {
|
pub(crate) fn validate_password(p: &str) -> AppResult<()> {
|
||||||
if p.len() < 8 {
|
if p.len() < 8 {
|
||||||
return Err(AppError::InvalidInput(
|
return Err(AppError::InvalidInput(
|
||||||
"password must be at least 8 characters".into(),
|
"password must be at least 8 characters".into(),
|
||||||
|
|||||||
@@ -291,6 +291,54 @@ pub async fn admin_safe_delete(
|
|||||||
Ok(())
|
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(
|
pub async fn bootstrap_admin(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
username: &str,
|
username: &str,
|
||||||
|
|||||||
@@ -397,3 +397,209 @@ async fn delete_writes_audit_row(pool: PgPool) {
|
|||||||
assert_eq!(payload["username"], b_name);
|
assert_eq!(payload["username"], b_name);
|
||||||
assert_eq!(payload["was_admin"], false);
|
assert_eq!(payload["was_admin"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- POST /admin/users (admin-create) --------------------------------------
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_requires_admin(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_username, cookie) = common::register_user(&h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "newbie", "password": "hunter2hunter2" }),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_unauthenticated_is_rejected(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "newbie", "password": "hunter2hunter2" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_happy_path_creates_user_and_audit(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "invited01", "password": "freshpass1234" }),
|
||||||
|
&a_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["username"], "invited01");
|
||||||
|
assert_eq!(body["is_admin"], false);
|
||||||
|
assert!(body["id"].as_str().is_some());
|
||||||
|
assert!(
|
||||||
|
body.get("password_hash").is_none(),
|
||||||
|
"password_hash must never appear in admin-create response"
|
||||||
|
);
|
||||||
|
|
||||||
|
let target_id =
|
||||||
|
Uuid::parse_str(body["id"].as_str().unwrap()).unwrap();
|
||||||
|
let (actor, action, kind, target, payload): (
|
||||||
|
Option<Uuid>,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<Uuid>,
|
||||||
|
serde_json::Value,
|
||||||
|
) = sqlx::query_as(
|
||||||
|
"SELECT actor_user_id, action, target_kind, target_id, payload \
|
||||||
|
FROM admin_audit ORDER BY at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(actor, Some(a_id));
|
||||||
|
assert_eq!(action, "create_user");
|
||||||
|
assert_eq!(kind, "user");
|
||||||
|
assert_eq!(target, Some(target_id));
|
||||||
|
assert_eq!(payload["username"], "invited01");
|
||||||
|
assert_eq!(payload["is_admin"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_can_mint_an_admin_in_one_call(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({
|
||||||
|
"username": "newadmin",
|
||||||
|
"password": "freshpass1234",
|
||||||
|
"is_admin": true
|
||||||
|
}),
|
||||||
|
&a_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["is_admin"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_returns_409_on_duplicate(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||||
|
// Seed an existing user via the public register path.
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json!({ "username": "taken", "password": "hunter2hunter2" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "Taken", "password": "freshpass1234" }),
|
||||||
|
&a_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"case-insensitive collision via the lower(username) index"
|
||||||
|
);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["error"]["code"], "conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_rejects_weak_password(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "okayname", "password": "short" }),
|
||||||
|
&a_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["error"]["code"], "invalid_input");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_rejects_invalid_username(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "bad name!", "password": "freshpass1234" }),
|
||||||
|
&a_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn create_user_works_even_when_self_register_disabled(pool: PgPool) {
|
||||||
|
// The admin-create path must NOT be gated by ALLOW_SELF_REGISTER —
|
||||||
|
// that's the entire point of having an admin-create endpoint.
|
||||||
|
let h = common::harness_with_self_register_disabled(pool.clone());
|
||||||
|
// Bootstrap an admin out-of-band since self-register would refuse.
|
||||||
|
repo::user::bootstrap_admin(&pool, "root", "hunter2hunter2")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json!({ "username": "root", "password": "hunter2hunter2" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let cookie = common::extract_session_cookie(&resp).unwrap();
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/admin/users",
|
||||||
|
json!({ "username": "invited01", "password": "freshpass1234" }),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::CREATED,
|
||||||
|
"admin must be able to mint users even with self-register off"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.42.0",
|
"version": "0.43.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
deleteAdminUser,
|
deleteAdminUser,
|
||||||
setUserAdmin,
|
setUserAdmin,
|
||||||
|
createAdminUser,
|
||||||
listAdminMangas,
|
listAdminMangas,
|
||||||
listAdminChapters,
|
listAdminChapters,
|
||||||
getSystemStats
|
getSystemStats
|
||||||
@@ -126,6 +127,49 @@ describe('admin api client', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createAdminUser POSTs to /v1/admin/users with body and returns the created user', async () => {
|
||||||
|
const created = { ...userFixture, username: 'invited01' };
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(created, 201));
|
||||||
|
const got = await createAdminUser({
|
||||||
|
username: 'invited01',
|
||||||
|
password: 'freshpass1234'
|
||||||
|
});
|
||||||
|
expect(got).toEqual(created);
|
||||||
|
const url = fetchSpy.mock.calls[0][0] as string;
|
||||||
|
expect(url).toMatch(/\/v1\/admin\/users$/);
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
username: 'invited01',
|
||||||
|
password: 'freshpass1234'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createAdminUser forwards is_admin when provided', async () => {
|
||||||
|
const created = { ...userFixture, username: 'coadmin', is_admin: true };
|
||||||
|
fetchSpy.mockResolvedValueOnce(ok(created, 201));
|
||||||
|
await createAdminUser({
|
||||||
|
username: 'coadmin',
|
||||||
|
password: 'freshpass1234',
|
||||||
|
is_admin: true
|
||||||
|
});
|
||||||
|
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
username: 'coadmin',
|
||||||
|
password: 'freshpass1234',
|
||||||
|
is_admin: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createAdminUser surfaces 409 conflict on duplicate username', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
envelope(409, 'conflict', 'username is already taken')
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
createAdminUser({ username: 'taken', password: 'freshpass1234' })
|
||||||
|
).rejects.toMatchObject({ status: 409, code: 'conflict' });
|
||||||
|
});
|
||||||
|
|
||||||
it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
|
it('setUserAdmin PATCHes is_admin and returns the updated user', async () => {
|
||||||
const updated = { ...userFixture, is_admin: true };
|
const updated = { ...userFixture, is_admin: true };
|
||||||
fetchSpy.mockResolvedValueOnce(ok(updated));
|
fetchSpy.mockResolvedValueOnce(ok(updated));
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ export async function setUserAdmin(id: string, isAdmin: boolean): Promise<User>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateAdminUserInput = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
is_admin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** POST /v1/admin/users — admin-initiated account creation. Works
|
||||||
|
* regardless of the ALLOW_SELF_REGISTER toggle, since the entire
|
||||||
|
* point is for an admin to enroll someone when self-register is off. */
|
||||||
|
export async function createAdminUser(input: CreateAdminUserInput): Promise<User> {
|
||||||
|
return request<User>('/v1/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- mangas / chapters with sync state -------------------------------------
|
// ---- mangas / chapters with sync state -------------------------------------
|
||||||
|
|
||||||
export type MangaSyncState = 'in_progress' | 'dropped' | 'synced';
|
export type MangaSyncState = 'in_progress' | 'dropped' | 'synced';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
deleteAdminUser,
|
deleteAdminUser,
|
||||||
setUserAdmin,
|
setUserAdmin,
|
||||||
|
createAdminUser,
|
||||||
type AdminUsersPage
|
type AdminUsersPage
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
@@ -14,6 +15,14 @@
|
|||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let busyId: string | null = $state(null);
|
let busyId: string | null = $state(null);
|
||||||
|
|
||||||
|
// Create-user form (collapsed by default).
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let newUsername = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let newIsAdmin = $state(false);
|
||||||
|
let createError: string | null = $state(null);
|
||||||
|
let creating = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
@@ -52,10 +61,92 @@
|
|||||||
busyId = null;
|
busyId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCreate(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
createError = null;
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
await createAdminUser({
|
||||||
|
username: newUsername.trim(),
|
||||||
|
password: newPassword,
|
||||||
|
is_admin: newIsAdmin
|
||||||
|
});
|
||||||
|
// Reset form + reload list so the new row is visible.
|
||||||
|
newUsername = '';
|
||||||
|
newPassword = '';
|
||||||
|
newIsAdmin = false;
|
||||||
|
showCreate = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
createError = e instanceof ApiError ? e.message : 'create failed';
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showCreate = !showCreate)}
|
||||||
|
data-testid="admin-users-toggle-create"
|
||||||
|
>
|
||||||
|
{showCreate ? 'Cancel' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<form class="create-form" onsubmit={onCreate} data-testid="admin-users-create-form">
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newUsername}
|
||||||
|
minlength="3"
|
||||||
|
maxlength="32"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
data-testid="admin-users-create-username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-testid="admin-users-create-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={newIsAdmin}
|
||||||
|
data-testid="admin-users-create-is-admin"
|
||||||
|
/>
|
||||||
|
<span>Make admin</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="primary"
|
||||||
|
disabled={creating}
|
||||||
|
data-testid="admin-users-create-submit"
|
||||||
|
>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
{#if createError}
|
||||||
|
<p class="error" role="alert" data-testid="admin-users-create-error">
|
||||||
|
{createError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -181,4 +272,57 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.create-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: end;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.create-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.create-form .field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.create-form .field input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.create-form .field-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
.create-form .primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.create-form .primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
border-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
.create-form .error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user