From 2b8ec1c1d9eec60b66b0fc25ecd665179847bb73 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 12 May 2026 10:49:44 -0500 Subject: [PATCH 1/4] fix: add permissions to the internal API Signed-off-by: Trezy --- src/admin/mod.rs | 1 + src/admin/permissions.rs | 465 ++++++++++++++++++++++++++++++++++++++- src/admin/users.rs | 52 ++++- tests/e2e_permissions.rs | 258 ++++++++++++++++++++++ web/src/lib/api.ts | 23 ++ 5 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 tests/e2e_permissions.rs diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 6a65c66..be15a5f 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -125,4 +125,5 @@ pub fn admin_routes(_state: AppState) -> Router { .route("/dead-letters/{id}/dismiss", post(dead_letters::dismiss)) .route("/dead-letters/{id}/retry", post(dead_letters::retry)) .route("/dead-letters/{id}/reindex", post(dead_letters::reindex)) + .route("/permissions", get(users::list_permissions)) } diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs index 3a8406f..1bc0977 100644 --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -2,7 +2,15 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; -/// All 37 permissions in the system. +#[derive(Serialize)] +pub struct PermissionInfo { + pub key: &'static str, + pub name: &'static str, + pub description: &'static str, + pub category: &'static str, +} + +/// All permissions in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Permission { #[serde(rename = "lexicons:create")] @@ -150,6 +158,257 @@ impl Permission { } } + pub fn info(&self) -> PermissionInfo { + match self { + Self::LexiconsCreate => PermissionInfo { + key: "lexicons:create", + name: "Create Lexicons", + description: "Upload and register new lexicon schemas", + category: "Lexicons", + }, + Self::LexiconsRead => PermissionInfo { + key: "lexicons:read", + name: "View Lexicons", + description: "View registered lexicon schemas", + category: "Lexicons", + }, + Self::LexiconsDelete => PermissionInfo { + key: "lexicons:delete", + name: "Delete Lexicons", + description: "Remove lexicon schemas", + category: "Lexicons", + }, + Self::RecordsRead => PermissionInfo { + key: "records:read", + name: "View Records", + description: "Browse indexed AT Protocol records", + category: "Records", + }, + Self::RecordsDelete => PermissionInfo { + key: "records:delete", + name: "Delete Records", + description: "Delete individual records from the index", + category: "Records", + }, + Self::RecordsDeleteCollection => PermissionInfo { + key: "records:delete-collection", + name: "Delete Collections", + description: "Bulk-delete all records in a collection", + category: "Records", + }, + Self::ScriptVariablesCreate => PermissionInfo { + key: "script-variables:create", + name: "Create Script Variables", + description: "Add or update environment variables for Lua scripts", + category: "Script Variables", + }, + Self::ScriptVariablesRead => PermissionInfo { + key: "script-variables:read", + name: "View Script Variables", + description: "View script environment variable keys and values", + category: "Script Variables", + }, + Self::ScriptVariablesDelete => PermissionInfo { + key: "script-variables:delete", + name: "Delete Script Variables", + description: "Remove script environment variables", + category: "Script Variables", + }, + Self::UsersCreate => PermissionInfo { + key: "users:create", + name: "Create Users", + description: "Add new dashboard users", + category: "Users", + }, + Self::UsersRead => PermissionInfo { + key: "users:read", + name: "View Users", + description: "View the user list and their permissions", + category: "Users", + }, + Self::UsersUpdate => PermissionInfo { + key: "users:update", + name: "Update Users", + description: "Modify user permissions", + category: "Users", + }, + Self::UsersDelete => PermissionInfo { + key: "users:delete", + name: "Delete Users", + description: "Remove dashboard users", + category: "Users", + }, + Self::ApiKeysCreate => PermissionInfo { + key: "api-keys:create", + name: "Create API Keys", + description: "Generate new API keys for admin access", + category: "API Keys", + }, + Self::ApiKeysRead => PermissionInfo { + key: "api-keys:read", + name: "View API Keys", + description: "View existing API keys", + category: "API Keys", + }, + Self::ApiKeysDelete => PermissionInfo { + key: "api-keys:delete", + name: "Revoke API Keys", + description: "Revoke existing API keys", + category: "API Keys", + }, + Self::BackfillCreate => PermissionInfo { + key: "backfill:create", + name: "Start Backfill", + description: "Trigger historical record backfill jobs", + category: "Backfill", + }, + Self::BackfillRead => PermissionInfo { + key: "backfill:read", + name: "View Backfill", + description: "View backfill job status and progress", + category: "Backfill", + }, + Self::StatsRead => PermissionInfo { + key: "stats:read", + name: "View Stats", + description: "View collection statistics and record counts", + category: "System", + }, + Self::EventsRead => PermissionInfo { + key: "events:read", + name: "View Events", + description: "View the event log", + category: "System", + }, + Self::LabelersCreate => PermissionInfo { + key: "labelers:create", + name: "Add Labelers", + description: "Subscribe to external labeler services", + category: "Labelers", + }, + Self::LabelersRead => PermissionInfo { + key: "labelers:read", + name: "View Labelers", + description: "View subscribed labeler services", + category: "Labelers", + }, + Self::LabelersDelete => PermissionInfo { + key: "labelers:delete", + name: "Remove Labelers", + description: "Unsubscribe from labeler services", + category: "Labelers", + }, + Self::SettingsManage => PermissionInfo { + key: "settings:manage", + name: "Manage Settings", + description: "Modify instance settings, logo, and configuration", + category: "Settings", + }, + Self::PluginsRead => PermissionInfo { + key: "plugins:read", + name: "View Plugins", + description: "View installed plugins and their configuration", + category: "Plugins", + }, + Self::PluginsCreate => PermissionInfo { + key: "plugins:create", + name: "Install Plugins", + description: "Install and configure new plugins", + category: "Plugins", + }, + Self::PluginsDelete => PermissionInfo { + key: "plugins:delete", + name: "Remove Plugins", + description: "Uninstall plugins", + category: "Plugins", + }, + Self::ApiClientsView => PermissionInfo { + key: "api-clients:view", + name: "View API Clients", + description: "View registered OAuth API clients", + category: "API Clients", + }, + Self::ApiClientsCreate => PermissionInfo { + key: "api-clients:create", + name: "Create API Clients", + description: "Register new OAuth API clients", + category: "API Clients", + }, + Self::ApiClientsEdit => PermissionInfo { + key: "api-clients:edit", + name: "Edit API Clients", + description: "Modify API client settings and credentials", + category: "API Clients", + }, + Self::ApiClientsDelete => PermissionInfo { + key: "api-clients:delete", + name: "Delete API Clients", + description: "Remove registered API clients", + category: "API Clients", + }, + Self::DeadLettersRead => PermissionInfo { + key: "dead-letters:read", + name: "View Dead Letters", + description: "View failed hook executions", + category: "Dead Letters", + }, + Self::DeadLettersManage => PermissionInfo { + key: "dead-letters:manage", + name: "Manage Dead Letters", + description: "Retry, re-index, or dismiss dead letters", + category: "Dead Letters", + }, + Self::SpacesCreate => PermissionInfo { + key: "spaces:create", + name: "Create Spaces", + description: "Create new permissioned data spaces", + category: "Spaces", + }, + Self::SpacesRead => PermissionInfo { + key: "spaces:read", + name: "View Spaces", + description: "View space details and metadata", + category: "Spaces", + }, + Self::SpacesUpdate => PermissionInfo { + key: "spaces:update", + name: "Update Spaces", + description: "Modify space settings", + category: "Spaces", + }, + Self::SpacesDelete => PermissionInfo { + key: "spaces:delete", + name: "Delete Spaces", + description: "Remove spaces and their data", + category: "Spaces", + }, + Self::SpacesManageMembers => PermissionInfo { + key: "spaces:manage-members", + name: "Manage Members", + description: "Add or remove space members and roles", + category: "Spaces", + }, + Self::SpacesManageInvites => PermissionInfo { + key: "spaces:manage-invites", + name: "Manage Invites", + description: "Create and revoke space invitations", + category: "Spaces", + }, + Self::SpacesManageRecords => PermissionInfo { + key: "spaces:manage-records", + name: "Manage Records", + description: "Read and write records within spaces", + category: "Spaces", + }, + Self::SpacesManageCredentials => PermissionInfo { + key: "spaces:manage-credentials", + name: "Manage Credentials", + description: "Issue and revoke space access credentials", + category: "Spaces", + }, + } + } + /// All permissions. pub fn all() -> HashSet { HashSet::from([ @@ -198,8 +457,65 @@ impl Permission { } } +/// Ordered list of all permissions with metadata. +pub fn catalog() -> Vec { + use Permission::*; + [ + LexiconsCreate, + LexiconsRead, + LexiconsDelete, + RecordsRead, + RecordsDelete, + RecordsDeleteCollection, + ScriptVariablesCreate, + ScriptVariablesRead, + ScriptVariablesDelete, + UsersCreate, + UsersRead, + UsersUpdate, + UsersDelete, + ApiKeysCreate, + ApiKeysRead, + ApiKeysDelete, + BackfillCreate, + BackfillRead, + StatsRead, + EventsRead, + LabelersCreate, + LabelersRead, + LabelersDelete, + SettingsManage, + PluginsRead, + PluginsCreate, + PluginsDelete, + ApiClientsView, + ApiClientsCreate, + ApiClientsEdit, + ApiClientsDelete, + DeadLettersRead, + DeadLettersManage, + SpacesCreate, + SpacesRead, + SpacesUpdate, + SpacesDelete, + SpacesManageMembers, + SpacesManageInvites, + SpacesManageRecords, + SpacesManageCredentials, + ] + .iter() + .map(|p| p.info()) + .collect() +} + +/// Check whether a permission string is recognized. +#[allow(dead_code)] +pub fn is_valid(key: &str) -> bool { + serde_json::from_value::(serde_json::Value::String(key.to_string())).is_ok() +} + /// Predefined permission templates. -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum Template { Viewer, @@ -208,7 +524,47 @@ pub enum Template { FullAccess, } +#[derive(Serialize)] +pub struct TemplateInfo { + pub key: String, + pub label: &'static str, + pub permissions: Vec<&'static str>, +} + impl Template { + pub const ALL: &[Template] = &[ + Template::Viewer, + Template::Operator, + Template::Manager, + Template::FullAccess, + ]; + + pub fn key(&self) -> &'static str { + match self { + Self::Viewer => "viewer", + Self::Operator => "operator", + Self::Manager => "manager", + Self::FullAccess => "full_access", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Viewer => "Viewer", + Self::Operator => "Operator", + Self::Manager => "Manager", + Self::FullAccess => "Full Access", + } + } + + pub fn info(&self) -> TemplateInfo { + TemplateInfo { + key: self.key().to_string(), + label: self.label(), + permissions: self.permissions().iter().map(|p| p.as_str()).collect(), + } + } + pub fn permissions(&self) -> HashSet { match self { Self::Viewer => HashSet::from([ @@ -262,3 +618,108 @@ impl Template { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_covers_all_permissions() { + let catalog_keys: Vec<&str> = catalog().iter().map(|p| p.key).collect(); + for perm in Permission::all() { + assert!( + catalog_keys.contains(&perm.as_str()), + "Permission {} missing from catalog()", + perm.as_str() + ); + } + } + + #[test] + fn catalog_has_no_duplicates() { + let entries = catalog(); + let mut seen = std::collections::HashSet::new(); + for entry in &entries { + assert!( + seen.insert(entry.key), + "Duplicate key in catalog: {}", + entry.key + ); + } + } + + #[test] + fn info_key_matches_as_str() { + for perm in Permission::all() { + assert_eq!(perm.info().key, perm.as_str()); + } + } + + #[test] + fn info_fields_are_nonempty() { + for perm in Permission::all() { + let info = perm.info(); + assert!(!info.name.is_empty(), "{} has empty name", info.key); + assert!( + !info.description.is_empty(), + "{} has empty description", + info.key + ); + assert!(!info.category.is_empty(), "{} has empty category", info.key); + } + } + + #[test] + fn is_valid_accepts_known_permissions() { + assert!(is_valid("lexicons:create")); + assert!(is_valid("spaces:manage-members")); + } + + #[test] + fn is_valid_rejects_unknown_permissions() { + assert!(!is_valid("fake:permission")); + assert!(!is_valid("")); + } + + #[test] + fn template_full_access_covers_all() { + assert_eq!(Template::FullAccess.permissions(), Permission::all()); + } + + #[test] + fn template_viewer_is_subset_of_operator() { + let viewer = Template::Viewer.permissions(); + let operator = Template::Operator.permissions(); + assert!(viewer.is_subset(&operator)); + } + + #[test] + fn template_operator_is_subset_of_manager() { + let operator = Template::Operator.permissions(); + let manager = Template::Manager.permissions(); + assert!(operator.is_subset(&manager)); + } + + #[test] + fn template_info_permissions_match_template_permissions() { + for t in Template::ALL { + let info = t.info(); + let expected: HashSet<&str> = t.permissions().iter().map(|p| p.as_str()).collect(); + let actual: HashSet<&str> = info.permissions.into_iter().collect(); + assert_eq!(expected, actual, "Template {:?} info mismatch", t); + } + } + + #[test] + fn spaces_permissions_are_in_spaces_category() { + for entry in catalog() { + if entry.key.starts_with("spaces:") { + assert_eq!( + entry.category, "Spaces", + "{} should be in Spaces category", + entry.key + ); + } + } + } +} diff --git a/src/admin/users.rs b/src/admin/users.rs index 6b8aaf7..375da13 100644 --- a/src/admin/users.rs +++ b/src/admin/users.rs @@ -10,7 +10,7 @@ use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; use super::auth::UserAuth; -use super::permissions::Permission; +use super::permissions::{self, Permission}; use super::types::{CreateUserBody, TransferSuperBody, UpdatePermissionsBody, UserSummary}; /// POST /admin/users — create a new user with template or explicit permissions. @@ -480,3 +480,53 @@ pub(super) async fn transfer_super( Ok(StatusCode::NO_CONTENT) } + +/// GET /admin/permissions — list all available permissions and templates. +pub(super) async fn list_permissions( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::UsersRead).await?; + + let spaces_enabled = crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await; + + let all_permissions: Vec = permissions::catalog() + .into_iter() + .filter(|p| spaces_enabled || p.category != "Spaces") + .map(|p| { + serde_json::json!({ + "key": p.key, + "name": p.name, + "description": p.description, + "category": p.category, + }) + }) + .collect(); + + let templates: Vec = permissions::Template::ALL + .iter() + .map(|t| { + let info = t.info(); + let perms: Vec<&str> = info + .permissions + .into_iter() + .filter(|p| spaces_enabled || !p.starts_with("spaces:")) + .collect(); + serde_json::json!({ + "key": info.key, + "label": info.label, + "permissions": perms, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "permissions": all_permissions, + "templates": templates, + }))) +} diff --git a/tests/e2e_permissions.rs b/tests/e2e_permissions.rs new file mode 100644 index 0000000..6827d23 --- /dev/null +++ b/tests/e2e_permissions.rs @@ -0,0 +1,258 @@ +mod common; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_put( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method(Method::PUT) + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .method(Method::DELETE) + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +#[serial] +async fn permissions_requires_auth() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/permissions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn permissions_returns_catalog() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().expect("permissions array"); + assert!(!permissions.is_empty()); + + let first = &permissions[0]; + assert!(first["key"].is_string()); + assert!(first["name"].is_string()); + assert!(first["description"].is_string()); + assert!(first["category"].is_string()); + + let templates = body["templates"].as_array().expect("templates array"); + assert!(!templates.is_empty()); + + let first_template = &templates[0]; + assert!(first_template["key"].is_string()); + assert!(first_template["label"].is_string()); + assert!(first_template["permissions"].is_array()); +} + +#[tokio::test] +#[serial] +async fn permissions_excludes_spaces_when_flag_disabled() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().unwrap(); + let has_spaces = permissions + .iter() + .any(|p| p["key"].as_str().unwrap_or("").starts_with("spaces:")); + assert!( + !has_spaces, + "spaces permissions should be excluded when flag is disabled" + ); + + let has_spaces_category = permissions + .iter() + .any(|p| p["category"].as_str().unwrap_or("") == "Spaces"); + assert!( + !has_spaces_category, + "Spaces category should not appear when flag is disabled" + ); + + let templates = body["templates"].as_array().unwrap(); + for template in templates { + let perms = template["permissions"].as_array().unwrap(); + let has_spaces_perm = perms + .iter() + .any(|p| p.as_str().unwrap_or("").starts_with("spaces:")); + assert!( + !has_spaces_perm, + "template {:?} should not contain spaces permissions when flag is disabled", + template["key"] + ); + } +} + +#[tokio::test] +#[serial] +async fn permissions_includes_spaces_when_flag_enabled() { + common::require_db!(); + let app = TestApp::new().await; + + // Enable the spaces feature flag + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().unwrap(); + let has_spaces = permissions + .iter() + .any(|p| p["key"].as_str().unwrap_or("").starts_with("spaces:")); + assert!( + has_spaces, + "spaces permissions should be included when flag is enabled" + ); + + let templates = body["templates"].as_array().unwrap(); + let manager = templates + .iter() + .find(|t| t["key"] == "manager") + .expect("manager template"); + let manager_perms = manager["permissions"].as_array().unwrap(); + let has_spaces_perm = manager_perms + .iter() + .any(|p| p.as_str().unwrap_or("").starts_with("spaces:")); + assert!( + has_spaces_perm, + "manager template should include spaces permissions when flag is enabled" + ); +} + +#[tokio::test] +#[serial] +async fn permissions_spaces_removed_after_disabling_flag() { + common::require_db!(); + let app = TestApp::new().await; + + // Enable + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Disable + let resp = app + .router + .clone() + .oneshot(admin_delete( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().unwrap(); + let has_spaces = permissions + .iter() + .any(|p| p["key"].as_str().unwrap_or("").starts_with("spaces:")); + assert!( + !has_spaces, + "spaces permissions should be gone after disabling flag" + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f05a348..fcee80a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -213,6 +213,29 @@ export function transferSuper(body: { target_user_id: string }) { }); } +// Permissions catalog +export type PermissionEntry = { + key: string; + name: string; + description: string; + category: string; +}; + +export type PermissionTemplate = { + key: string; + label: string; + permissions: string[]; +}; + +export type PermissionsCatalog = { + permissions: PermissionEntry[]; + templates: PermissionTemplate[]; +}; + +export function getPermissions() { + return apiFetch("/admin/permissions"); +} + // API Keys export function getApiKeys() { return apiFetch("/admin/api-keys"); -- 2.51.2 From 7f49a77771b70acc076fb6d6e1613a652c994b20 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 12 May 2026 10:50:18 -0500 Subject: [PATCH 2/4] fix: allow sheets to be much larger Signed-off-by: Trezy --- web/src/app/dashboard/dead-letters/page.tsx | 2 +- web/src/app/dashboard/records/page.tsx | 2 +- web/src/components/ui/sheet.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/app/dashboard/dead-letters/page.tsx b/web/src/app/dashboard/dead-letters/page.tsx index 24ff16f..d871d5d 100644 --- a/web/src/app/dashboard/dead-letters/page.tsx +++ b/web/src/app/dashboard/dead-letters/page.tsx @@ -560,7 +560,7 @@ export default function DeadLettersPage() { if (!open) setViewDetail(null); }} > - + {viewDetail && ( <> diff --git a/web/src/app/dashboard/records/page.tsx b/web/src/app/dashboard/records/page.tsx index 01cbc40..4f0bb02 100644 --- a/web/src/app/dashboard/records/page.tsx +++ b/web/src/app/dashboard/records/page.tsx @@ -442,7 +442,7 @@ export default function RecordsPage() { if (!open) setViewRecord(null); }} > - + {viewRecord && ( <> diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx index 5963090..cd427e3 100644 --- a/web/src/components/ui/sheet.tsx +++ b/web/src/components/ui/sheet.tsx @@ -62,9 +62,9 @@ function SheetContent({ className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", side === "right" && - "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", + "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-[90%] border-l lg:w-1/2", side === "left" && - "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-[90%] border-r lg:w-1/2", side === "top" && "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", side === "bottom" && -- 2.51.2 From 32942fb2d4b71465ccfadd62d0f64f8df72d0e6c Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 12 May 2026 10:50:57 -0500 Subject: [PATCH 3/4] fix: move user permission management into sheets fixes #23 Signed-off-by: Trezy --- web/src/app/dashboard/settings/users/page.tsx | 438 +++++++++++------- 1 file changed, 266 insertions(+), 172 deletions(-) diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx index 84c6d45..e41f8cd 100644 --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; -import { ChevronDown, ChevronRight, Shield, Trash2 } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronRight, Search, Shield, Trash2 } from "lucide-react"; import { useAuth } from "@/lib/auth-context"; import { @@ -10,13 +10,16 @@ import { deleteUser, updateUserPermissions, transferSuper, + getPermissions, } from "@/lib/api"; +import type { PermissionEntry, PermissionTemplate } from "@/lib/api"; import type { UserSummary } from "@/types/users"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Select, @@ -43,45 +46,61 @@ import { ResponsiveDialogTitle, ResponsiveDialogTrigger, } from "@/components/ui/responsive-dialog"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; -const PERMISSION_CATEGORIES: Record = { - Lexicons: ["lexicons:create", "lexicons:read", "lexicons:delete"], - Records: ["records:read", "records:delete", "records:delete-collection"], - "Script Variables": [ - "script-variables:create", - "script-variables:read", - "script-variables:delete", - ], - Users: ["users:create", "users:read", "users:update", "users:delete"], - "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], - Backfill: ["backfill:create", "backfill:read"], - "API Clients": ["api-clients:view", "api-clients:create", "api-clients:edit", "api-clients:delete"], - Plugins: ["plugins:read", "plugins:create", "plugins:delete"], - System: ["stats:read", "events:read"], +type BskyProfile = { + avatar?: string; + displayName?: string; + description?: string; }; -const ALL_PERMISSIONS = Object.values(PERMISSION_CATEGORIES).flat(); - -const TEMPLATES = [ - { value: "viewer", label: "Viewer" }, - { value: "operator", label: "Operator" }, - { value: "manager", label: "Manager" }, - { value: "full_access", label: "Full Access" }, -] as const; - -const TEMPLATE_PERMISSIONS: Record = { - viewer: ["lexicons:read", "records:read", "script-variables:read", "users:read", "api-keys:read", "backfill:read", "stats:read", "events:read"], - operator: ["lexicons:read", "records:read", "records:delete", "script-variables:read", "script-variables:create", "users:read", "api-keys:read", "backfill:read", "backfill:create", "stats:read", "events:read"], - manager: ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:read", "api-keys:read", "backfill:create", "backfill:read", "stats:read", "events:read", "plugins:read", "plugins:create", "plugins:delete"], - full_access: ALL_PERMISSIONS, -}; +function buildCategories(permissions: PermissionEntry[]): Record { + const cats: Record = {}; + for (const p of permissions) { + if (!cats[p.category]) cats[p.category] = []; + cats[p.category].push(p); + } + return cats; +} export default function UsersPage() { const { did: currentDid } = useAuth(); const [users, setUsers] = useState([]); const [handles, setHandles] = useState>({}); const [error, setError] = useState(null); - const [expandedUserId, setExpandedUserId] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(null); + const [permSearch, setPermSearch] = useState(""); + const [permissionEntries, setPermissionEntries] = useState([]); + const [profiles, setProfiles] = useState>({}); + const [templates, setTemplates] = useState([]); + + const permissionCategories = React.useMemo(() => buildCategories(permissionEntries), [permissionEntries]); + const filteredCategories = useMemo(() => { + if (!permSearch.trim()) return permissionCategories; + const terms = permSearch.toLowerCase().split(/\s+/); + const result: Record = {}; + for (const [category, permissions] of Object.entries(permissionCategories)) { + const matched = permissions.filter((p) => { + const haystack = `${p.name} ${p.description} ${p.category} ${p.key}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }); + if (matched.length > 0) result[category] = matched; + } + return result; + }, [permissionCategories, permSearch]); + const allPermissionKeys = React.useMemo(() => permissionEntries.map((p) => p.key), [permissionEntries]); + const templatePermissions = React.useMemo(() => { + const map: Record = {}; + for (const t of templates) map[t.key] = t.permissions; + return map; + }, [templates]); const currentUser = users.find((u) => u.did === currentDid); const isCurrentUserSuper = currentUser?.is_super ?? false; @@ -94,6 +113,12 @@ export default function UsersPage() { useEffect(() => { load(); + getPermissions() + .then((catalog) => { + setPermissionEntries(catalog.permissions); + setTemplates(catalog.templates); + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))); }, [load]); // Resolve DIDs to handles via PLC directory @@ -116,6 +141,27 @@ export default function UsersPage() { } }, [users, handles]); + // Fetch Bluesky profile when a user is selected + useEffect(() => { + if (!selectedUserId) return; + const user = users.find((u) => u.id === selectedUserId); + if (!user || user.did in profiles) return; + fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.did)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setProfiles((prev) => ({ + ...prev, + [user.did]: { + avatar: data.avatar, + displayName: data.displayName, + description: data.description, + }, + })); + }) + .catch(() => {}); + }, [selectedUserId, users, profiles]); + async function handleDelete(id: string) { try { await deleteUser(id); @@ -193,7 +239,7 @@ export default function UsersPage() {

Users

{(isCurrentUserSuper || currentUser?.permissions.includes("users:create")) && ( - + )}
@@ -201,19 +247,18 @@ export default function UsersPage() { - User Permissions Created Last Used - + {users.length === 0 && ( No users yet. @@ -221,125 +266,169 @@ export default function UsersPage() { )} {users.map((user) => ( - - - -
+ + + {(() => { + const selectedUser = users.find((u) => u.id === selectedUserId); + return ( + { if (!open) { setSelectedUserId(null); setPermSearch(""); } }}> + + {selectedUser && ( + <> + + User + + + + + {profiles[selectedUser.did]?.avatar && ( + + )} +
+

+ {profiles[selectedUser.did]?.displayName || handles[selectedUser.did] ? ( + <> + {profiles[selectedUser.did]?.displayName && ( + {profiles[selectedUser.did].displayName} + )} + {handles[selectedUser.did] && ( + + @{handles[selectedUser.did]} + + )} + ) : ( - - )} - - - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > -

-
- {handles[user.did] && ( - @{handles[user.did]} + {selectedUser.did} )} - {user.did} -
- {user.is_super && ( - - Owner - - )} -
- - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > - {user.is_super - ? `${ALL_PERMISSIONS.length}/${ALL_PERMISSIONS.length}` - : `${user.permissions.length}/${ALL_PERMISSIONS.length}`} - - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > - {new Date(user.created_at).toLocaleString()} - - - setExpandedUserId( - expandedUserId === user.id ? null : user.id - ) - } - > - {user.last_used_at - ? new Date(user.last_used_at).toLocaleString() - : "Never"} - - -
- {isCurrentUserSuper && ( - handleTransferSuper(user.id)} - /> +

+

{selectedUser.did}

+ {profiles[selectedUser.did]?.description && ( +

{profiles[selectedUser.did].description}

)} -
-
- - {expandedUserId === user.id && ( - - - + + +
+
+ Role +

+ {selectedUser.is_super ? ( + Owner + ) : "Member"} +

+
+
+ Permissions +

+ {selectedUser.is_super + ? `${allPermissionKeys.length}/${allPermissionKeys.length}` + : `${selectedUser.permissions.filter((p) => allPermissionKeys.includes(p)).length}/${allPermissionKeys.length}`} +

+
+
+ Created +

{new Date(selectedUser.created_at).toLocaleString()}

+
+
+ Last Active +

+ {selectedUser.last_used_at + ? new Date(selectedUser.last_used_at).toLocaleString() + : "Never"} +

+
+
+ +
+ +
+ + setPermSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ +
+ + +
+ {isCurrentUserSuper && ( + handleTransferSuper(selectedUser.id)} /> - - - )} - - ))} - - -
+ )} + +
+ + + )} +
+
+ ); + })()} ); @@ -350,47 +439,50 @@ function PermissionsPanel({ isSelf, currentUserPermissions, isCurrentUserSuper, + filteredCategories, onToggle, }: { user: UserSummary; isSelf: boolean; currentUserPermissions: string[]; isCurrentUserSuper: boolean; + filteredCategories: Record; onToggle: (user: UserSummary, permission: string, enabled: boolean) => void; }) { const canUpdate = isCurrentUserSuper || currentUserPermissions.includes("users:update"); return ( -
- {Object.entries(PERMISSION_CATEGORIES).map(([category, permissions]) => ( -
+
+ {Object.entries(filteredCategories).map(([category, permissions]) => ( +

{category}

-
+
{permissions.map((perm) => { - const enabled = user.is_super || user.permissions.includes(perm); + const enabled = user.is_super || user.permissions.includes(perm.key); return ( -
+
- onToggle(user, perm, checked) + onToggle(user, perm.key, checked) } - className="scale-75" + className="mt-0.5 scale-75" />
); @@ -422,14 +514,12 @@ function TransferOwnershipDialog({ @@ -457,8 +547,12 @@ function TransferOwnershipDialog({ function AddUserDialog({ onSuccess, + templates, + templatePermissions, }: { onSuccess: () => void; + templates: PermissionTemplate[]; + templatePermissions: Record; }) { const [did, setDid] = useState(""); const [template, setTemplate] = useState(""); @@ -511,8 +605,8 @@ function AddUserDialog({ - {TEMPLATES.map((t) => ( - + {templates.map((t) => ( + {t.label} ))} @@ -521,7 +615,7 @@ function AddUserDialog({
{template && (

- Grants {TEMPLATE_PERMISSIONS[template]?.length ?? 0} permissions. + Grants {templatePermissions[template]?.length ?? 0} permissions.

)}
-- 2.51.2 From 66c792f87e20615abcfe50a487a246add3ff4e8a Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 12 May 2026 11:12:49 -0500 Subject: [PATCH 4/4] fix: use correct permission IDs in dashboard fixes #24 Signed-off-by: Trezy --- web/src/app/dashboard/settings/users/page.tsx | 172 +++++++++++++----- web/src/components/ui/sonner.tsx | 4 + 2 files changed, 132 insertions(+), 44 deletions(-) diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx index e41f8cd..6bb9f43 100644 --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronRight, Search, Shield, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { useAuth } from "@/lib/auth-context"; import { @@ -76,6 +77,8 @@ export default function UsersPage() { const [handles, setHandles] = useState>({}); const [error, setError] = useState(null); const [selectedUserId, setSelectedUserId] = useState(null); + const [pendingPermissions, setPendingPermissions] = useState([]); + const [saving, setSaving] = useState(false); const [permSearch, setPermSearch] = useState(""); const [permissionEntries, setPermissionEntries] = useState([]); const [profiles, setProfiles] = useState>({}); @@ -141,6 +144,13 @@ export default function UsersPage() { } }, [users, handles]); + // Initialize pending permissions when a user is selected + useEffect(() => { + if (!selectedUserId) return; + const user = users.find((u) => u.id === selectedUserId); + if (user) setPendingPermissions([...user.permissions]); + }, [selectedUserId, users]); + // Fetch Bluesky profile when a user is selected useEffect(() => { if (!selectedUserId) return; @@ -171,53 +181,59 @@ export default function UsersPage() { } } - async function handleTogglePermission( - user: UserSummary, + function handleTogglePermission( + _user: UserSummary, permission: string, enabled: boolean ) { - const grant: string[] = []; - const revoke: string[] = []; - - const [ns, action] = permission.split(":"); - - if (enabled) { - grant.push(permission); - // Adding a write permission also enables its read counterpart - if (action === "create" || action === "update" || action === "delete") { - const readPerm = `${ns}:read`; - if (!user.permissions.includes(readPerm)) { - grant.push(readPerm); - } - } - // Adding records:delete-collection also enables records:delete - if (permission === "records:delete-collection" && !user.permissions.includes("records:delete")) { - grant.push("records:delete"); - } - } else { - revoke.push(permission); - // Removing read also removes all write permissions in the same namespace - if (action === "read") { - for (const p of user.permissions) { - if (p.startsWith(`${ns}:`) && p !== permission) { - revoke.push(p); + setPendingPermissions((prev) => { + const perms = new Set(prev); + const [ns, action] = permission.split(":"); + + const nsReadPerm = allPermissionKeys.find( + (k) => k.startsWith(`${ns}:`) && (k.endsWith(":read") || k.endsWith(":view")) + ); + const isReadAction = action === "read" || action === "view"; + + if (enabled) { + perms.add(permission); + if (!isReadAction && nsReadPerm) perms.add(nsReadPerm); + if (permission === "records:delete-collection") perms.add("records:delete"); + } else { + perms.delete(permission); + if (isReadAction) { + for (const p of prev) { + if (p.startsWith(`${ns}:`) && p !== permission) perms.delete(p); } } + if (permission === "records:delete") perms.delete("records:delete-collection"); } - // Removing records:delete also removes records:delete-collection - if (permission === "records:delete" && user.permissions.includes("records:delete-collection")) { - revoke.push("records:delete-collection"); - } - } + return [...perms]; + }); + } + + async function handleSavePermissions(userId: string, originalPermissions: string[]) { + const originalSet = new Set(originalPermissions); + const pendingSet = new Set(pendingPermissions); + + const grant = pendingPermissions.filter((p) => !originalSet.has(p)); + const revoke = originalPermissions.filter((p) => !pendingSet.has(p)); + + if (grant.length === 0 && revoke.length === 0) return; + + setSaving(true); try { const body: { grant?: string[]; revoke?: string[] } = {}; if (grant.length > 0) body.grant = grant; if (revoke.length > 0) body.revoke = revoke; - await updateUserPermissions(user.id, body); + await updateUserPermissions(userId, body); + toast.success("Permissions updated"); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); } } @@ -307,8 +323,27 @@ export default function UsersPage() { {(() => { const selectedUser = users.find((u) => u.id === selectedUserId); return ( - { if (!open) { setSelectedUserId(null); setPermSearch(""); } }}> - + { + if (!open) { + const user = users.find((u) => u.id === selectedUserId); + if (user) { + const origSet = new Set(user.permissions); + const pendSet = new Set(pendingPermissions); + const unsaved = pendingPermissions.some((p) => !origSet.has(p)) || user.permissions.some((p) => !pendSet.has(p)); + if (unsaved) { + toast.warning("You have unsaved changes. Save or cancel before closing."); + return; + } + } + setSelectedUserId(null); + setPermSearch(""); + } + }}> + { + if (e.target instanceof HTMLElement && e.target.closest("[data-sonner-toaster]")) { + e.preventDefault(); + } + }}> {selectedUser && ( <> @@ -349,6 +384,14 @@ export default function UsersPage() { + {(() => { + const originalSet = new Set(selectedUser.permissions); + const pendingSet = new Set(pendingPermissions); + const added = pendingPermissions.filter((p) => !originalSet.has(p)).length; + const removed = selectedUser.permissions.filter((p) => !pendingSet.has(p)).length; + const hasChanges = added > 0 || removed > 0; + return ( + <>
Role @@ -363,7 +406,14 @@ export default function UsersPage() {

{selectedUser.is_super ? `${allPermissionKeys.length}/${allPermissionKeys.length}` - : `${selectedUser.permissions.filter((p) => allPermissionKeys.includes(p)).length}/${allPermissionKeys.length}`} + : `${pendingPermissions.filter((p) => allPermissionKeys.includes(p)).length}/${allPermissionKeys.length}`} + {hasChanges && ( + + {added > 0 && +{added}} + {added > 0 && removed > 0 && " "} + {removed > 0 && -{removed}} + + )}

@@ -399,12 +449,23 @@ export default function UsersPage() { currentUserPermissions={currentUser?.permissions ?? []} isCurrentUserSuper={isCurrentUserSuper} filteredCategories={filteredCategories} + pendingPermissions={pendingPermissions} + originalPermissions={selectedUser.permissions} onToggle={handleTogglePermission} />
-
+
+ {isCurrentUserSuper && ( handleTransferSuper(selectedUser.id)} /> )} +
+
+
+ + ); + })()} )} @@ -440,6 +512,8 @@ function PermissionsPanel({ currentUserPermissions, isCurrentUserSuper, filteredCategories, + pendingPermissions, + originalPermissions, onToggle, }: { user: UserSummary; @@ -447,9 +521,12 @@ function PermissionsPanel({ currentUserPermissions: string[]; isCurrentUserSuper: boolean; filteredCategories: Record; + pendingPermissions: string[]; + originalPermissions: string[]; onToggle: (user: UserSummary, permission: string, enabled: boolean) => void; }) { const canUpdate = isCurrentUserSuper || currentUserPermissions.includes("users:update"); + const originalSet = new Set(originalPermissions); return (
@@ -460,7 +537,10 @@ function PermissionsPanel({

{permissions.map((perm) => { - const enabled = user.is_super || user.permissions.includes(perm.key); + const enabled = user.is_super || pendingPermissions.includes(perm.key); + const wasEnabled = user.is_super || originalSet.has(perm.key); + const isAdded = enabled && !wasEnabled; + const isRemoved = !enabled && wasEnabled; return (
- {perm.name} + + {perm.name} + {isAdded && } + {isRemoved && } + {perm.description}
diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index 9b20afe..7a41a46 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -17,6 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => { , info: , @@ -30,6 +31,9 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", + "--toast-close-button-start": "unset", + "--toast-close-button-end": "0", + "--toast-close-button-transform": "translate(35%, -35%)", } as React.CSSProperties } {...props} -- 2.51.2