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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
members = [
|
||||
".",
|
||||
"crates/skald-core",
|
||||
"crates/skald-setup",
|
||||
"crates/honcho-client",
|
||||
"crates/llm-client",
|
||||
"crates/core-api",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod boot;
|
||||
pub mod config;
|
||||
pub mod auth;
|
||||
pub mod config_store;
|
||||
pub mod skald;
|
||||
pub mod agents;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 6000
|
||||
port: 9000
|
||||
|
||||
# ── Global timezone ─────────────────────────────────────────────────────────────
|
||||
# IANA timezone name applied globally to:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 */ }
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user