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:
@@ -400,6 +400,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
database_password BLOB,
|
||||
password_hash BLOB,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
locale TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK (
|
||||
@@ -410,6 +411,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// Per-user UI locale override is additive — reaches an existing DB in place.
|
||||
ensure_column(pool, "users", "locale", "TEXT").await?;
|
||||
|
||||
// Shared on-disk folders (blueprint §6/§0.1): a named directory
|
||||
// `{WD}/shared/{folder_name}` bind-mounted into the container of each member.
|
||||
@@ -422,11 +425,16 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
"CREATE TABLE IF NOT EXISTS shared_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// The folder's description is injected into the agent's system context so it
|
||||
// knows what each shared folder holds and when to read/write it. Additive —
|
||||
// reaches an existing DB in place (a no-op on the fresh CREATE above).
|
||||
ensure_column(pool, "shared_folders", "description", "TEXT NOT NULL DEFAULT ''").await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS shared_folder_members (
|
||||
|
||||
@@ -25,6 +25,12 @@ pub const REGISTER_LOCAL_SCRIPT: &str = "mcp.register_local_script";
|
||||
/// Curate the connector catalog (admin only).
|
||||
pub const MANAGE_CATALOG: &str = "mcp.manage_catalog";
|
||||
|
||||
/// Manage shared on-disk folders — create/describe/delete and grant membership
|
||||
/// (blueprint §6). Admin-only for now; not in [`DEFAULT_USER_CAPABILITIES`], so
|
||||
/// `admin` holds it implicitly (via [`has`]) and opening it to another role later
|
||||
/// is a single [`grant`], no code change.
|
||||
pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ use sqlx::SqlitePool;
|
||||
pub struct SharedFolder {
|
||||
pub id: i64,
|
||||
pub folder_name: String,
|
||||
/// What the folder holds — injected into the agent's system context so it
|
||||
/// knows what to store here and when to read it. Admin-authored (§6).
|
||||
pub description: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -59,25 +62,50 @@ pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Share
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<SharedFolder>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String)>(
|
||||
"SELECT id, folder_name, created_at FROM shared_folders ORDER BY folder_name",
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders ORDER BY folder_name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at })
|
||||
.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, folder_id: i64) -> Result<Option<SharedFolder>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders WHERE id = ?",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_by_name(pool: &SqlitePool, folder_name: &str) -> Result<Option<SharedFolder>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, String)>(
|
||||
"SELECT id, folder_name, created_at FROM shared_folders WHERE folder_name = ?",
|
||||
let row = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, folder_name, description, created_at FROM shared_folders WHERE folder_name = ?",
|
||||
)
|
||||
.bind(folder_name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, folder_name, created_at)| SharedFolder { id, folder_name, created_at }))
|
||||
Ok(row.map(|(id, folder_name, description, created_at)| SharedFolder {
|
||||
id,
|
||||
folder_name,
|
||||
description,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The members of a folder — the set of users whose containers mount it.
|
||||
@@ -98,15 +126,27 @@ pub async fn members(pool: &SqlitePool, folder_id: i64) -> Result<Vec<FolderMemb
|
||||
|
||||
/// Creates a folder, returning its id. `folder_name` must already be validated as
|
||||
/// a safe path component (see [`is_valid_folder_name`]).
|
||||
pub async fn create(pool: &SqlitePool, folder_name: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO shared_folders (folder_name) VALUES (?)")
|
||||
pub async fn create(pool: &SqlitePool, folder_name: &str, description: &str) -> Result<i64> {
|
||||
let id = sqlx::query("INSERT INTO shared_folders (folder_name, description) VALUES (?, ?)")
|
||||
.bind(folder_name)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Updates a folder's description — the agent-facing text. No-op if `folder_id`
|
||||
/// no longer exists.
|
||||
pub async fn set_description(pool: &SqlitePool, folder_id: i64, description: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE shared_folders SET description = ? WHERE id = ?")
|
||||
.bind(description)
|
||||
.bind(folder_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds (or updates the capability of) a member. Idempotent on the PK.
|
||||
pub async fn add_member(
|
||||
pool: &SqlitePool,
|
||||
|
||||
@@ -65,6 +65,8 @@ pub struct User {
|
||||
pub role_id: String,
|
||||
pub credentials: Credentials,
|
||||
pub active: bool,
|
||||
/// UI locale override (NULL = follow the instance default).
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -78,6 +80,7 @@ pub struct UserSummary {
|
||||
pub role_id: String,
|
||||
pub encrypted: bool,
|
||||
pub active: bool,
|
||||
pub locale: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -95,6 +98,7 @@ impl User {
|
||||
role_id: self.role_id.clone(),
|
||||
encrypted: self.is_encrypted(),
|
||||
active: self.active,
|
||||
locale: self.locale.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
}
|
||||
@@ -140,6 +144,7 @@ struct Row {
|
||||
database_password: Option<Vec<u8>>,
|
||||
password_hash: Option<Vec<u8>>,
|
||||
active: bool,
|
||||
locale: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -150,7 +155,7 @@ macro_rules! select {
|
||||
($tail:literal) => {
|
||||
concat!(
|
||||
"SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ",
|
||||
"database_password, password_hash, active, created_at, updated_at FROM users ",
|
||||
"database_password, password_hash, active, locale, created_at, updated_at FROM users ",
|
||||
$tail
|
||||
)
|
||||
};
|
||||
@@ -185,6 +190,7 @@ impl TryFrom<Row> for User {
|
||||
role_id: r.role_id,
|
||||
credentials,
|
||||
active: r.active,
|
||||
locale: r.locale,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
@@ -370,6 +376,22 @@ pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: O
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets (or clears, with `None`) the user's UI locale override.
|
||||
pub async fn set_locale(pool: &SqlitePool, id: &str, locale: Option<&str>) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET locale = ?2, updated_at = datetime('now') WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(locale)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the directory row only. The caller still owns `database/{id}.db`:
|
||||
/// erasing a user means deleting that file too.
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -556,6 +578,7 @@ mod tests {
|
||||
role_id: "admin".into(),
|
||||
credentials: encrypted(),
|
||||
active: true,
|
||||
locale: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user