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:
+71
-12
@@ -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 })))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
Reference in New Issue
Block a user