Major rebranding, i18n, dashboard, shared folders, role capabilities

- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette
  (terracotta accent, --radius tokens, WCAG contrast, reduced-motion),
  updated favicon, tray icon, skaldkonur asset
- i18n: backend crate (i18n.rs, locale column, ui_locale config),
  frontend library (web/lib/i18n.js, I18nMixin, t(key)),
  translation files (web/i18n/), every component wired
- Dashboard: <dashboard-page> replaces old home-page content;
  <app-copilot> becomes the landing page (full/dock layout modes)
- Shared folders: API endpoints (shared_folders.rs), frontend page,
  can_write membership, container mount topology, user_fs routing
- Role capabilities: new db table & authorization seam (data not enums),
  roles.attrs JSON for ui_mode / interface select
- Setup: skald-setup prompts for language + password, sets ui_locale
- General: components migrated to CSS variables, Lit conventions cleanup,
  connectors/catalog/marketplace/approval refactoring
This commit is contained in:
2026-07-18 21:38:42 +01:00
parent 2b35312abd
commit 126886e309
109 changed files with 6228 additions and 1390 deletions
+71 -12
View File
@@ -58,9 +58,19 @@ pub async fn login(
#[derive(Serialize)]
pub struct MeResponse {
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
/// Interface mode resolved from the role's `attrs.ui_mode` — "full" unless
/// the role opts into the simplified UI. Never hardcoded per-role: it is
/// data on the role row (§0.1), and `admin` is always "full".
pub ui_mode: String,
/// The user's own locale override (NULL = follow the instance default).
pub locale: Option<String>,
/// Whether the user's database is encrypted (drives the profile UI).
pub encrypted: bool,
/// Instance default locale (registry config `ui_locale`).
pub default_locale: String,
}
/// Returns the authenticated user's profile, or 401 if no valid session.
@@ -84,14 +94,44 @@ pub async fn me(
.await?
.ok_or_else(|| ApiError::not_found("user not found"))?;
let ui_mode = resolve_ui_mode(&skald, &user.role_id).await;
let default_locale = skald
.config()
.get(skald_core::i18n::DEFAULT_LOCALE_KEY)
.await?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "en".into());
Ok(Json(MeResponse {
username: user.username,
display_name: user.display_name,
role_id: user.role_id,
ui_mode,
locale: user.locale,
encrypted: user.encrypted,
default_locale,
})
.into_response())
}
/// Reads `roles.attrs.ui_mode` for the given role. Any error or missing key
/// resolves to "full" — the simplified UI is strictly opt-in.
async fn resolve_ui_mode(skald: &Skald, role_id: &str) -> String {
if role_id == skald_core::db::roles::ADMIN_ROLE_ID {
return "full".into();
}
let attrs = skald_core::db::roles::get(skald.db(), role_id)
.await
.ok()
.flatten()
.and_then(|r| r.attrs);
attrs
.and_then(|a| serde_json::from_str::<serde_json::Value>(&a).ok())
.and_then(|v| v.get("ui_mode")?.as_str().map(str::to_owned))
.filter(|m| m == "simple" || m == "full")
.unwrap_or_else(|| "full".into())
}
// ── POST /api/auth/logout ────────────────────────────────────────────────────
pub async fn logout(
@@ -127,11 +167,16 @@ fn extract_session_token(headers: &HeaderMap) -> Option<String> {
None
}
// ── PUT /api/auth/profile — update display name ──────────────────────────────
// ── PUT /api/auth/profile — update display name / locale ─────────────────────
// Tri-state fields: absent = don't touch, `null` = clear, value = set. Serde
// maps them onto `Option<Option<T>>` with `#[serde(default)]`.
#[derive(Deserialize)]
pub struct UpdateProfileBody {
pub display_name: Option<String>,
#[serde(default)]
pub display_name: Option<Option<String>>,
#[serde(default)]
pub locale: Option<Option<String>>,
}
pub async fn update_profile(
@@ -145,13 +190,27 @@ pub async fn update_profile(
.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?;
if let Some(display_name) = body.display_name {
skald_core::db::users::rename(
skald.db(),
&auth.user_id,
&user.username,
display_name.as_deref().filter(|s| !s.trim().is_empty()),
)
.await?;
}
if let Some(locale) = body.locale {
match locale.as_deref().map(str::trim) {
None | Some("") => {
skald_core::db::users::set_locale(skald.db(), &auth.user_id, None).await?;
}
Some(l) if skald_core::i18n::is_supported(l) => {
skald_core::db::users::set_locale(skald.db(), &auth.user_id, Some(l)).await?;
}
Some(_) => return Err(ApiError::bad_request("unsupported locale")),
}
}
Ok(Json(serde_json::json!({ "ok": true })))
}
+7
View File
@@ -22,6 +22,7 @@ pub mod roles;
pub mod run_context;
pub mod sessions;
pub mod setup;
pub mod shared_folders;
pub mod transcribe_audio;
pub mod transcribe_models;
pub mod tts_models;
@@ -176,6 +177,12 @@ pub fn router() -> Router<Arc<Skald>> {
.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))
// Shared on-disk folders (blueprint §6) — admin-curated, capability-gated.
.route("/shared-folders", get(shared_folders::list).post(shared_folders::create))
.route("/shared-folders/{id}", patch(shared_folders::update_description).delete(shared_folders::delete))
.route("/shared-folders/{id}/members", post(shared_folders::add_member))
.route("/shared-folders/{id}/members/{user_id}", delete(shared_folders::remove_member))
// 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)
+15
View File
@@ -30,6 +30,9 @@ pub struct CreateUserBody {
pub password: String,
#[serde(default)]
pub encrypted: bool,
/// Chosen interface language — becomes the instance default (`ui_locale`).
#[serde(default)]
pub locale: Option<String>,
}
#[derive(Serialize)]
@@ -54,11 +57,23 @@ pub async fn create_user(
if body.password.is_empty() {
return Err(ApiError::bad_request("password must not be empty"));
}
let locale = body.locale.as_deref().map(str::trim).filter(|s| !s.is_empty());
if let Some(l) = locale {
if !skald_core::i18n::is_supported(l) {
return Err(ApiError::bad_request("unsupported locale"));
}
}
let id = skald
.users()
.register_user(username, None, "admin", Some(&body.password), body.encrypted)
.await?;
// The first-run language choice is instance-wide: it lands in the registry
// config as the default every user follows until they override it.
if let Some(l) = locale {
skald.config().set(skald_core::i18n::DEFAULT_LOCALE_KEY, l).await?;
}
Ok(Json(CreateUserResult { user_id: id }))
}
+233
View File
@@ -0,0 +1,233 @@
//! Shared on-disk folders management API (blueprint §6/§0.1).
//!
//! Admin-curated shared directories. The admin creates a folder, describes what it
//! holds (the description is injected into the agent's system context so it knows
//! what to store there and when to read it), and grants members read-only or
//! read-write access. Capability-gated on `MANAGE_SHARED_FOLDERS` — admin-only for
//! now, but a single `grant` opens it to any role (§0.1). Never agent-driven.
//!
//! The folder rows + membership live in the registry (`system.db`); the physical
//! directory `{WD}/shared/{name}` is created here so the bind-mount has a source
//! and the admin can drop files in immediately. Propagating a membership change
//! into a *running* container (recreate) and into a logged-in user's fs view +
//! system prompt is the follow-on step — see blueprint §6.
use std::sync::Arc;
use axum::extract::{Extension, Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use skald_core::db::{role_capabilities, shared_folders, users};
use skald_core::skald::Skald;
use super::guard::AuthUser;
use super::ApiError;
// ── helpers ───────────────────────────────────────────────────────────────────
/// Fails with 403 unless the caller's role may manage shared folders. `admin`
/// holds every capability by construction (`role_capabilities::has`).
async fn require_manage(skald: &Skald, user_id: &str) -> Result<(), ApiError> {
let user = users::get(skald.db(), user_id)
.await?
.ok_or_else(|| ApiError::unauthorized("unknown user"))?;
if role_capabilities::has(skald.db(), &user.role_id, role_capabilities::MANAGE_SHARED_FOLDERS)
.await?
{
Ok(())
} else {
Err(ApiError::forbidden("your role cannot manage shared folders"))
}
}
/// Creates `{WD}/shared/{name}` — the bind-mount source. `name` is already
/// validated as a single safe component, so it cannot escape the shared root.
fn create_shared_dir(name: &str) -> Result<(), ApiError> {
let dir = std::env::current_dir()?
.join(skald_core::container::SHARED_DIR)
.join(name);
std::fs::create_dir_all(&dir)
.map_err(|e| ApiError::bad_request(format!("failed to create folder directory: {e}")))?;
Ok(())
}
/// Applies a membership change to a user's live environment — recreate their
/// container with the new mounts and, if they are logged in, refresh their fs view
/// + per-user MCP in place (blueprint §6 remount). Best-effort: the membership row
/// is already committed, so a Docker hiccup is logged, not surfaced — it settles at
/// the user's next login/boot.
async fn remount(skald: &Skald, user_id: &str) {
if let Err(e) = skald.refresh_user_shared_folders(user_id).await {
tracing::warn!(user = %user_id, error = %e,
"shared-folder remount failed (settles at next login/boot)");
}
}
// ── response / request types ──────────────────────────────────────────────────
#[derive(Serialize)]
pub struct MemberView {
pub user_id: String,
pub can_write: bool,
}
/// A folder plus its membership. Member identities are just ids — the frontend
/// joins them against `/api/users`, which it already loads for the member picker.
#[derive(Serialize)]
pub struct FolderView {
pub id: i64,
pub folder_name: String,
pub description: String,
pub created_at: String,
pub members: Vec<MemberView>,
}
async fn folder_view(skald: &Skald, f: shared_folders::SharedFolder) -> Result<FolderView, ApiError> {
let members = shared_folders::members(skald.db(), f.id)
.await?
.into_iter()
.map(|m| MemberView { user_id: m.user_id, can_write: m.can_write })
.collect();
Ok(FolderView {
id: f.id,
folder_name: f.folder_name,
description: f.description,
created_at: f.created_at,
members,
})
}
// ── GET /api/shared-folders ───────────────────────────────────────────────────
pub async fn list(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<FolderView>>, ApiError> {
require_manage(&skald, &auth.user_id).await?;
let folders = shared_folders::list_all(skald.db()).await?;
let mut out = Vec::with_capacity(folders.len());
for f in folders {
out.push(folder_view(&skald, f).await?);
}
Ok(Json(out))
}
// ── POST /api/shared-folders ──────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CreateBody {
pub folder_name: String,
#[serde(default)]
pub description: String,
}
pub async fn create(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<CreateBody>,
) -> Result<Json<FolderView>, ApiError> {
require_manage(&skald, &auth.user_id).await?;
let name = body.folder_name.trim();
if !shared_folders::is_valid_folder_name(name) {
return Err(ApiError::bad_request(
"folder name must be a single path component (no '/', '\\', '.' or '..')",
));
}
if shared_folders::get_by_name(skald.db(), name).await?.is_some() {
return Err(ApiError::bad_request(format!("a folder named '{name}' already exists")));
}
let id = shared_folders::create(skald.db(), name, body.description.trim()).await?;
// The bind-mount needs a real directory to point at; make it now.
create_shared_dir(name)?;
let folder = shared_folders::get(skald.db(), id)
.await?
.ok_or_else(|| ApiError::not_found("folder vanished after creation"))?;
Ok(Json(folder_view(&skald, folder).await?))
}
// ── PATCH /api/shared-folders/{id} — description only (no rename) ──────────────
#[derive(Deserialize)]
pub struct DescriptionBody {
pub description: String,
}
pub async fn update_description(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<DescriptionBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
require_manage(&skald, &auth.user_id).await?;
if shared_folders::get(skald.db(), id).await?.is_none() {
return Err(ApiError::not_found("no such folder"));
}
shared_folders::set_description(skald.db(), id, body.description.trim()).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ── DELETE /api/shared-folders/{id} ───────────────────────────────────────────
pub async fn delete(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
require_manage(&skald, &auth.user_id).await?;
// Capture the members before the cascade delete so each can be unmounted after.
let members = shared_folders::members(skald.db(), id).await.unwrap_or_default();
shared_folders::delete(skald.db(), id).await?;
// The on-disk directory is deliberately left in place: unsharing a folder must
// not destroy the files inside it. The admin removes them by hand if intended.
for m in &members {
remount(&skald, &m.user_id).await;
}
Ok(Json(serde_json::json!({ "ok": true })))
}
// ── POST /api/shared-folders/{id}/members ── add or re-grant (RO/RW) ───────────
#[derive(Deserialize)]
pub struct MemberBody {
pub user_id: String,
#[serde(default)]
pub can_write: bool,
}
pub async fn add_member(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<MemberBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
require_manage(&skald, &auth.user_id).await?;
if shared_folders::get(skald.db(), id).await?.is_none() {
return Err(ApiError::not_found("no such folder"));
}
// Catch an unknown user id here for a clean 400 — the membership FK would
// otherwise surface it as an opaque 500.
if users::get(skald.db(), &body.user_id).await?.is_none() {
return Err(ApiError::bad_request("no such user"));
}
shared_folders::add_member(skald.db(), id, &body.user_id, body.can_write).await?;
// Mount the folder into (or re-grant RO/RW inside) the member's environment.
remount(&skald, &body.user_id).await;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ── DELETE /api/shared-folders/{id}/members/{user_id} ─────────────────────────
pub async fn remove_member(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path((id, user_id)): Path<(i64, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
require_manage(&skald, &auth.user_id).await?;
shared_folders::remove_member(skald.db(), id, &user_id).await?;
// Unmount the folder from the (former) member's environment.
remount(&skald, &user_id).await;
Ok(Json(serde_json::json!({ "ok": true })))
}