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:
2026-07-10 19:19:25 +01:00
parent 178a38357e
commit 7dd77d4ef4
36 changed files with 2660 additions and 27 deletions
+11 -4
View File
@@ -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<dyn Plugin>` 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`
Generated
+33 -2
View File
@@ -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",
+1
View File
@@ -2,6 +2,7 @@
members = [
".",
"crates/skald-core",
"crates/skald-setup",
"crates/honcho-client",
"crates/llm-client",
"crates/core-api",
+17 -9
View File
@@ -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
# 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
+85
View File
@@ -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<UserManager>,
sessions: RwLock<HashMap<String, String>>,
}
impl SessionStore {
pub fn new(users: Arc<UserManager>) -> 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<String, AuthError> {
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<String> {
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");
}
}
}
+16 -5
View File
@@ -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,
+124
View File
@@ -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<String>,
pub created_at: String,
}
type RawRow = (String, String, String, Option<String>, 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<Vec<Role>> {
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<Option<Role>> {
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<bool> {
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<i64> {
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(())
}
+26
View File
@@ -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')
+1
View File
@@ -7,6 +7,7 @@
pub mod boot;
pub mod config;
pub mod auth;
pub mod config_store;
pub mod skald;
pub mod agents;
+1
View File
@@ -52,6 +52,7 @@ impl Skald {
// Runtime / cross-cutting
pub fn db(&self) -> &Arc<SqlitePool> { &self.rt.db }
pub fn users(&self) -> &Arc<UserManager> { &self.rt.users }
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
pub fn system_bus(&self) -> &Arc<SystemEventBus> { &self.rt.system_bus }
+4
View File
@@ -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<SqlitePool>,
pub(super) users: Arc<UserManager>,
pub(super) sessions: Arc<SessionStore>,
pub(super) config: Arc<GlobalConfigManager>,
pub(super) config_properties: Vec<core_api::ConfigSet>,
pub(super) system_bus: Arc<SystemEventBus>,
@@ -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,
+15
View File
@@ -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).
+21
View File
@@ -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"
+242
View File
@@ -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<Mode, String> {
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<std::process::ExitCode> {
// 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<String> {
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<bool> {
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<String> {
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<String> {
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<String> {
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)
}
+1 -1
View File
@@ -1,6 +1,6 @@
server:
host: 127.0.0.1
port: 6000
port: 9000
# ── Global timezone ─────────────────────────────────────────────────────────────
# IANA timezone name applied globally to:
+25
View File
@@ -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
+192
View File
@@ -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<Arc<Skald>>,
Json(body): Json<LoginBody>,
) -> Result<Response, ApiError> {
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<String>,
pub role_id: String,
}
/// Returns the authenticated user's profile, or 401 if no valid session.
pub async fn me(
State(skald): State<Arc<Skald>>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
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<Arc<Skald>>,
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<String> {
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<String>,
}
pub async fn update_profile(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<UpdateProfileBody>,
) -> Result<Json<serde_json::Value>, 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<String>,
pub new_password: String,
}
pub async fn change_password(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<ChangePasswordBody>,
) -> Result<Json<serde_json::Value>, 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 })))
}
+82
View File
@@ -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<String> {
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<Arc<Skald>>,
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(),
}
}
+21
View File
@@ -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<Arc<Skald>> {
// 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<Arc<Skald>> {
// 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)
+78
View File
@@ -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<Arc<Skald>>) -> Result<Json<Vec<Role>>, 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<String>,
}
pub async fn create(
State(skald): State<Arc<Skald>>,
Json(body): Json<CreateBody>,
) -> Result<Json<Role>, 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<String>,
}
pub async fn update(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> Result<Json<Role>, 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<Arc<Skald>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, 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 })))
}
+64
View File
@@ -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<Arc<Skald>>) -> Result<Json<SetupStatus>, 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<Arc<Skald>>,
Json(body): Json<CreateUserBody>,
) -> Result<Json<CreateUserResult>, 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 }))
}
+120
View File
@@ -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 })))
}
+10 -1
View File
@@ -70,10 +70,19 @@ impl WebServer {
skald: Arc<Skald>,
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);
+40
View File
@@ -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 */ }
})();
+106
View File
@@ -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`<span class="login-spinner"></span>Signing in…`
: 'Sign in';
return html`
<div class="login-page">
<form class="login-card" @submit=${this._submit} autocomplete="on">
<div class="login-logo">
<img src="/assets/icons/icon-192.png" alt="Skald" />
</div>
<h1 class="login-title">Welcome back</h1>
<p class="login-subtitle">Sign in to your account.</p>
${this._error ? html`<div class="login-error">${this._error}</div>` : null}
<div class="mb-3">
<label class="form-label">Username</label>
<input
type="text"
class="form-control"
autocomplete="username"
.value=${this._username}
@input=${e => this._username = e.target.value}
?disabled=${this._busy} />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input
type="password"
class="form-control"
autocomplete="current-password"
.value=${this._password}
@input=${e => this._password = e.target.value}
?disabled=${this._busy} />
</div>
<button type="submit" class="btn btn-primary login-submit" ?disabled=${this._busy}>
${btnLabel}
</button>
</form>
</div>
`;
}
}
+183
View File
@@ -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`
<div class="um-page" style="display:flex">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>Profile</h2>
</div>
<div style="padding:0 24px 48px;max-width:480px">
${me ? html`
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Account</h6>
<div class="mb-2">
<label class="form-label" style="font-size:.82rem;font-weight:600">Username</label>
<input class="form-control" .value=${me.username} disabled />
</div>
<div class="mb-2">
<label class="form-label" style="font-size:.82rem;font-weight:600">Role</label>
<input class="form-control" .value=${me.role_id} disabled />
</div>
</div>
</div>
` : nothing}
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Display name</h6>
<div class="mb-3">
<input class="form-control" placeholder="Your name"
.value=${this._displayName}
@input=${e => this._displayName = e.target.value} />
</div>
${this._nameMsg ? html`<div class="alert alert-${this._nameMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._nameMsg.text}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._saveName()} ?disabled=${this._savingName}>
${this._savingName ? 'Saving…' : 'Save'}
</button>
</div>
</div>
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
<div class="card-body">
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Change password</h6>
${me?.role_id === 'admin' || me?.encrypted ? html`
<div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">Current password</label>
<input type="password" class="form-control" autocomplete="current-password"
.value=${this._pwCurrent}
@input=${e => this._pwCurrent = e.target.value} />
</div>
` : nothing}
<div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">New password</label>
<input type="password" class="form-control" autocomplete="new-password"
.value=${this._pwNew}
@input=${e => this._pwNew = e.target.value} />
</div>
<div class="mb-3">
<label class="form-label" style="font-size:.82rem;font-weight:600">Confirm new password</label>
<input type="password" class="form-control" autocomplete="new-password"
.value=${this._pwConfirm}
@input=${e => this._pwConfirm = e.target.value} />
</div>
${this._pwMsg ? html`<div class="alert alert-${this._pwMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._pwMsg.text}</div>` : nothing}
<button class="btn btn-sm btn-primary" @click=${() => this._savePassword()} ?disabled=${this._savingPw}>
${this._savingPw ? 'Changing…' : 'Change password'}
</button>
</div>
</div>
</div>
</div>
`;
}
}
+252
View File
@@ -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`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi ${mode === 'create' ? 'bi-plus-circle' : 'bi-pencil-square'}"></i>
<span>${title}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">ID <span class="text-muted">(slug)</span></label>
<input class="form-control font-monospace" placeholder="e.g. editor" .value=${form.id}
@input=${e => this._patch('id', e.target.value)} />
<div class="form-text" style="font-size:.75rem">Lowercase, no spaces. Cannot be changed later.</div>
</div>
` : nothing}
<div class="mb-3">
<label class="form-label">Label</label>
<input class="form-control" .value=${form.label} @input=${e => this._patch('label', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Permission group</label>
<select class="form-select" @change=${e => this._patch('permission_group', e.target.value)}>
${(this._groups ?? []).map(g => html`<option value=${g.id} ?selected=${form.permission_group === g.id}>${g.name}</option>`)}
</select>
</div>
<div class="mb-3">
<label class="form-label">Attrs <span class="text-muted">(JSON, optional)</span></label>
<input class="form-control font-monospace" placeholder="{}" .value=${form.attrs}
@input=${e => this._patch('attrs', e.target.value)} />
</div>
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : 'Save'}
</button>
</div>
</div>
</div>
`;
}
render() {
if (!this._open) return nothing;
const roles = this._roles ?? [];
const loading = this._roles === null;
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-tags me-2"></i>Roles</h2>
<div class="um-header-right">
<span class="um-header-count">${roles.length} role${roles.length === 1 ? '' : 's'}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New role
</button>
</div>
</div>
${this._error && !this._modal ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>
` : nothing}
<div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : roles.length === 0 ? html`
<div class="um-empty"><i class="bi bi-tags"></i><p>No roles.</p></div>
` : html`
<table class="um-table">
<thead>
<tr>
<th>ID</th>
<th>Label</th>
<th>Permission group</th>
<th></th>
</tr>
</thead>
<tbody>
${roles.map(r => {
const isAdmin = r.id === ADMIN_ID;
return html`
<tr>
<td><code>${r.id}</code></td>
<td><strong>${r.label}</strong></td>
<td>${this._groupLabel(r.permission_group)}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Edit'}
?disabled=${isAdmin}
@click=${() => !isAdmin && this._openEdit(r)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title=${isAdmin ? 'Built-in role — locked' : 'Delete'}
?disabled=${isAdmin}
@click=${() => !isAdmin && this._delete(r)}>
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
`}
</div>
</div>
${this._renderModal()}
`;
}
}
+152
View File
@@ -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`<span class="setup-spinner"></span>Creating…`
: 'Create account';
return html`
<div class="setup-page">
<form class="setup-card" @submit=${this._submit} autocomplete="off">
<div class="setup-logo">
<img src="/assets/icons/icon-192.png" alt="Skald" />
</div>
<h1 class="setup-title">Welcome to Skald</h1>
<p class="setup-subtitle">Create the admin account to get started.</p>
${this._error ? html`<div class="setup-error">${this._error}</div>` : null}
<div class="mb-3">
<label class="form-label">Username</label>
<input
type="text"
class="form-control"
.value=${this._username}
@input=${e => this._username = e.target.value}
?disabled=${this._busy}
required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input
type="password"
class="form-control"
.value=${this._password}
@input=${e => this._password = e.target.value}
?disabled=${this._busy}
required />
</div>
<div class="mb-3">
<label class="form-label">Confirm password</label>
<input
type="password"
class="form-control"
.value=${this._confirm}
@input=${e => this._confirm = e.target.value}
?disabled=${this._busy}
required />
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="encrypt-chk"
.checked=${this._encrypted}
@change=${e => this._encrypted = e.target.checked}
?disabled=${this._busy} />
<label class="form-check-label" for="encrypt-chk">
Encrypt my conversation history
</label>
</div>
${this._encrypted ? html`
<div class="setup-warn">
<strong>Warning:</strong> your password derives the encryption key.
If you forget it, <strong>your entire conversation history will be
permanently lost</strong> — there is no recovery.
</div>
` : null}
<button type="submit" class="btn btn-primary setup-submit" ?disabled=${this._busy}>
${btnLabel}
</button>
</form>
</div>
`;
}
}
+11 -1
View File
@@ -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 {
<i class="bi bi-people"></i>
<span class="sidebar-link-name">Agents</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'users' ? 'active' : ''}"
@click=${(e) => this._togglePage('users', e)}>
<i class="bi bi-person-badge"></i>
<span class="sidebar-link-name">Users</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'roles' ? 'active' : ''}"
@click=${(e) => this._togglePage('roles', e)}>
<i class="bi bi-tags"></i>
<span class="sidebar-link-name">Roles</span>
</a>
<a href="#" class="sidebar-link ${this._activePage === 'config' ? 'active' : ''}"
@click=${(e) => this._togglePage('config', e)}>
<i class="bi bi-gear"></i>
+58 -1
View File
@@ -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 },
_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()}>
<i class="bi ${isDark ? 'bi-sun' : 'bi-moon-stars'}"></i>
</button>
<div class="topbar-profile-wrapper">
<button class="topbar-avatar" title="Account" @click=${(e) => this._toggleMenu(e)}>
${this._initial}
</button>
${this._menuOpen ? html`
<div class="topbar-dropdown">
<div class="topbar-dropdown-header">
<div class="topbar-dropdown-name">${this._me?.display_name || this._me?.username || ''}</div>
<div class="topbar-dropdown-sub">@${this._me?.username || ''}</div>
</div>
<button class="topbar-dropdown-item" @click=${() => this._goProfile()}>
<i class="bi bi-person"></i> Profile
</button>
<button class="topbar-dropdown-item topbar-dropdown-logout" @click=${() => this._logout()}>
<i class="bi bi-box-arrow-right"></i> Logout
</button>
</div>
` : nothing}
</div>
`;
}
}
+311
View File
@@ -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`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
<div class="um-modal">
<div class="um-modal-header">
<i class="bi ${mode === 'create' ? 'bi-person-plus' : mode === 'edit' ? 'bi-pencil-square' : 'bi-key'}"></i>
<span>${title}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closeModal()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${this._error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${this._error}</div>` : nothing}
${mode === 'create' ? html`
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Display name <span class="text-muted">(optional)</span></label>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
</select>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="um-enc"
.checked=${form.encrypted}
@change=${e => this._patch('encrypted', e.target.checked)} />
<label class="form-check-label" for="um-enc">Encrypt conversation history</label>
</div>
${form.encrypted ? html`
<div class="setup-warn mt-2">
<strong>Warning:</strong> if the password is lost, the conversation history is permanently unrecoverable.
</div>
` : nothing}
` : mode === 'edit' ? html`
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" .value=${form.username} @input=${e => this._patch('username', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Display name <span class="text-muted">(optional)</span></label>
<input class="form-control" .value=${form.display_name} @input=${e => this._patch('display_name', e.target.value)} />
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select class="form-select" @change=${e => this._patch('role_id', e.target.value)}>
${(this._roles ?? []).map(r => html`<option value=${r.id} ?selected=${form.role_id === r.id}>${r.label}</option>`)}
</select>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="um-active"
.checked=${form.active}
@change=${e => this._patch('active', e.target.checked)} />
<label class="form-check-label" for="um-active">Active</label>
</div>
` : html`
<div class="alert alert-warning py-2 mb-3" style="font-size:.82rem">
<i class="bi bi-exclamation-triangle me-1"></i>
Only works for cleartext (non-encrypted) users.
</div>
<div class="mb-3">
<label class="form-label">New password</label>
<input type="password" class="form-control" .value=${form.password} @input=${e => this._patch('password', e.target.value)} />
</div>
`}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closeModal()}>Cancel</button>
<button class="btn btn-sm btn-primary" @click=${() => this._save()}>
<i class="bi bi-check-lg me-1"></i>${mode === 'create' ? 'Create' : mode === 'edit' ? 'Save' : 'Reset'}
</button>
</div>
</div>
</div>
`;
}
render() {
if (!this._open) return nothing;
const users = this._users ?? [];
const loading = this._users === null;
return html`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-people-fill me-2"></i>Users</h2>
<div class="um-header-right">
<span class="um-header-count">${users.length} user${users.length === 1 ? '' : 's'}</span>
<button class="btn btn-sm btn-primary" @click=${() => this._openCreate()}>
<i class="bi bi-plus-lg me-1"></i>New user
</button>
</div>
</div>
${this._error && !this._modal ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>
` : nothing}
<div class="um-table-wrap">
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : users.length === 0 ? html`
<div class="um-empty"><i class="bi bi-people"></i><p>No users.</p></div>
` : html`
<table class="um-table">
<thead>
<tr>
<th>Username</th>
<th>Display name</th>
<th>Role</th>
<th>DB</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${users.map(u => html`
<tr>
<td><strong>${u.username}</strong></td>
<td>${u.display_name ?? '—'}</td>
<td>${this._roleLabel(u.role_id)}</td>
<td>${u.encrypted
? html`<span class="um-badge um-badge-encrypted">Encrypted</span>`
: html`<span class="um-badge um-badge-clear">Cleartext</span>`}</td>
<td>${u.active
? html`<span class="um-badge um-badge-active">Active</span>`
: html`<span class="um-badge um-badge-inactive">Inactive</span>`}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title="Reset password" @click=${() => this._openPassword(u)}>
<i class="bi bi-key"></i>
</button>
<button class="um-btn-icon" title="Edit" @click=${() => this._openEdit(u)}>
<i class="bi bi-pencil"></i>
</button>
<button class="um-btn-icon" title="Delete" @click=${() => this._delete(u)}>
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`)}
</tbody>
</table>
`}
</div>
</div>
${this._renderModal()}
`;
}
}
+12
View File
@@ -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 {
+107
View File
@@ -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;
}
+89
View File
@@ -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);
}
+138
View File
@@ -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);
}
+8
View File
@@ -61,6 +61,8 @@
<link rel="stylesheet" href="css/agent-inbox.css" />
<link rel="stylesheet" href="css/home.css" />
<link rel="stylesheet" href="css/llm-requests.css" />
<link rel="stylesheet" href="css/setup-page.css" />
<link rel="stylesheet" href="css/users-roles.css" />
<link rel="stylesheet" href="css/projects/base.css" />
<link rel="stylesheet" href="css/projects/board.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
@@ -88,6 +90,9 @@
<app-sidebar></app-sidebar>
<div class="app-workspace" id="app-workspace"></div>
<agents-page></agents-page>
<users-page></users-page>
<roles-page></roles-page>
<profile-page style="display:none"></profile-page>
<llm-providers-page></llm-providers-page>
<models-hub-page></models-hub-page>
<tasks-page></tasks-page>
@@ -105,6 +110,9 @@
</div>
</div>
<setup-page style="display:none"></setup-page>
<login-page style="display:none"></login-page>
<script type="module" src="app.js"></script>
</body>
</html>