feat(auth): login, roles, user mgmt, setup wizard, and session guard
- New skald-setup crate: interactive first-run wizard that creates the admin user, prompts for encryption choice and password - Auth system: session-based login/logout with cookie, guard middleware - Roles API: CRUD for data-driven roles, seeded on first boot - Users management API: create, list, edit, delete users - Setup state API: check if first admin has been created - Frontend: login-page, setup-page, users-page, roles-page, profile-page components with corresponding CSS - Topbar: avatar dropdown with profile link and logout - Sidebar: nav entries for Users and Roles (admin only) - Page shell CSS: layout support for the new pages - build.sh: builds both skald and skald-setup binaries - run.sh: runs skald-setup before the server loop - CLAUDE.md: updated workspace layout and build/run docs
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::{Path, State}};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use skald_core::db::users::UserSummary;
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
use super::ApiError;
|
||||
|
||||
// ── GET /api/users ───────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<UserSummary>>, ApiError> {
|
||||
let users = skald.users().list().await?;
|
||||
Ok(Json(users))
|
||||
}
|
||||
|
||||
// ── POST /api/users ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserBody {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role_id: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreatedUser {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Json(body): Json<CreateUserBody>,
|
||||
) -> Result<Json<CreatedUser>, ApiError> {
|
||||
let username = body.username.trim();
|
||||
if username.is_empty() {
|
||||
return Err(ApiError::bad_request("username must not be empty"));
|
||||
}
|
||||
if body.password.is_empty() {
|
||||
return Err(ApiError::bad_request("password must not be empty"));
|
||||
}
|
||||
let id = skald
|
||||
.users()
|
||||
.register_user(username, body.display_name.as_deref(), &body.role_id, Some(&body.password), body.encrypted)
|
||||
.await?;
|
||||
Ok(Json(CreatedUser { id }))
|
||||
}
|
||||
|
||||
// ── PUT /api/users/:id ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserBody {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role_id: String,
|
||||
#[serde(default)]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateUserBody>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let username = body.username.trim();
|
||||
if username.is_empty() {
|
||||
return Err(ApiError::bad_request("username must not be empty"));
|
||||
}
|
||||
skald_core::db::users::update_profile(
|
||||
skald.db(),
|
||||
&id,
|
||||
username,
|
||||
body.display_name.as_deref(),
|
||||
&body.role_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// active is separate because it's a boolean flip
|
||||
skald_core::db::users::set_active(skald.db(), &id, body.active).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ── DELETE /api/users/:id ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn delete(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
skald.users().delete_user(&id).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ── POST /api/users/:id/password ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordBody {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn reset_password(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ResetPasswordBody>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if body.password.is_empty() {
|
||||
return Err(ApiError::bad_request("password must not be empty"));
|
||||
}
|
||||
// change_password with no old password and a new one resets it. For
|
||||
// encrypted users the admin must supply the old password; this endpoint
|
||||
// is for cleartext users or for admin-initiated resets of cleartext
|
||||
// accounts. For encrypted users, the user should change their own password.
|
||||
skald.users().change_password(&id, None, Some(&body.password)).await
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
Reference in New Issue
Block a user