From 7dd77d4ef452ca0e4c711fa5d71c7dca5c3b3035 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Fri, 10 Jul 2026 19:19:25 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 15 +- Cargo.lock | 35 ++- Cargo.toml | 1 + build.sh | 30 ++- crates/skald-core/src/auth/mod.rs | 85 +++++++ crates/skald-core/src/db/mod.rs | 21 +- crates/skald-core/src/db/roles.rs | 124 +++++++++ crates/skald-core/src/db/users.rs | 26 ++ crates/skald-core/src/lib.rs | 1 + crates/skald-core/src/skald/accessors.rs | 1 + crates/skald-core/src/skald/runtime.rs | 4 + crates/skald-core/src/users/mod.rs | 15 ++ crates/skald-setup/Cargo.toml | 21 ++ crates/skald-setup/src/main.rs | 242 ++++++++++++++++++ default.config.yaml | 2 +- run.sh | 25 ++ src/frontend/api/auth.rs | 192 ++++++++++++++ src/frontend/api/guard.rs | 82 ++++++ src/frontend/api/mod.rs | 21 ++ src/frontend/api/roles.rs | 78 ++++++ src/frontend/api/setup.rs | 64 +++++ src/frontend/api/users_mgmt.rs | 120 +++++++++ src/frontend/server.rs | 11 +- web/app.js | 40 +++ web/components/login-page.js | 106 ++++++++ web/components/profile-page.js | 183 +++++++++++++ web/components/roles-page.js | 252 ++++++++++++++++++ web/components/setup-page.js | 152 +++++++++++ web/components/sidebar.js | 12 +- web/components/topbar.js | 61 ++++- web/components/users-page.js | 311 +++++++++++++++++++++++ web/css/page-shell.css | 12 + web/css/setup-page.css | 107 ++++++++ web/css/topbar.css | 89 +++++++ web/css/users-roles.css | 138 ++++++++++ web/index.html | 8 + 36 files changed, 2660 insertions(+), 27 deletions(-) create mode 100644 crates/skald-core/src/auth/mod.rs create mode 100644 crates/skald-core/src/db/roles.rs create mode 100644 crates/skald-setup/Cargo.toml create mode 100644 crates/skald-setup/src/main.rs create mode 100644 src/frontend/api/auth.rs create mode 100644 src/frontend/api/guard.rs create mode 100644 src/frontend/api/roles.rs create mode 100644 src/frontend/api/setup.rs create mode 100644 src/frontend/api/users_mgmt.rs create mode 100644 web/components/login-page.js create mode 100644 web/components/profile-page.js create mode 100644 web/components/roles-page.js create mode 100644 web/components/setup-page.js create mode 100644 web/components/users-page.js create mode 100644 web/css/setup-page.css create mode 100644 web/css/users-roles.css diff --git a/CLAUDE.md b/CLAUDE.md index 4ce6be7..0cd305a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,8 @@ Rust async web app (Tokio + Axum). Runs as a local chat server with LLM tool-calling and a sub-agent system. +> **Never `git commit` unless explicitly asked.** Staging, building, running and testing are fine on your own initiative; creating a commit is not. Do the work, leave it in the working tree, and let the user commit — or ask them to — even when a commit looks like the obvious next step. + ## What this repository is A **dedicated fork** of Skald, turning a single-user personal agent into a **multi-user assistant for a small trusted group** — positioned at families, but see the neutrality rule below. @@ -45,6 +47,7 @@ The application core is the `skald-core` crate; the binaries are **shells** arou | ---- | ---- | | `crates/skald-core/` | Storage, identity, crypto, LLM stack, tools, MCP, sessions. Knows nothing about what runs it: no Tauri, no HTTP server, and **no concrete plugin crate** — `PluginManager` only ever sees `Arc` from `core-api` | | `skald` (root, `src/`) | The server shell: `main.rs`, the Axum `frontend/`, the Tauri `desktop/`, `config.rs`. Constructs the plugin list and hands it to `Skald::new` | +| `crates/skald-setup/` | Guided first-run setup — a terminal shell over `skald-core`. Creates the first admin via `UserManager::register_user` (asking whether to encrypt, default yes). A separate binary so the server never links TTY-prompt deps, and so a future GUI installer is a third shell over the same `UserManager`. `run.sh` runs it before the server loop; it prompts only when `users` is empty **and** stdin is a terminal, otherwise a no-op. `--check` reports readiness by exit code (0 done, 1 needed) | | `crates/core-api/` | The contracts both sides share: `Plugin`, `Tool`, event buses, provider types | Two rules keep the boundary real, and both are enforced by the compiler: @@ -144,12 +147,16 @@ Use it to pick up `config.yml` / database changes, which are only read at startu ## Build & run ```sh -./build.sh # cargo build --release → bin/skald (atomic install) -./build.sh -d # debug profile; extra args are forwarded to cargo -./run.sh # supervisor loop over the pre-built binary — never compiles +./build.sh # release build → bin/skald and bin/skald-setup (atomic install) +./build.sh -d # debug profile; extra args are forwarded to the server build +./run.sh # first-run setup, then the supervisor loop — never compiles ``` -`run.sh` resolves the binary as `$SKALD_BIN` → `bin/skald` → `target/release/skald`, and warns when sources are newer than the binary. Exit `0` stops the loop, `255` re-executes, anything else propagates. +`build.sh` builds and installs **both** binaries; forwarded args (e.g. `--features desktop`) go to the server only. + +`run.sh` resolves the server binary as `$SKALD_BIN` → `bin/skald` → `target/release/skald`, and warns when sources are newer than it. Before the loop it runs `skald-setup` (found next to the server, or `$SKALD_SETUP_BIN`); a non-zero exit there — a failed or cancelled wizard — stops `run.sh` before the server starts. Server exit `0` stops the loop, `255` re-executes, anything else propagates. + +> In a **debug** build, Argon2id at 256 MiB is unoptimised and takes far longer than the ~1s of a release build — `skald-setup -d` will feel stuck at the password step. Use the release binary for anything interactive. Tracing filter: `RUST_LOG=skald=debug,info` diff --git a/Cargo.lock b/Cargo.lock index f684d8d..f10468d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4345,7 +4345,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -4869,6 +4869,27 @@ dependencies = [ "serde", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5617,6 +5638,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "skald-setup" +version = "0.1.0" +dependencies = [ + "anyhow", + "rpassword", + "skald-core", + "tokio", +] + [[package]] name = "slab" version = "0.4.12" @@ -6467,7 +6498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 693bb60..3661065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ ".", "crates/skald-core", + "crates/skald-setup", "crates/honcho-client", "crates/llm-client", "crates/core-api", diff --git a/build.sh b/build.sh index 10f6fda..02f41f0 100755 --- a/build.sh +++ b/build.sh @@ -14,7 +14,6 @@ set -eu cd "$(dirname "$0")" -BIN_NAME="skald" OUT_DIR="bin" PROFILE="release" @@ -27,21 +26,30 @@ fi RUSTFLAGS="-A warnings" export RUSTFLAGS +# The server takes any forwarded args (e.g. --features desktop); the setup wizard +# is a plain binary and is always built on its own, without them. if [ "$PROFILE" = "release" ]; then cargo build --release "$@" + cargo build --release -p skald-setup else cargo build "$@" + cargo build -p skald-setup fi -SRC="target/$PROFILE/$BIN_NAME" -if [ ! -f "$SRC" ]; then - echo "[build.sh] Expected binary not found at $SRC" >&2 - exit 1 -fi +# Stage as .new and rename into place: a plain cp over a live binary fails with +# ETXTBSY on Linux while run.sh supervises a running instance. +install_bin() { + src="target/$PROFILE/$1" + if [ ! -f "$src" ]; then + echo "[build.sh] Expected binary not found at $src" >&2 + exit 1 + fi + cp "$src" "$OUT_DIR/$1.new" + chmod 755 "$OUT_DIR/$1.new" + mv -f "$OUT_DIR/$1.new" "$OUT_DIR/$1" + echo "[build.sh] $PROFILE build installed → $OUT_DIR/$1" +} mkdir -p "$OUT_DIR" -cp "$SRC" "$OUT_DIR/$BIN_NAME.new" -chmod 755 "$OUT_DIR/$BIN_NAME.new" -mv -f "$OUT_DIR/$BIN_NAME.new" "$OUT_DIR/$BIN_NAME" - -echo "[build.sh] $PROFILE build installed → $OUT_DIR/$BIN_NAME" +install_bin skald +install_bin skald-setup diff --git a/crates/skald-core/src/auth/mod.rs b/crates/skald-core/src/auth/mod.rs new file mode 100644 index 0000000..26e74fc --- /dev/null +++ b/crates/skald-core/src/auth/mod.rs @@ -0,0 +1,85 @@ +//! Session management — the layer above `UserManager`. +//! +//! `SessionStore` maps an opaque session token to a user id, in RAM only. +//! It knows nothing about HTTP cookies: the handler layer parses the cookie +//! header and calls [`SessionStore::user_of`] / [`SessionStore::logout`]. +//! +//! The session lifetime mirrors the process lifetime: on restart every pool is +//! dropped (§9), so a surviving session would be useless — the user must +//! re-authenticate to unlock the database anyway. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use tracing::info; + +use crate::users::{AuthError, UserManager}; + +/// Cookie name used across all auth endpoints. +pub const COOKIE_NAME: &str = "skald_session"; + +pub struct SessionStore { + users: Arc, + sessions: RwLock>, +} + +impl SessionStore { + pub fn new(users: Arc) -> Self { + Self { + users, + sessions: RwLock::new(HashMap::new()), + } + } + + /// Authenticates the user and unlocks their database, then mints a session + /// token. Returns the token so the caller can set it as a cookie. + pub async fn login( + &self, + username: &str, + password: &str, + ) -> Result { + let user = self + .users + .by_username(username) + .await + .map_err(AuthError::Internal)? + .ok_or(AuthError::UnknownUser)?; + + // Always verify the password, even if the pool is already unlocked — + // open_db's idempotency short-circuit would skip the check otherwise. + self.users.verify_credentials(&user.id, password).await?; + + // open_db handles pool lifecycle: unlocks if needed, no-op if open. + self.users.open_db(&user.id, Some(password)).await?; + + let token = uuid::Uuid::new_v4().to_string(); + self.sessions + .write() + .expect("sessions map poisoned") + .insert(token.clone(), user.id.clone()); + + info!(user = %user.id, %username, "session created"); + Ok(token) + } + + /// Returns the user id for a session token, or `None` if unknown / expired. + pub fn user_of(&self, token: &str) -> Option { + self.sessions + .read() + .ok()? + .get(token) + .cloned() + } + + /// Removes a single session. The database pool stays open (§9). + pub fn logout(&self, token: &str) { + if let Some(user_id) = self + .sessions + .write() + .expect("sessions map poisoned") + .remove(token) + { + info!(user = %user_id, "session removed"); + } + } +} diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index ed7b2f3..dca146a 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -13,6 +13,7 @@ pub mod llm_requests; pub mod mcp_events; pub mod mcp_servers; pub mod plugins; +pub mod roles; pub mod scheduled_jobs; pub mod scratchpad; pub mod session_mcp_grants; @@ -349,16 +350,26 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { // the registry — which means it must never hold anything that derives a // user's key: `database_password` is the DEK sealed under a key derived // from the password, useless without it. - // - // `role_id` has no `REFERENCES roles(id)` yet: sqlx turns on - // `PRAGMA foreign_keys`, so pointing at a table that does not exist would - // make every INSERT fail. The constraint lands with the `roles` table. + sqlx::query( + "CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + permission_group TEXT NOT NULL, + attrs TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + ) + .execute(pool) + .await?; + + crate::db::roles::seed_admin(pool).await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, display_name TEXT, - role_id TEXT NOT NULL, + role_id TEXT NOT NULL REFERENCES roles(id), encrypted INTEGER NOT NULL, kdf_params TEXT, kdf_salt BLOB, diff --git a/crates/skald-core/src/db/roles.rs b/crates/skald-core/src/db/roles.rs new file mode 100644 index 0000000..eee28c9 --- /dev/null +++ b/crates/skald-core/src/db/roles.rs @@ -0,0 +1,124 @@ +use anyhow::{Result, bail}; +use serde::Serialize; +use sqlx::SqlitePool; + +/// The built-in admin role — immutable from the API. +pub const ADMIN_ROLE_ID: &str = "admin"; + +#[derive(Debug, Clone, Serialize)] +pub struct Role { + pub id: String, + pub label: String, + pub permission_group: String, + pub attrs: Option, + pub created_at: String, +} + +type RawRow = (String, String, String, Option, String); + +fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role { + Role { id, label, permission_group, attrs, created_at } +} + +// ── Reads ──────────────────────────────────────────────────────────────────── + +pub async fn list(pool: &SqlitePool) -> Result> { + let rows = sqlx::query_as::<_, RawRow>( + "SELECT id, label, permission_group, attrs, created_at + FROM roles + ORDER BY label", + ) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(from_raw).collect()) +} + +pub async fn get(pool: &SqlitePool, id: &str) -> Result> { + let row = sqlx::query_as::<_, RawRow>( + "SELECT id, label, permission_group, attrs, created_at + FROM roles WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(from_raw)) +} + +// ── Writes ─────────────────────────────────────────────────────────────────── + +pub async fn insert( + pool: &SqlitePool, + id: &str, + label: &str, + permission_group: &str, + attrs: Option<&str>, +) -> Result<()> { + sqlx::query( + "INSERT INTO roles (id, label, permission_group, attrs) + VALUES (?, ?, ?, ?)", + ) + .bind(id) + .bind(label) + .bind(permission_group) + .bind(attrs) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn update( + pool: &SqlitePool, + id: &str, + label: &str, + permission_group: &str, + attrs: Option<&str>, +) -> Result { + let rows = sqlx::query( + "UPDATE roles SET label = ?, permission_group = ?, attrs = ? WHERE id = ?", + ) + .bind(label) + .bind(permission_group) + .bind(attrs) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + Ok(rows > 0) +} + +pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> { + if id == ADMIN_ROLE_ID { + bail!("cannot delete the built-in admin role"); + } + let rows = sqlx::query("DELETE FROM roles WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if rows == 0 { + bail!("no such role: {id}"); + } + Ok(()) +} + +/// How many users are assigned to a role — prevents deletion when non-zero. +pub async fn user_count(pool: &SqlitePool, role_id: &str) -> Result { + let (n,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users WHERE role_id = ?") + .bind(role_id) + .fetch_one(pool) + .await?; + Ok(n) +} + +// ── Seed ───────────────────────────────────────────────────────────────────── + +/// Inserts the built-in `admin` role. Idempotent. +pub async fn seed_admin(pool: &SqlitePool) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO roles (id, label, permission_group) + VALUES ('admin', 'Administrator', 'default')", + ) + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/skald-core/src/db/users.rs b/crates/skald-core/src/db/users.rs index 4d08dda..b969889 100644 --- a/crates/skald-core/src/db/users.rs +++ b/crates/skald-core/src/db/users.rs @@ -327,6 +327,32 @@ pub async fn set_active(pool: &SqlitePool, id: &str, active: bool) -> Result<()> Ok(()) } +/// Changes the role assignment and optionally the display name in one statement. +pub async fn update_profile( + pool: &SqlitePool, + id: &str, + username: &str, + display_name: Option<&str>, + role_id: &str, +) -> Result<()> { + let n = sqlx::query( + "UPDATE users + SET username = ?2, display_name = ?3, role_id = ?4, updated_at = datetime('now') + WHERE id = ?1", + ) + .bind(id) + .bind(username) + .bind(display_name) + .bind(role_id) + .execute(pool) + .await? + .rows_affected(); + if n == 0 { + bail!("no such user: {id}"); + } + Ok(()) +} + pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: Option<&str>) -> Result<()> { let n = sqlx::query( "UPDATE users SET username = ?2, display_name = ?3, updated_at = datetime('now') diff --git a/crates/skald-core/src/lib.rs b/crates/skald-core/src/lib.rs index 884644a..a1e4cb9 100644 --- a/crates/skald-core/src/lib.rs +++ b/crates/skald-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod boot; pub mod config; +pub mod auth; pub mod config_store; pub mod skald; pub mod agents; diff --git a/crates/skald-core/src/skald/accessors.rs b/crates/skald-core/src/skald/accessors.rs index a2813bd..f8575f8 100644 --- a/crates/skald-core/src/skald/accessors.rs +++ b/crates/skald-core/src/skald/accessors.rs @@ -52,6 +52,7 @@ impl Skald { // Runtime / cross-cutting pub fn db(&self) -> &Arc { &self.rt.db } pub fn users(&self) -> &Arc { &self.rt.users } + pub fn sessions(&self) -> &Arc { &self.rt.sessions } pub fn config(&self) -> &Arc { &self.rt.config } pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties } pub fn system_bus(&self) -> &Arc { &self.rt.system_bus } diff --git a/crates/skald-core/src/skald/runtime.rs b/crates/skald-core/src/skald/runtime.rs index 5539031..d0282a3 100644 --- a/crates/skald-core/src/skald/runtime.rs +++ b/crates/skald-core/src/skald/runtime.rs @@ -17,6 +17,7 @@ use tracing::info; use core_api::events::GlobalEvent; use core_api::system_bus::SystemEventBus; +use crate::auth::SessionStore; use crate::chat_event_bus::ChatEventBus; use crate::config_store::GlobalConfigManager; use crate::users::UserManager; @@ -28,6 +29,7 @@ pub(super) struct Runtime { /// writes: nothing has moved to per-user pools yet. `users` owns those. pub(super) db: Arc, pub(super) users: Arc, + pub(super) sessions: Arc, pub(super) config: Arc, pub(super) config_properties: Vec, pub(super) system_bus: Arc, @@ -46,6 +48,7 @@ impl Runtime { let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool))); let users = Arc::new(UserManager::new(Arc::clone(&pool))); + let sessions = Arc::new(SessionStore::new(Arc::clone(&users))); let system_bus = Arc::new(SystemEventBus::new()); info!("system event bus ready"); @@ -58,6 +61,7 @@ impl Runtime { Runtime { db: pool, users, + sessions, config, config_properties: vec![crate::tic::config_set()], system_bus, diff --git a/crates/skald-core/src/users/mod.rs b/crates/skald-core/src/users/mod.rs index 5290ef2..c6aca84 100644 --- a/crates/skald-core/src/users/mod.rs +++ b/crates/skald-core/src/users/mod.rs @@ -135,6 +135,21 @@ impl UserManager { db::users::count(&self.system).await } + /// Verifies the password **always**, regardless of whether the pool is + /// already unlocked. Use this for login authentication; use [`open_db`] + /// only for pool lifecycle. + pub async fn verify_credentials(&self, id: &str, password: &str) -> Result<(), AuthError> { + let user = db::users::get(&self.system, id) + .await + .map_err(AuthError::Internal)? + .ok_or(AuthError::UnknownUser)?; + if !user.active { + return Err(AuthError::Inactive); + } + self.authenticate(&user, Some(password)).await?; + Ok(()) + } + // ── Unlock registry ─────────────────────────────────────────────────────── /// The user's pool, or `None` when the database is still locked (§9). diff --git a/crates/skald-setup/Cargo.toml b/crates/skald-setup/Cargo.toml new file mode 100644 index 0000000..0bc7f43 --- /dev/null +++ b/crates/skald-setup/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "skald-setup" +version = "0.1.0" +edition = "2024" + +# Guided first-run setup: a thin terminal shell over `skald-core`. Deliberately +# separate from the server binary so a richer installer (a GUI later) can be a +# third shell over the same `UserManager`, and so the server never links the +# TTY-prompt dependencies. It is a *frontend* on provisioning — all the real work +# (envelope, file+row ordering, rollback) lives in `UserManager::register_user`. + +[[bin]] +name = "skald-setup" +path = "src/main.rs" + +[dependencies] +skald-core = { path = "../skald-core" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +anyhow = "1" +# Reads a password without echoing it to the terminal. +rpassword = "7" diff --git a/crates/skald-setup/src/main.rs b/crates/skald-setup/src/main.rs new file mode 100644 index 0000000..76e095a --- /dev/null +++ b/crates/skald-setup/src/main.rs @@ -0,0 +1,242 @@ +//! `skald-setup` — guided first-run setup. +//! +//! A terminal shell over `skald-core`: it opens the system database, and if no +//! user exists yet, walks whoever is at the keyboard through creating the first +//! admin. The real provisioning — envelope encryption, writing the file before +//! the row, rolling back on failure — lives in [`UserManager::register_user`]; +//! this file is only the conversation around it. +//! +//! Run automatically by `run.sh` before the server loop. It is safe to run by +//! hand at any time: every step checks whether it is already satisfied and skips +//! itself, so re-running never duplicates anything. +//! +//! ## When it prompts +//! +//! Only when there is a real person to answer: no users yet **and** stdin is a +//! terminal. Headless (systemd, a container, CI) it prints one line and exits 0 +//! so the server still starts — necessary while nothing consumes `users` yet and +//! the server runs fine with none. `SKALD_SKIP_SETUP=1` forces that path even on +//! a terminal. +//! +//! `--check` reports readiness without prompting: exit 0 if an admin exists, +//! exit 1 if setup is still needed. Anything else is a real error (exit 2). + +use std::io::{self, IsTerminal, Write}; + +use anyhow::{Context, Result}; +use skald_core::db::{self, SYSTEM_DB_PATH}; +use skald_core::users::UserManager; + +/// The role id given to the first user. There is no `roles` table yet, and +/// `users.role_id` has no foreign key, so this is a plain string for now — the +/// seeded `admin` preset (blueprint §12) will adopt it later without a migration. +const ADMIN_ROLE: &str = "admin"; + +/// SQLCipher's per-user privacy is only as strong as the password's entropy +/// times the KDF cost (§5.1). The KDF is fixed; this is the floor we put under +/// the entropy. Not a substitute for a real strength meter — a deliberate, +/// visible minimum. +const MIN_PASSWORD_LEN: usize = 10; + +fn main() -> std::process::ExitCode { + let mode = match parse_args() { + Ok(m) => m, + Err(msg) => { + eprintln!("{msg}"); + return std::process::ExitCode::from(2); + } + }; + + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("skald-setup: failed to start async runtime: {e}"); + return std::process::ExitCode::from(2); + } + }; + + match rt.block_on(run(mode)) { + Ok(code) => code, + Err(e) => { + eprintln!("skald-setup: {e:#}"); + std::process::ExitCode::from(2) + } + } +} + +enum Mode { + /// Prompt if needed, otherwise a no-op. + Run, + /// Report readiness via exit code, never prompt. + Check, +} + +fn parse_args() -> Result { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + None => Ok(Mode::Run), + Some("--check") => Ok(Mode::Check), + Some("--help" | "-h") => Err(usage()), + Some(other) => Err(format!("skald-setup: unknown argument {other:?}\n\n{}", usage())), + } +} + +fn usage() -> String { + "skald-setup — guided first-run setup\n\n\ + usage:\n \ + skald-setup create the first admin if none exists (interactive)\n \ + skald-setup --check exit 0 if setup is done, 1 if still needed\n\n\ + environment:\n \ + SKALD_SKIP_SETUP=1 never prompt, even on a terminal" + .to_string() +} + +async fn run(mode: Mode) -> Result { + // Opening the pool creates `database/system.db` and its schema if absent — + // the same call the server makes, so setup and server agree on the layout. + let pool = db::init_system_pool(SYSTEM_DB_PATH) + .await + .context("opening the system database")?; + let users = UserManager::new(std::sync::Arc::new(pool)); + + let has_admin = users.count().await.context("counting users")? > 0; + + if let Mode::Check = mode { + return Ok(if has_admin { + std::process::ExitCode::SUCCESS + } else { + std::process::ExitCode::FAILURE + }); + } + + // ── Steps ────────────────────────────────────────────────────────────── + // Each is idempotent: it decides for itself whether there is work to do. + // Today there is one. Provider and model setup will be added here as further + // steps, in order, each skipping itself when already configured. + step_first_user(&users, has_admin).await?; + + Ok(std::process::ExitCode::SUCCESS) +} + +/// Create the first admin, or do nothing if one already exists. +async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> { + if has_admin { + // Idempotent re-run, or a second binary got there first. + return Ok(()); + } + + // No one to answer ⇒ don't block the server. It runs fine with no users + // today, so a missing admin is a note, not a failure. + if std::env::var_os("SKALD_SKIP_SETUP").is_some() || !io::stdin().is_terminal() { + eprintln!( + "skald-setup: no users configured. Run `./bin/skald-setup` from a terminal \ + to create the first admin." + ); + return Ok(()); + } + + println!("\nWelcome to Skald. Let's create the first user (the admin).\n"); + + let username = prompt_username()?; + let display_name = prompt_line("Display name (optional): ")?; + let display_name = display_name.trim(); + let display_name = (!display_name.is_empty()).then_some(display_name); + + let encrypt = prompt_encrypt()?; + let password = prompt_new_password()?; + + let id = users + .register_user(&username, display_name, ADMIN_ROLE, Some(&password), encrypt) + .await + .context("creating the admin user")?; + + println!("\n✓ Admin user '{username}' created (id {id})."); + if encrypt { + println!(" Their private database is encrypted. There is no recovery if the password is lost."); + } + println!(); + Ok(()) +} + +// ── Prompts ──────────────────────────────────────────────────────────────── + +fn prompt_username() -> Result { + loop { + let name = prompt_line("Username: ")?; + let name = name.trim(); + if name.is_empty() { + println!(" A username is required."); + continue; + } + // The username is a login handle; keep it to something a person types. + if name.contains(char::is_whitespace) { + println!(" No spaces in a username."); + continue; + } + return Ok(name.to_string()); + } +} + +/// Default yes, with the honest caveat shown before the choice. For the admin — +/// who owns the box — encryption guards against a stolen machine, not against +/// the other users (§2/§4); and it has no recovery. The prompt says so. +fn prompt_encrypt() -> Result { + println!("Encrypt this user's private conversations at rest?"); + println!(" • Only this user, unlocked with their password, can read them."); + println!(" • There is NO recovery: if the password is lost, the data is gone."); + loop { + let ans = prompt_line("Encrypt? [Y/n]: ")?; + match ans.trim().to_lowercase().as_str() { + "" | "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => println!(" Please answer y or n."), + } + } +} + +/// Reads a password twice, without echo, and holds it to a visible floor. +fn prompt_new_password() -> Result { + loop { + let pw = rpassword::prompt_password("Password: ").context("reading password")?; + if let Some(reason) = weak_password(&pw) { + println!(" {reason}"); + continue; + } + let again = rpassword::prompt_password("Confirm password: ").context("reading password")?; + if pw != again { + println!(" The passwords did not match — try again."); + continue; + } + return Ok(pw); + } +} + +/// A blunt weak-password check: a length floor plus the handful of passwords an +/// attacker guesses first. Not a strength meter — enough to stop the obviously +/// hopeless choice on an account whose password *is* the encryption key. +fn weak_password(pw: &str) -> Option { + if pw.chars().count() < MIN_PASSWORD_LEN { + return Some(format!("Too short — use at least {MIN_PASSWORD_LEN} characters.")); + } + const COMMON: &[&str] = &[ + "password", "password1", "1234567890", "12345678", "qwertyuiop", + "letmein", "iloveyou", "admin", "welcome", "changeme", "skald", + ]; + if COMMON.contains(&pw.to_lowercase().as_str()) { + return Some("That is one of the most common passwords — choose another.".to_string()); + } + None +} + +fn prompt_line(label: &str) -> Result { + print!("{label}"); + io::stdout().flush().ok(); + let mut line = String::new(); + let n = io::stdin().read_line(&mut line).context("reading input")?; + if n == 0 { + // EOF on a terminal (Ctrl-D): the person is declining. Stop the whole + // supervisor rather than looping on empty input. + anyhow::bail!("setup cancelled"); + } + Ok(line) +} diff --git a/default.config.yaml b/default.config.yaml index 5e57ece..e4fdda8 100644 --- a/default.config.yaml +++ b/default.config.yaml @@ -1,6 +1,6 @@ server: host: 127.0.0.1 - port: 6000 + port: 9000 # ── Global timezone ───────────────────────────────────────────────────────────── # IANA timezone name applied globally to: diff --git a/run.sh b/run.sh index 953736c..7ecc736 100755 --- a/run.sh +++ b/run.sh @@ -72,6 +72,31 @@ if [ -f "$VENV_DIR/bin/python3" ]; then export PATH="$(pwd)/$VENV_DIR/bin:$PATH" fi +# ── First-run setup ────────────────────────────────────────────────────────── +# Runs once before the server loop. It decides for itself whether there is work: +# it prompts only when no user exists and stdin is a terminal, and is otherwise a +# no-op. Located next to the server binary; $SKALD_SETUP_BIN overrides. +if [ -n "${SKALD_SETUP_BIN:-}" ]; then + SETUP_BIN="$SKALD_SETUP_BIN" +else + SETUP_BIN="$(dirname "$BIN")/skald-setup" + [ -x "$SETUP_BIN" ] || SETUP_BIN="bin/skald-setup" + [ -x "$SETUP_BIN" ] || SETUP_BIN="target/release/skald-setup" +fi + +if [ -x "$SETUP_BIN" ]; then + "$SETUP_BIN" + setup_code=$? + # A non-zero exit means the wizard failed or the person cancelled it (Ctrl-D). + # Don't launch a half-configured instance behind their back — stop the loop. + if [ "$setup_code" -ne 0 ]; then + echo "[run.sh] Setup did not complete (exit $setup_code). Not starting." >&2 + exit "$setup_code" + fi +else + echo "[run.sh] Note: skald-setup not found — skipping first-run setup." +fi + echo "[run.sh] Supervising $BIN" while true; do diff --git a/src/frontend/api/auth.rs b/src/frontend/api/auth.rs new file mode 100644 index 0000000..160bf53 --- /dev/null +++ b/src/frontend/api/auth.rs @@ -0,0 +1,192 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::Extension, + extract::State, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; + +use skald_core::auth::COOKIE_NAME; +use skald_core::skald::Skald; + +use super::guard::AuthUser; +use super::ApiError; + +// ── POST /api/auth/login ───────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct LoginBody { + pub username: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct LoginResult { + pub ok: bool, +} + +/// Authenticates the user, unlocks their database (pool stays open until +/// restart), mints a session token, and sets it as an `HttpOnly` cookie. +pub async fn login( + State(skald): State>, + Json(body): Json, +) -> Result { + let token = skald + .sessions() + .login(&body.username, &body.password) + .await + .map_err(|_| { + ApiError::bad_request("Invalid username or password") + })?; + + let cookie = format!( + "{COOKIE_NAME}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=2592000" + ); + + Ok(( + StatusCode::OK, + [(header::SET_COOKIE, cookie)], + Json(LoginResult { ok: true }), + ) + .into_response()) +} + +// ── GET /api/auth/me ───────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct MeResponse { + pub username: String, + pub display_name: Option, + pub role_id: String, +} + +/// Returns the authenticated user's profile, or 401 if no valid session. +pub async fn me( + State(skald): State>, + headers: HeaderMap, +) -> Result { + let token = match extract_session_token(&headers) { + Some(t) => t, + None => return Ok(StatusCode::UNAUTHORIZED.into_response()), + }; + + let user_id = match skald.sessions().user_of(&token) { + Some(id) => id, + None => return Ok(StatusCode::UNAUTHORIZED.into_response()), + }; + + let user = skald + .users() + .get(&user_id) + .await? + .ok_or_else(|| ApiError::not_found("user not found"))?; + + Ok(Json(MeResponse { + username: user.username, + display_name: user.display_name, + role_id: user.role_id, + }) + .into_response()) +} + +// ── POST /api/auth/logout ──────────────────────────────────────────────────── + +pub async fn logout( + State(skald): State>, + headers: HeaderMap, +) -> Response { + if let Some(token) = extract_session_token(&headers) { + skald.sessions().logout(&token); + } + // Clear the cookie. + let expired = format!( + "{COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0" + ); + ( + StatusCode::OK, + [(header::SET_COOKIE, expired)], + Json(serde_json::json!({ "ok": true })), + ) + .into_response() +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Parses the `Cookie` header and extracts the session token. +fn extract_session_token(headers: &HeaderMap) -> Option { + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + for pair in raw.split(';') { + let pair = pair.trim(); + if let Some(val) = pair.strip_prefix(&format!("{COOKIE_NAME}=")) { + return Some(val.to_string()); + } + } + None +} + +// ── PUT /api/auth/profile — update display name ────────────────────────────── + +#[derive(Deserialize)] +pub struct UpdateProfileBody { + pub display_name: Option, +} + +pub async fn update_profile( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + let user = skald + .users() + .get(&auth.user_id) + .await? + .ok_or_else(|| ApiError::not_found("user not found"))?; + + skald_core::db::users::rename( + skald.db(), + &auth.user_id, + &user.username, + body.display_name.as_deref(), + ) + .await?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ── POST /api/auth/change-password ─────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ChangePasswordBody { + pub current_password: Option, + pub new_password: String, +} + +pub async fn change_password( + State(skald): State>, + Extension(auth): Extension, + Json(body): Json, +) -> Result, ApiError> { + if body.new_password.is_empty() { + return Err(ApiError::bad_request("new password must not be empty")); + } + + skald + .users() + .change_password( + &auth.user_id, + body.current_password.as_deref(), + Some(&body.new_password), + ) + .await + .map_err(|e| match e { + skald_core::users::AuthError::WrongPassword => { + ApiError::bad_request("Current password is incorrect") + } + other => ApiError::bad_request(other.to_string()), + })?; + + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/src/frontend/api/guard.rs b/src/frontend/api/guard.rs new file mode 100644 index 0000000..39c5d35 --- /dev/null +++ b/src/frontend/api/guard.rs @@ -0,0 +1,82 @@ +//! Authentication gate for `/api` — deny by default. +//! +//! Applied as a layer over the whole API router, so **every** endpoint requires +//! a valid session cookie except the handful named in [`is_public`]. A new route +//! is therefore protected the moment it is added, with no extra step; opening it +//! up is the deliberate act. +//! +//! Kept separate from the `auth` handlers on purpose: this is the cross-cutting +//! gate, they are the login/logout endpoints. On a hit it resolves the session +//! once and injects [`AuthUser`] into the request, so downstream handlers — and +//! the per-user pool routing that will land with the multi-user split — read the +//! identity without re-parsing the cookie. + +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::{StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use skald_core::auth::COOKIE_NAME; +use skald_core::skald::Skald; + +/// The authenticated user, injected into request extensions by [`require_auth`] +/// for every gated endpoint. This is where per-user pool routing will read the +/// id once the database split (blueprint §5.1) reaches the call sites. +#[derive(Clone, Debug)] +pub struct AuthUser { + pub user_id: String, +} + +/// Endpoints reachable without a session. Everything else under `/api` needs a +/// valid cookie. +/// +/// - `auth/login` — the bootstrap: you cannot have a session before it. +/// - `auth/logout` — idempotent; tolerating an already-expired session is kinder +/// than answering 401 to someone trying to log out. +/// - `auth/me` — the canonical "am I logged in?" probe; it answers 401 itself. +/// - `setup/status` / `setup/user` — called before the first user exists, and +/// `create_user` already refuses once one does. +fn is_public(path: &str) -> bool { + // The layer sits on the inner router, so the path arrives without the `/api` + // nest prefix — but strip it defensively so a move of the layer can't + // silently open everything up. + let p = path.strip_prefix("/api").unwrap_or(path); + matches!( + p, + "/auth/login" | "/auth/logout" | "/auth/me" | "/setup/status" | "/setup/user" + ) +} + +/// Extracts the session token from the `Cookie` header. +fn session_token(headers: &axum::http::HeaderMap) -> Option { + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + raw.split(';') + .filter_map(|pair| pair.trim().strip_prefix(&format!("{COOKIE_NAME}="))) + .next() + .map(str::to_owned) +} + +/// The gate. Public paths pass through untouched; every other request must carry +/// a cookie that maps to a live session, or it gets 401 before reaching a +/// handler. +pub async fn require_auth( + State(skald): State>, + mut req: Request, + next: Next, +) -> Response { + if is_public(req.uri().path()) { + return next.run(req).await; + } + + match session_token(req.headers()).and_then(|t| skald.sessions().user_of(&t)) { + Some(user_id) => { + req.extensions_mut().insert(AuthUser { user_id }); + next.run(req).await + } + None => StatusCode::UNAUTHORIZED.into_response(), + } +} diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 352f888..836c9ee 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -1,10 +1,12 @@ pub mod agents; +pub mod auth; pub mod commands; pub mod config; pub mod approval; pub mod cron; pub mod dev; pub mod file_watch; +pub mod guard; pub mod stats; pub mod files; pub mod image_generate_models; @@ -15,12 +17,15 @@ pub mod mcp; pub mod mcp_media; pub mod plugins; pub mod projects; +pub mod roles; pub mod run_context; pub mod sessions; +pub mod setup; pub mod transcribe_audio; pub mod transcribe_models; pub mod tts_models; pub mod uploads; +pub mod users_mgmt; pub mod ws; pub mod ws_session; @@ -44,6 +49,15 @@ pub fn router() -> Router> { // Custom slash commands (file-based, read-only listing for autocomplete + /help) .route("/commands", get(commands::list)) .route("/sessions", get(sessions::list_sessions).post(sessions::create)) + // First-run setup + .route("/setup/status", get(setup::status)) + .route("/setup/user", post(setup::create_user)) + // Auth + .route("/auth/login", post(auth::login)) + .route("/auth/me", get(auth::me)) + .route("/auth/logout", post(auth::logout)) + .route("/auth/profile", put(auth::update_profile)) + .route("/auth/change-password", post(auth::change_password)) .route("/sessions/{id}", get(sessions::get_session_detail)) .route("/web/messages", get(sessions::web_messages)) .route("/{source}/messages", get(sessions::source_messages)) @@ -130,6 +144,13 @@ pub fn router() -> Router> { // Plugins .route("/plugins", get(plugins::list)) .route("/plugins/{id}", put(plugins::update)) + // Roles + .route("/roles", get(roles::list).post(roles::create)) + .route("/roles/{id}", put(roles::update).delete(roles::delete)) + // User management + .route("/users", get(users_mgmt::list).post(users_mgmt::create)) + .route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete)) + .route("/users/{id}/password", post(users_mgmt::reset_password)) // Images (generated by image_generate tool) .route("/images/{task_id}", get(images::get_image)) // MCP tool-result media (images/audio/files returned by MCP servers) diff --git a/src/frontend/api/roles.rs b/src/frontend/api/roles.rs new file mode 100644 index 0000000..c397823 --- /dev/null +++ b/src/frontend/api/roles.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use axum::{Json, extract::{Path, State}}; +use serde::Deserialize; + +use skald_core::db::roles::{self, ADMIN_ROLE_ID, Role}; +use skald_core::skald::Skald; + +use super::ApiError; + +pub async fn list(State(skald): State>) -> Result>, ApiError> { + let roles = roles::list(skald.db()).await?; + Ok(Json(roles)) +} + +#[derive(Deserialize)] +pub struct CreateBody { + pub id: String, + pub label: String, + pub permission_group: String, + pub attrs: Option, +} + +pub async fn create( + State(skald): State>, + Json(body): Json, +) -> Result, ApiError> { + let id = body.id.trim(); + if id.is_empty() || id == ADMIN_ROLE_ID { + return Err(ApiError::bad_request("invalid role id")); + } + if body.label.trim().is_empty() { + return Err(ApiError::bad_request("label must not be empty")); + } + roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) + .await?; + let role = roles::get(skald.db(), id).await?.ok_or_else(|| ApiError::not_found("role not found after insert"))?; + Ok(Json(role)) +} + +#[derive(Deserialize)] +pub struct UpdateBody { + pub label: String, + pub permission_group: String, + pub attrs: Option, +} + +pub async fn update( + State(skald): State>, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + if id == ADMIN_ROLE_ID { + return Err(ApiError::bad_request("the built-in admin role cannot be modified")); + } + let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) + .await?; + if !ok { + return Err(ApiError::not_found("role not found")); + } + let role = roles::get(skald.db(), &id).await?.ok_or_else(|| ApiError::not_found("role not found after update"))?; + Ok(Json(role)) +} + +pub async fn delete( + State(skald): State>, + Path(id): Path, +) -> Result, ApiError> { + if id == ADMIN_ROLE_ID { + return Err(ApiError::bad_request("the built-in admin role cannot be deleted")); + } + let count = roles::user_count(skald.db(), &id).await?; + if count > 0 { + return Err(ApiError::bad_request(format!("{count} user(s) still assigned to this role"))); + } + roles::delete(skald.db(), &id).await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/src/frontend/api/setup.rs b/src/frontend/api/setup.rs new file mode 100644 index 0000000..22279b5 --- /dev/null +++ b/src/frontend/api/setup.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; + +use skald_core::skald::Skald; + +use super::ApiError; + +// ── GET /api/setup/status ──────────────────────────────────────────────────── +// +// `needs_setup` is true when no user has ever been created. The frontend uses +// this to decide whether to show the first-run setup screen. + +#[derive(Serialize)] +pub struct SetupStatus { + pub needs_setup: bool, +} + +pub async fn status(State(skald): State>) -> Result, ApiError> { + let count = skald.users().count().await?; + Ok(Json(SetupStatus { needs_setup: count == 0 })) +} + +// ── POST /api/setup/user — create the first (admin) user ──────────────────── + +#[derive(Deserialize)] +pub struct CreateUserBody { + pub username: String, + pub password: String, + #[serde(default)] + pub encrypted: bool, +} + +#[derive(Serialize)] +pub struct CreateUserResult { + pub user_id: String, +} + +pub async fn create_user( + State(skald): State>, + Json(body): Json, +) -> Result, ApiError> { + // Guard: the setup endpoint is only available before the first user exists. + let count = skald.users().count().await?; + if count > 0 { + return Err(ApiError::bad_request("setup is already complete")); + } + + 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, None, "admin", Some(&body.password), body.encrypted) + .await?; + + Ok(Json(CreateUserResult { user_id: id })) +} diff --git a/src/frontend/api/users_mgmt.rs b/src/frontend/api/users_mgmt.rs new file mode 100644 index 0000000..6c0d8a9 --- /dev/null +++ b/src/frontend/api/users_mgmt.rs @@ -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>) -> Result>, 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, + 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>, + Json(body): Json, +) -> Result, 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, + pub role_id: String, + #[serde(default)] + pub active: bool, +} + +pub async fn update( + State(skald): State>, + Path(id): Path, + Json(body): Json, +) -> Result, 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>, + Path(id): Path, +) -> Result, 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>, + Path(id): Path, + Json(body): Json, +) -> Result, 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 }))) +} diff --git a/src/frontend/server.rs b/src/frontend/server.rs index 3e27c44..ae1c260 100644 --- a/src/frontend/server.rs +++ b/src/frontend/server.rs @@ -70,10 +70,19 @@ impl WebServer { skald: Arc, plugin_routers: Vec<(String, Router)>, ) -> Router { + // Gate the API behind a session cookie (deny by default; see + // `api::guard`). The layer sits on the inner router so it runs before the + // `/api` prefix is nested away, and it carries its own state so it works + // before `with_state` resolves the router's. + let api = api::router().layer(axum::middleware::from_fn_with_state( + Arc::clone(&skald), + api::guard::require_auth, + )); + // Resolve the app state first so the resulting `Router<()>` can host the // stateless plugin routers via `nest`. let mut router = Router::new() - .nest("/api", api::router()) + .nest("/api", api) .with_state(skald); for (id, plugin_router) in plugin_routers { router = router.nest(&format!("/api/plugin/{id}"), plugin_router); diff --git a/web/app.js b/web/app.js index 0b837e0..a766a06 100644 --- a/web/app.js +++ b/web/app.js @@ -9,6 +9,9 @@ import { ModelsImageSection } from './components/models-image.js'; import { ModelsTtsSection } from './components/models-tts.js'; import { TasksPage } from './components/tasks/index.js'; import { AgentsPage } from './components/agents.js'; +import { UsersPage } from './components/users-page.js'; +import { RolesPage } from './components/roles-page.js'; +import { ProfilePage } from './components/profile-page.js'; import { ApprovalGroupsPage } from './components/approval-groups.js'; import { ApprovalRulesPage } from './components/approval-rules.js'; import { ConfigPage } from './components/config-page.js'; @@ -20,6 +23,8 @@ import { SessionDetailPage } from './components/session-detail.js'; import { TicSessionsPage } from './components/tic-sessions.js'; import { ProjectsPage } from './components/projects/index.js'; import { FileViewerPage } from './components/file-viewer-page.js'; +import { SetupPage } from './components/setup-page.js'; +import { LoginPage } from './components/login-page.js'; // Register the global `openFile(path)` helper (window.openFile → location.hash). import './lib/open-file.js'; @@ -35,6 +40,9 @@ customElements.define('models-image-section', ModelsImageSection); customElements.define('models-tts-section', ModelsTtsSection); customElements.define('tasks-page', TasksPage); customElements.define('agents-page', AgentsPage); +customElements.define('users-page', UsersPage); +customElements.define('roles-page', RolesPage); +customElements.define('profile-page', ProfilePage); customElements.define('approval-groups-page', ApprovalGroupsPage); customElements.define('approval-rules-page', ApprovalRulesPage); customElements.define('config-page', ConfigPage); @@ -46,9 +54,41 @@ customElements.define('session-detail-page', SessionDetailPage); customElements.define('tic-sessions-page', TicSessionsPage); customElements.define('projects-page', ProjectsPage); customElements.define('file-viewer-page', FileViewerPage); +customElements.define('setup-page', SetupPage); +customElements.define('login-page', LoginPage); // Toggle the workspace placeholder when an LLM page opens/closes. const workspace = document.getElementById('app-workspace'); window.addEventListener('llm-page-change', (e) => { workspace.style.display = e.detail.page ? 'none' : 'flex'; }); + +// ── First-run check ───────────────────────────────────────────────────────── +// If no user exists yet, show the setup screen. Otherwise, check if we have +// a valid session: if not, show the login screen. If logged in, show the app. +(async () => { + const app = document.getElementById('app'); + const setup = document.querySelector('setup-page'); + const login = document.querySelector('login-page'); + + try { + const setupRes = await fetch('/api/setup/status'); + if (setupRes.ok) { + const { needs_setup } = await setupRes.json(); + if (needs_setup) { + if (app) app.style.display = 'none'; + if (setup) setup.style.display = ''; + return; + } + } + } catch { /* fall through to auth check */ } + + try { + const meRes = await fetch('/api/auth/me'); + if (meRes.status === 401) { + if (app) app.style.display = 'none'; + if (login) login.style.display = ''; + return; + } + } catch { /* show app by default */ } +})(); diff --git a/web/components/login-page.js b/web/components/login-page.js new file mode 100644 index 0000000..d79a5e2 --- /dev/null +++ b/web/components/login-page.js @@ -0,0 +1,106 @@ +import { html } from 'lit'; +import { LightElement } from '../lib/base.js'; + +export class LoginPage extends LightElement { + + static get properties() { + return { + _username: { state: true }, + _password: { state: true }, + _error: { state: true }, + _busy: { state: true }, + }; + } + + constructor() { + super(); + this._username = ''; + this._password = ''; + this._error = null; + this._busy = false; + } + + _submit(e) { + e.preventDefault(); + if (this._busy) return; + + this._error = null; + + if (!this._username.trim() || !this._password) { + this._error = 'Enter your username and password.'; + return; + } + + this._doLogin(); + } + + async _doLogin() { + this._busy = true; + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this._username.trim(), + password: this._password, + }), + }); + if (!res.ok) { + this._error = 'Invalid username or password.'; + return; + } + // Logged in — reload into the app. + window.location.reload(); + } catch { + this._error = 'Network error — please try again.'; + } finally { + this._busy = false; + } + } + + render() { + const btnLabel = this._busy + ? html`Signing in…` + : 'Sign in'; + + return html` + + `; + } +} diff --git a/web/components/profile-page.js b/web/components/profile-page.js new file mode 100644 index 0000000..b7089d5 --- /dev/null +++ b/web/components/profile-page.js @@ -0,0 +1,183 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; + +export class ProfilePage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _me: { state: true }, + _displayName: { state: true }, + _savingName: { state: true }, + _nameMsg: { state: true }, + _pwCurrent: { state: true }, + _pwNew: { state: true }, + _pwConfirm: { state: true }, + _savingPw: { state: true }, + _pwMsg: { state: true }, + }; + } + + constructor() { + super(); + this._open = false; + this._me = null; + this._displayName = ''; + this._savingName = false; + this._nameMsg = null; + this._pwCurrent = ''; + this._pwNew = ''; + this._pwConfirm = ''; + this._savingPw = false; + this._pwMsg = null; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === 'profile'; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + async _load() { + try { + const res = await fetch('/api/auth/me'); + if (res.ok) { + this._me = await res.json(); + this._displayName = this._me.display_name ?? ''; + } + } catch { /* ignore */ } + } + + async _saveName() { + if (this._savingName) return; + this._savingName = true; + this._nameMsg = null; + try { + const res = await fetch('/api/auth/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ display_name: this._displayName.trim() || null }), + }); + if (!res.ok) throw new Error(await res.text()); + this._nameMsg = { type: 'ok', text: 'Saved.' }; + await this._load(); + } catch (e) { + this._nameMsg = { type: 'err', text: e.message }; + } finally { + this._savingName = false; + } + } + + async _savePassword() { + if (this._savingPw) return; + this._pwMsg = null; + if (this._pwNew.length < 4) { + this._pwMsg = { type: 'err', text: 'Password must be at least 4 characters.' }; + return; + } + if (this._pwNew !== this._pwConfirm) { + this._pwMsg = { type: 'err', text: 'Passwords do not match.' }; + return; + } + this._savingPw = true; + try { + const res = await fetch('/api/auth/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + current_password: this._pwCurrent || null, + new_password: this._pwNew, + }), + }); + if (!res.ok) throw new Error(await res.text()); + this._pwMsg = { type: 'ok', text: 'Password changed.' }; + this._pwCurrent = ''; + this._pwNew = ''; + this._pwConfirm = ''; + } catch (e) { + this._pwMsg = { type: 'err', text: e.message }; + } finally { + this._savingPw = false; + } + } + + render() { + if (!this._open) return nothing; + const me = this._me; + + return html` +
+
+

Profile

+
+
+ + ${me ? html` +
+
+
Account
+
+ + +
+
+ + +
+
+
+ ` : nothing} + +
+
+
Display name
+
+ this._displayName = e.target.value} /> +
+ ${this._nameMsg ? html`
${this._nameMsg.text}
` : nothing} + +
+
+ +
+
+
Change password
+ ${me?.role_id === 'admin' || me?.encrypted ? html` +
+ + this._pwCurrent = e.target.value} /> +
+ ` : nothing} +
+ + this._pwNew = e.target.value} /> +
+
+ + this._pwConfirm = e.target.value} /> +
+ ${this._pwMsg ? html`
${this._pwMsg.text}
` : nothing} + +
+
+ +
+
+ `; + } +} diff --git a/web/components/roles-page.js b/web/components/roles-page.js new file mode 100644 index 0000000..2de3320 --- /dev/null +++ b/web/components/roles-page.js @@ -0,0 +1,252 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; + +const ADMIN_ID = 'admin'; + +export class RolesPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _roles: { state: true }, + _groups: { state: true }, + _error: { state: true }, + _modal: { state: true }, // null | { mode: 'create'|'edit', role?, form } + }; + } + + constructor() { + super(); + this._open = false; + this._roles = null; + this._groups = null; + this._error = null; + this._modal = null; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === 'roles'; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + async _load() { + this._error = null; + try { + const [rRes, gRes] = await Promise.all([ + fetch('/api/roles'), + fetch('/api/tool-permission-groups'), + ]); + if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`); + if (!gRes.ok) throw new Error(`Groups: HTTP ${gRes.status}`); + this._roles = await rRes.json(); + this._groups = await gRes.json(); + } catch (e) { + this._error = e.message; + } + } + + // ── Modal helpers ──────────────────────────────────────────────────────────── + + _openCreate() { + this._modal = { + mode: 'create', + form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '' }, + }; + } + + _openEdit(role) { + this._modal = { + mode: 'edit', + role, + form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '' }, + }; + } + + _closeModal() { this._modal = null; this._error = null; } + + _patch(field, value) { + this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; + } + + // ── API actions ────────────────────────────────────────────────────────────── + + async _save() { + const { mode, form } = this._modal; + this._error = null; + + if (mode === 'create') { + if (!form.id.trim() || !form.label.trim()) { this._error = 'ID and label are required.'; return; } + try { + const res = await fetch('/api/roles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: form.id.trim(), + label: form.label.trim(), + permission_group: form.permission_group, + attrs: form.attrs.trim() || null, + }), + }); + if (!res.ok) throw new Error(await res.text()); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } else { + const { role } = this._modal; + if (!form.label.trim()) { this._error = 'Label is required.'; return; } + try { + const res = await fetch(`/api/roles/${role.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: form.label.trim(), + permission_group: form.permission_group, + attrs: form.attrs.trim() || null, + }), + }); + if (!res.ok) throw new Error(await res.text()); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } + } + + async _delete(role) { + if (!confirm(`Delete role "${role.label}"?`)) return; + try { + const res = await fetch(`/api/roles/${role.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── Render ────────────────────────────────────────────────────────────────── + + _groupLabel(groupId) { + return this._groups?.find(g => g.id === groupId)?.name ?? groupId; + } + + _renderModal() { + if (!this._modal) return nothing; + const { mode, form, role } = this._modal; + const title = mode === 'create' ? 'New role' : `Edit ${role.label}`; + + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> +
+
+ + ${title} + +
+
+ ${this._error ? html`
${this._error}
` : nothing} + + ${mode === 'create' ? html` +
+ + this._patch('id', e.target.value)} /> +
Lowercase, no spaces. Cannot be changed later.
+
+ ` : nothing} + +
+ + this._patch('label', e.target.value)} /> +
+
+ + +
+
+ + this._patch('attrs', e.target.value)} /> +
+
+ +
+
+ `; + } + + render() { + if (!this._open) return nothing; + const roles = this._roles ?? []; + const loading = this._roles === null; + + return html` +
+
+

Roles

+
+ ${roles.length} role${roles.length === 1 ? '' : 's'} + +
+
+ + ${this._error && !this._modal ? html` +
${this._error}
+ ` : nothing} + +
+ ${loading ? html`
Loading…
` : roles.length === 0 ? html` +

No roles.

+ ` : html` + + + + + + + + + + + ${roles.map(r => { + const isAdmin = r.id === ADMIN_ID; + return html` + + + + + + + `; + })} + +
IDLabelPermission group
${r.id}${r.label}${this._groupLabel(r.permission_group)} +
+ + +
+
+ `} +
+
+ ${this._renderModal()} + `; + } +} diff --git a/web/components/setup-page.js b/web/components/setup-page.js new file mode 100644 index 0000000..17dd0d5 --- /dev/null +++ b/web/components/setup-page.js @@ -0,0 +1,152 @@ +import { html } from 'lit'; +import { LightElement } from '../lib/base.js'; + +export class SetupPage extends LightElement { + + static get properties() { + return { + _username: { state: true }, + _password: { state: true }, + _confirm: { state: true }, + _encrypted: { state: true }, + _error: { state: true }, + _busy: { state: true }, + }; + } + + constructor() { + super(); + this._username = ''; + this._password = ''; + this._confirm = ''; + this._encrypted = true; + this._error = null; + this._busy = false; + } + + _submit(e) { + e.preventDefault(); + if (this._busy) return; + + this._error = null; + + if (!this._username.trim()) { + this._error = 'Choose a username.'; + return; + } + if (this._password.length < 4) { + this._error = 'Password must be at least 4 characters.'; + return; + } + if (this._password !== this._confirm) { + this._error = 'The two passwords do not match.'; + return; + } + + this._doCreate(); + } + + async _doCreate() { + this._busy = true; + try { + const res = await fetch('/api/setup/user', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this._username.trim(), + password: this._password, + encrypted: this._encrypted, + }), + }); + if (!res.ok) { + const txt = await res.text(); + this._error = txt || `Request failed (${res.status}).`; + return; + } + // First user created — reload into the app. + window.location.reload(); + } catch { + this._error = 'Network error — please try again.'; + } finally { + this._busy = false; + } + } + + render() { + const btnLabel = this._busy + ? html`Creating…` + : 'Create account'; + + return html` +
+
+ +

Welcome to Skald

+

Create the admin account to get started.

+ + ${this._error ? html`
${this._error}
` : null} + +
+ + this._username = e.target.value} + ?disabled=${this._busy} + required /> +
+ +
+ + this._password = e.target.value} + ?disabled=${this._busy} + required /> +
+ +
+ + this._confirm = e.target.value} + ?disabled=${this._busy} + required /> +
+ +
+ this._encrypted = e.target.checked} + ?disabled=${this._busy} /> + +
+ + ${this._encrypted ? html` +
+ Warning: your password derives the encryption key. + If you forget it, your entire conversation history will be + permanently lost — there is no recovery. +
+ ` : null} + + +
+
+ `; + } +} diff --git a/web/components/sidebar.js b/web/components/sidebar.js index 3cb44e6..0a509fe 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -106,7 +106,7 @@ export class AppSidebar extends LightElement { // Segment ends at the first `/` (e.g. `#session/123`) or `?` (e.g. `#file_viewer?path=...`). const match = hash.match(/^([^/?]+)/); const segment = match ? match[1] : ''; - return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; + return ['inbox', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home'; } _tasksSectionFromHash() { @@ -276,6 +276,16 @@ export class AppSidebar extends LightElement { Agents + this._togglePage('users', e)}> + + Users + + this._togglePage('roles', e)}> + + Roles + this._togglePage('config', e)}> diff --git a/web/components/topbar.js b/web/components/topbar.js index 63ca989..3ff8c5c 100644 --- a/web/components/topbar.js +++ b/web/components/topbar.js @@ -1,16 +1,20 @@ -import { html } from 'lit'; +import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; export class AppTopbar extends LightElement { static properties = { - _theme: { state: true }, + _theme: { state: true }, _copilotCollapsed: { state: true }, + _menuOpen: { state: true }, + _me: { state: true }, }; constructor() { super(); this._theme = document.documentElement.getAttribute('data-bs-theme') ?? 'light'; this._copilotCollapsed = false; + this._menuOpen = false; + this._me = null; } connectedCallback() { @@ -18,6 +22,19 @@ export class AppTopbar extends LightElement { window.addEventListener('copilot-collapsed', (e) => { this._copilotCollapsed = e.detail.collapsed; }); + this._loadMe(); + document.addEventListener('click', (e) => { + if (this._menuOpen && !e.composedPath().includes(this)) { + this._menuOpen = false; + } + }); + } + + async _loadMe() { + try { + const res = await fetch('/api/auth/me'); + if (res.ok) this._me = await res.json(); + } catch { /* ignore */ } } _toggleTheme() { @@ -27,6 +44,27 @@ export class AppTopbar extends LightElement { localStorage.setItem('theme', next); } + _toggleMenu(e) { + e.stopPropagation(); + this._menuOpen = !this._menuOpen; + } + + _goProfile() { + this._menuOpen = false; + history.pushState({ page: 'profile' }, '', '#profile'); + window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'profile' } })); + } + + _logout() { + this._menuOpen = false; + fetch('/api/auth/logout', { method: 'POST' }).then(() => window.location.reload()); + } + + get _initial() { + const name = this._me?.display_name || this._me?.username || '?'; + return name.charAt(0).toUpperCase(); + } + render() { const isDark = this._theme === 'dark'; return html` @@ -42,6 +80,25 @@ export class AppTopbar extends LightElement { @click=${() => this._toggleTheme()}> +
+ + ${this._menuOpen ? html` +
+
+
${this._me?.display_name || this._me?.username || ''}
+
@${this._me?.username || ''}
+
+ + +
+ ` : nothing} +
`; } } diff --git a/web/components/users-page.js b/web/components/users-page.js new file mode 100644 index 0000000..cc21074 --- /dev/null +++ b/web/components/users-page.js @@ -0,0 +1,311 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; + +export class UsersPage extends LightElement { + + static get properties() { + return { + _open: { state: true }, + _users: { state: true }, + _roles: { state: true }, + _error: { state: true }, + _modal: { state: true }, // null | { mode: 'create'|'edit'|'password', user?, form } + }; + } + + constructor() { + super(); + this._open = false; + this._users = null; + this._roles = null; + this._error = null; + this._modal = null; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('llm-page-change', (e) => { + this._open = e.detail.page === 'users'; + this.style.display = this._open ? 'flex' : 'none'; + if (this._open) this._load(); + }); + } + + async _load() { + this._error = null; + try { + const [uRes, rRes] = await Promise.all([ + fetch('/api/users'), + fetch('/api/roles'), + ]); + if (!uRes.ok) throw new Error(`Users: HTTP ${uRes.status}`); + if (!rRes.ok) throw new Error(`Roles: HTTP ${rRes.status}`); + this._users = await uRes.json(); + this._roles = await rRes.json(); + } catch (e) { + this._error = e.message; + } + } + + // ── Modal helpers ──────────────────────────────────────────────────────────── + + _openCreate() { + this._modal = { + mode: 'create', + form: { username: '', display_name: '', role_id: this._roles?.[0]?.id ?? '', password: '', encrypted: false }, + }; + } + + _openEdit(user) { + this._modal = { + mode: 'edit', + user, + form: { username: user.username, display_name: user.display_name ?? '', role_id: user.role_id, active: user.active }, + }; + } + + _openPassword(user) { + this._modal = { mode: 'password', user, form: { password: '' } }; + } + + _closeModal() { this._modal = null; this._error = null; } + + _patch(field, value) { + this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; + } + + // ── API actions ────────────────────────────────────────────────────────────── + + async _save() { + const { mode, form } = this._modal; + this._error = null; + + if (mode === 'create') { + if (!form.username.trim() || !form.password) { this._error = 'Username and password are required.'; return; } + try { + const res = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: form.username.trim(), + display_name: form.display_name.trim() || null, + role_id: form.role_id, + password: form.password, + encrypted: form.encrypted, + }), + }); + if (!res.ok) throw new Error(await res.text()); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } else if (mode === 'edit') { + const { user } = this._modal; + if (!form.username.trim()) { this._error = 'Username is required.'; return; } + try { + const res = await fetch(`/api/users/${user.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: form.username.trim(), + display_name: form.display_name.trim() || null, + role_id: form.role_id, + active: form.active, + }), + }); + if (!res.ok) throw new Error(await res.text()); + this._closeModal(); + await this._load(); + } catch (e) { this._error = e.message; } + } else if (mode === 'password') { + const { user } = this._modal; + if (!form.password) { this._error = 'Password must not be empty.'; return; } + try { + const res = await fetch(`/api/users/${user.id}/password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: form.password }), + }); + if (!res.ok) throw new Error(await res.text()); + this._closeModal(); + } catch (e) { this._error = e.message; } + } + } + + async _delete(user) { + if (!confirm(`Delete user "${user.username}"? This permanently erases their database and all conversation history.`)) return; + try { + const res = await fetch(`/api/users/${user.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── Render ────────────────────────────────────────────────────────────────── + + _roleLabel(roleId) { + return this._roles?.find(r => r.id === roleId)?.label ?? roleId; + } + + _renderModal() { + if (!this._modal) return nothing; + const { mode, form, user } = this._modal; + const title = mode === 'create' ? 'New user' + : mode === 'edit' ? `Edit ${user.username}` + : `Reset password — ${user.username}`; + + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}> +
+
+ + ${title} + +
+
+ ${this._error ? html`
${this._error}
` : nothing} + + ${mode === 'create' ? html` +
+ + this._patch('username', e.target.value)} /> +
+
+ + this._patch('display_name', e.target.value)} /> +
+
+ + +
+
+ + this._patch('password', e.target.value)} /> +
+
+ this._patch('encrypted', e.target.checked)} /> + +
+ ${form.encrypted ? html` +
+ Warning: if the password is lost, the conversation history is permanently unrecoverable. +
+ ` : nothing} + ` : mode === 'edit' ? html` +
+ + this._patch('username', e.target.value)} /> +
+
+ + this._patch('display_name', e.target.value)} /> +
+
+ + +
+
+ this._patch('active', e.target.checked)} /> + +
+ ` : html` +
+ + Only works for cleartext (non-encrypted) users. +
+
+ + this._patch('password', e.target.value)} /> +
+ `} +
+ +
+
+ `; + } + + render() { + if (!this._open) return nothing; + const users = this._users ?? []; + const loading = this._users === null; + + return html` +
+
+

Users

+
+ ${users.length} user${users.length === 1 ? '' : 's'} + +
+
+ + ${this._error && !this._modal ? html` +
${this._error}
+ ` : nothing} + +
+ ${loading ? html`
Loading…
` : users.length === 0 ? html` +

No users.

+ ` : html` + + + + + + + + + + + + + ${users.map(u => html` + + + + + + + + + `)} + +
UsernameDisplay nameRoleDBStatus
${u.username}${u.display_name ?? '—'}${this._roleLabel(u.role_id)}${u.encrypted + ? html`Encrypted` + : html`Cleartext`}${u.active + ? html`Active` + : html`Inactive`} +
+ + + +
+
+ `} +
+
+ ${this._renderModal()} + `; + } +} diff --git a/web/css/page-shell.css b/web/css/page-shell.css index 409a3a4..91f6839 100644 --- a/web/css/page-shell.css +++ b/web/css/page-shell.css @@ -71,6 +71,18 @@ file-viewer-page { border-right: 1px solid var(--toolbar-border, #e5e9f0); } +users-page, +roles-page, +profile-page { + display: none; /* toggled by JS */ + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--toolbar-border, #e5e9f0); +} + /* ── Workspace placeholder ──────────────────────────────────────────────────── */ .app-workspace { diff --git a/web/css/setup-page.css b/web/css/setup-page.css new file mode 100644 index 0000000..d26fac6 --- /dev/null +++ b/web/css/setup-page.css @@ -0,0 +1,107 @@ +.setup-page, +.login-page { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: var(--bs-body-bg); + overflow: auto; + padding: 24px; +} + +.setup-card, +.login-card { + width: 100%; + max-width: 440px; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + box-shadow: var(--card-shadow); + padding: 36px 32px; +} + +.setup-logo, +.login-logo { + text-align: center; + margin-bottom: 24px; +} + +.setup-logo img, +.login-logo img { + width: 64px; + height: 64px; + border-radius: 14px; +} + +.setup-title, +.login-title { + font-size: 1.5rem; + font-weight: 700; + text-align: center; + margin: 0 0 6px; +} + +.setup-subtitle, +.login-subtitle { + text-align: center; + color: var(--placeholder-color); + font-size: .9rem; + margin: 0 0 28px; +} + +.setup-error, +.login-error { + background: rgba(220, 53, 69, .1); + border: 1px solid rgba(220, 53, 69, .35); + border-radius: var(--card-radius); + padding: 10px 14px; + margin-bottom: 18px; + font-size: .85rem; + color: #dc3545; +} + +.setup-warn { + background: rgba(255, 193, 7, .1); + border: 1px solid rgba(255, 193, 7, .35); + border-radius: var(--card-radius); + padding: 12px 14px; + margin-top: 18px; + font-size: .82rem; + color: var(--bs-secondary-color); +} + +.setup-warn strong { + color: #b8860b; +} + +[data-bs-theme="dark"] .setup-warn strong { + color: #ffc107; +} + +.setup-submit, +.login-submit { + width: 100%; + margin-top: 24px; +} + +.setup-spinner, +.login-spinner { + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + display: inline-block; + animation: setup-spin .65s linear infinite; + vertical-align: -3px; + margin-right: 6px; +} + +@keyframes setup-spin { + to { transform: rotate(360deg); } +} +.login-spinner { + animation-name: setup-spin; +} diff --git a/web/css/topbar.css b/web/css/topbar.css index 6eda616..3e3ce61 100644 --- a/web/css/topbar.css +++ b/web/css/topbar.css @@ -69,3 +69,92 @@ app-topbar { .topbar-copilot-btn i { font-size: 0.9rem; } + +/* ── Profile avatar + dropdown ─────────────────────────────────────────────── */ + +.topbar-profile-wrapper { + position: relative; + margin-left: 0.5rem; +} + +.topbar-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: 0.72rem; + font-weight: 700; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + padding: 0; +} + +.topbar-avatar:hover { + background: var(--accent-hover); +} + +.topbar-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 200px; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + box-shadow: var(--card-shadow); + padding: 6px; + z-index: 100; +} + +.topbar-dropdown-header { + padding: 8px 10px; + border-bottom: 1px solid var(--card-border); + margin-bottom: 4px; +} + +.topbar-dropdown-name { + font-size: 0.85rem; + font-weight: 600; +} + +.topbar-dropdown-sub { + font-size: 0.75rem; + color: var(--placeholder-color); +} + +.topbar-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + border: none; + background: none; + color: var(--bs-body-color); + font-size: 0.82rem; + padding: 8px 10px; + border-radius: 5px; + cursor: pointer; + text-align: left; +} + +.topbar-dropdown-item:hover { + background: var(--bs-tertiary-bg); +} + +.topbar-dropdown-item i { + font-size: 0.9rem; + opacity: 0.7; +} + +.topbar-dropdown-logout { + color: #dc3545; +} + +.topbar-dropdown-logout:hover { + background: rgba(220, 53, 69, 0.08); +} diff --git a/web/css/users-roles.css b/web/css/users-roles.css new file mode 100644 index 0000000..0ead655 --- /dev/null +++ b/web/css/users-roles.css @@ -0,0 +1,138 @@ +/* ── Users & Roles pages (shared) ───────────────────────────────────────────── */ + +.um-page { + flex: 1; + flex-direction: column; + min-height: 0; + overflow-y: auto; + padding: 0; +} + +.um-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px 12px; +} + +.um-title { + font-size: 1.35rem; + font-weight: 700; + margin: 0; +} + +.um-header-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 14px; +} + +.um-header-count { + font-size: .82rem; + color: var(--placeholder-color); +} + +.um-table-wrap { + padding: 0 24px 24px; +} + +.um-table { + width: 100%; + border-collapse: collapse; + font-size: .88rem; +} + +.um-table th { + text-align: left; + padding: 8px 12px; + font-weight: 600; + font-size: .78rem; + text-transform: uppercase; + letter-spacing: .03em; + color: var(--placeholder-color); + border-bottom: 1px solid var(--card-border); +} + +.um-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--card-border); +} + +.um-table tr:hover td { + background: var(--bs-tertiary-bg); +} + +.um-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: .72rem; + font-weight: 600; +} + +.um-badge-encrypted { background: rgba(99, 102, 241, .15); color: #6366f1; } +.um-badge-clear { background: rgba(108, 117, 125, .15); color: #6c757d; } +.um-badge-active { background: rgba(25, 135, 84, .15); color: #198754; } +.um-badge-inactive { background: rgba(220, 53, 69, .15); color: #dc3545; } + +.um-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} + +.um-btn-icon { + background: none; + border: none; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + color: var(--placeholder-color); + font-size: .95rem; + transition: background .15s, color .15s; +} + +.um-btn-icon:hover { background: var(--bs-tertiary-bg); color: var(--bs-body-color); } +.um-btn-icon:disabled { opacity: .3; cursor: not-allowed; } + +/* ── Modal form ─────────────────────────────────────────────────────────────── */ + +.um-modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, .4); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.um-modal { + width: 100%; + max-width: 460px; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + box-shadow: var(--card-shadow); +} + +.um-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 20px; + border-bottom: 1px solid var(--card-border); + font-weight: 600; + font-size: 1rem; +} + +.um-modal-body { padding: 20px; } +.um-modal-footer { padding: 12px 20px; display: flex; justify-content: flex-end; gap: 8px; } + +.um-empty { + text-align: center; + padding: 48px 24px; + color: var(--placeholder-color); +} diff --git a/web/index.html b/web/index.html index acfd78b..2d1e68f 100644 --- a/web/index.html +++ b/web/index.html @@ -61,6 +61,8 @@ + + @@ -88,6 +90,9 @@
+ + + @@ -105,6 +110,9 @@ + + +