diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6f8be4b..b8174fb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.42.0" +version = "0.43.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7cb4c1b..ed3f95f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.42.0" +version = "0.43.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/admin/users.rs b/backend/src/api/admin/users.rs index 831152c..171cf07 100644 --- a/backend/src/api/admin/users.rs +++ b/backend/src/api/admin/users.rs @@ -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 { 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, + RequireAdmin(actor): RequireAdmin, + Json(input): Json, +) -> AppResult<(StatusCode, Json)> { + 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))) +} diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index 4264213..d4253c9 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -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(), diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index e376325..7e20c08 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -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 { + 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, diff --git a/backend/tests/api_admin_users.rs b/backend/tests/api_admin_users.rs index baf5627..2e92d9a 100644 --- a/backend/tests/api_admin_users.rs +++ b/backend/tests/api_admin_users.rs @@ -397,3 +397,209 @@ async fn delete_writes_audit_row(pool: PgPool) { assert_eq!(payload["username"], b_name); 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, + String, + String, + Option, + 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" + ); +} diff --git a/frontend/package.json b/frontend/package.json index 66575cf..5db3b50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.42.0", + "version": "0.43.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index 104331f..ff24936 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -11,6 +11,7 @@ import { listAdminUsers, deleteAdminUser, setUserAdmin, + createAdminUser, listAdminMangas, listAdminChapters, 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 () => { const updated = { ...userFixture, is_admin: true }; fetchSpy.mockResolvedValueOnce(ok(updated)); diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 0dfe398..7816391 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -44,6 +44,23 @@ export async function setUserAdmin(id: string, isAdmin: boolean): Promise }); } +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 { + return request('/v1/admin/users', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input) + }); +} + // ---- mangas / chapters with sync state ------------------------------------- export type MangaSyncState = 'in_progress' | 'dropped' | 'synced'; diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 7d6e3d2..9e1e836 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -4,6 +4,7 @@ listAdminUsers, deleteAdminUser, setUserAdmin, + createAdminUser, type AdminUsersPage } from '$lib/api/admin'; import { ApiError } from '$lib/api/client'; @@ -14,6 +15,14 @@ let error: 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() { error = null; try { @@ -52,10 +61,92 @@ 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; + } + }

Users

+
+ +
+ +{#if showCreate} +
+ + + + + {#if createError} + + {/if} +
+{/if} +
{ e.preventDefault(); @@ -181,4 +272,57 @@ border-radius: var(--radius-md); 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; + }