diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9d947d0..239a28b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -50,7 +50,9 @@ NX9-Auth is designed with a **security-first, privacy-first, zero-trust** archit Audit logs record critical identity lifecycle events while strictly redacting sensitive fields: - **Recorded Events**: Login success/failure, logout, password change, user creation/deletion, tenant reassignment, API token issuance/revocation, application creation/secret rotation/membership modification, role/permission assignments. - **Redaction Rules**: Plaintext passwords, password hashes, bearer tokens, refresh tokens, client secrets, client secret hashes, session secrets, and `Authorization` headers are **never** logged under any circumstances. -- **Bounded CSV Export**: Audit log CSV export uses server-side audit search APIs bounded to a maximum of 5,000 records matching currently active query filters, preserving exact tenant/RBAC restrictions and RFC-4180 field escaping. +- **Success/Failure Filters**: Server-side derived success/failure filtering is based on audit action and severity semantics; success is not persisted as a database column. +- **Exact Resource Activity**: Resource activity filters use exact `resource_type` and `resource_id` predicates; generic text search remains separate. +- **Bounded CSV Export**: Audit log CSV export uses server-side audit search APIs bounded to a maximum of 5,000 records matching active filters and preserves the same `audit:view` authorization as the normal audit endpoint, with RFC-4180 field escaping. ## Rate Limiting & Protection diff --git a/src/api/audit.rs b/src/api/audit.rs index c97c182..44df081 100644 --- a/src/api/audit.rs +++ b/src/api/audit.rs @@ -56,6 +56,7 @@ pub struct AuditQuery { pub actor: Option, pub action: Option, pub resource_type: Option, + pub resource_id: Option, pub severity: Option, pub since: Option, pub until: Option, @@ -80,6 +81,7 @@ pub async fn list_audit( actor_user_id: query.actor, action: query.action, resource_type: query.resource_type, + resource_id: query.resource_id, severity: query.severity, since: query.since, until: query.until, @@ -124,6 +126,7 @@ pub async fn export_audit( actor_user_id: query.actor, action: query.action, resource_type: query.resource_type, + resource_id: query.resource_id, severity: query.severity, since: query.since, until: query.until, diff --git a/src/api/ui.rs b/src/api/ui.rs index 62eceab..031c9c8 100644 --- a/src/api/ui.rs +++ b/src/api/ui.rs @@ -52,16 +52,6 @@ fn is_static_asset(path: &str) -> bool { /// Serve a static file from the UI dist dir, or SPA fallback for app routes. pub async fn serve_ui(uri: Uri) -> Response { - let dist = ui_dist_dir(); - if !dist.exists() { - return missing_ui_page().into_response(); - } - - let path = uri.path().trim_start_matches('/'); - if path.starts_with("api/") || path == "health" || path == "version" { - return StatusCode::NOT_FOUND.into_response(); - } - // Security Hardening: Reject & sanitize any GET request containing credentials in query string. if let Some(query) = uri.query() { let q_lower = query.to_ascii_lowercase(); @@ -84,6 +74,16 @@ pub async fn serve_ui(uri: Uri) -> Response { } } + let dist = ui_dist_dir(); + if !dist.exists() { + return missing_ui_page().into_response(); + } + + let path = uri.path().trim_start_matches('/'); + if path.starts_with("api/") || path == "health" || path == "version" { + return StatusCode::NOT_FOUND.into_response(); + } + // Normalize and reject path traversal if path.contains("..") { return StatusCode::BAD_REQUEST.into_response(); diff --git a/src/db/models/audit_log.rs b/src/db/models/audit_log.rs index 94a3ee7..3491c3b 100644 --- a/src/db/models/audit_log.rs +++ b/src/db/models/audit_log.rs @@ -44,6 +44,7 @@ pub struct AuditFilter { pub actor_user_id: Option, pub action: Option, pub resource_type: Option, + pub resource_id: Option, pub severity: Option, pub since: Option, pub until: Option, diff --git a/src/db/repository/postgres/audit.rs b/src/db/repository/postgres/audit.rs index 9a5adf7..7d39da0 100644 --- a/src/db/repository/postgres/audit.rs +++ b/src/db/repository/postgres/audit.rs @@ -1,9 +1,8 @@ +use crate::db::models::{AuditFilter, AuditLog}; use crate::db::repository::traits::AuditRepository; use async_trait::async_trait; use sqlx::PgPool; -use crate::db::models::{AuditFilter, AuditLog}; - pub struct PostgresAuditRepository { pub pool: PgPool, } @@ -31,29 +30,13 @@ impl AuditRepository for PostgresAuditRepository { user_agent: Option<&str>, metadata_json: Option<&str>, ) -> Result { - sqlx::query_as::<_, AuditLog>( - r#" - INSERT INTO audit_logs ( - id, actor_user_id, target_user_id, - action, resource_type, resource_id, - severity, ip_address, user_agent, metadata_json - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING * - "#, - ) - .bind(id) - .bind(actor_user_id) - .bind(target_user_id) - .bind(action) - .bind(resource_type) - .bind(resource_id) - .bind(severity) - .bind(ip_address) - .bind(user_agent) - .bind(metadata_json) - .fetch_one(&self.pool) - .await + sqlx::query_as::<_, AuditLog>(r#" + INSERT INTO audit_logs (id, actor_user_id, target_user_id, action, resource_type, resource_id, severity, ip_address, user_agent, metadata_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * + "#) + .bind(id).bind(actor_user_id).bind(target_user_id).bind(action).bind(resource_type) + .bind(resource_id).bind(severity).bind(ip_address).bind(user_agent).bind(metadata_json) + .fetch_one(&self.pool).await } async fn list_recent(&self, limit: i64) -> Result, sqlx::Error> { @@ -64,91 +47,71 @@ impl AuditRepository for PostgresAuditRepository { } async fn list_filtered(&self, filter: &AuditFilter) -> Result, sqlx::Error> { - let search_like = filter - .search - .as_ref() - .map(|s| format!("%{}%", s.replace('%', "\\%"))); - - sqlx::query_as::<_, AuditLog>( - r#" - SELECT * FROM audit_logs - WHERE ($1::text IS NULL OR actor_user_id = $1) - AND ($2::text IS NULL OR action = $2) - AND ($3::text IS NULL OR resource_type = $3) - AND ($4::text IS NULL OR severity = $4) - AND ($5::text IS NULL OR created_at >= $5) - AND ($6::text IS NULL OR created_at <= $6) - AND ( - $7::text IS NULL - OR action LIKE $7 ESCAPE '\' - OR resource_type LIKE $7 ESCAPE '\' - OR resource_id LIKE $7 ESCAPE '\' - OR ip_address LIKE $7 ESCAPE '\' - OR metadata_json LIKE $7 ESCAPE '\' - ) - AND ( - $8::boolean IS NULL - OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') - OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) - ) - ORDER BY created_at DESC - LIMIT $9 OFFSET $10 - "#, - ) - .bind(filter.actor_user_id.as_deref()) - .bind(filter.action.as_deref()) - .bind(filter.resource_type.as_deref()) - .bind(filter.severity.as_deref()) - .bind(filter.since.as_deref()) - .bind(filter.until.as_deref()) - .bind(search_like.as_deref()) - .bind(filter.success) - .bind(filter.limit) - .bind(filter.offset) - .fetch_all(&self.pool) - .await + let search_like = search_like(filter); + sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL) + .bind(filter.actor_user_id.as_deref()) + .bind(filter.action.as_deref()) + .bind(filter.resource_type.as_deref()) + .bind(filter.resource_id.as_deref()) + .bind(filter.severity.as_deref()) + .bind(filter.since.as_deref()) + .bind(filter.until.as_deref()) + .bind(search_like.as_deref()) + .bind(filter.success) + .bind(filter.limit) + .bind(filter.offset) + .fetch_all(&self.pool) + .await } async fn count_filtered(&self, filter: &AuditFilter) -> Result { - let search_like = filter - .search - .as_ref() - .map(|s| format!("%{}%", s.replace('%', "\\%"))); - - let row: (i64,) = sqlx::query_as( - r#" - SELECT COUNT(*) FROM audit_logs - WHERE ($1::text IS NULL OR actor_user_id = $1) - AND ($2::text IS NULL OR action = $2) - AND ($3::text IS NULL OR resource_type = $3) - AND ($4::text IS NULL OR severity = $4) - AND ($5::text IS NULL OR created_at >= $5) - AND ($6::text IS NULL OR created_at <= $6) - AND ( - $7::text IS NULL - OR action LIKE $7 ESCAPE '\' - OR resource_type LIKE $7 ESCAPE '\' - OR resource_id LIKE $7 ESCAPE '\' - OR ip_address LIKE $7 ESCAPE '\' - OR metadata_json LIKE $7 ESCAPE '\' - ) - AND ( - $8::boolean IS NULL - OR ($8::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') - OR ($8::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) - ) - "#, - ) - .bind(filter.actor_user_id.as_deref()) - .bind(filter.action.as_deref()) - .bind(filter.resource_type.as_deref()) - .bind(filter.severity.as_deref()) - .bind(filter.since.as_deref()) - .bind(filter.until.as_deref()) - .bind(search_like.as_deref()) - .bind(filter.success) - .fetch_one(&self.pool) - .await?; + let search_like = search_like(filter); + let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL) + .bind(filter.actor_user_id.as_deref()) + .bind(filter.action.as_deref()) + .bind(filter.resource_type.as_deref()) + .bind(filter.resource_id.as_deref()) + .bind(filter.severity.as_deref()) + .bind(filter.since.as_deref()) + .bind(filter.until.as_deref()) + .bind(search_like.as_deref()) + .bind(filter.success) + .fetch_one(&self.pool) + .await?; Ok(row.0) } } + +fn search_like(filter: &AuditFilter) -> Option { + filter + .search + .as_ref() + .map(|s| format!("%{}%", s.replace('%', "\\%"))) +} + +const FILTER_LIST_SQL: &str = r#" + SELECT * FROM audit_logs + WHERE ($1::text IS NULL OR actor_user_id = $1) + AND ($2::text IS NULL OR action = $2) + AND ($3::text IS NULL OR resource_type = $3) + AND ($4::text IS NULL OR resource_id = $4) + AND ($5::text IS NULL OR severity = $5) + AND ($6::text IS NULL OR created_at >= $6) + AND ($7::text IS NULL OR created_at <= $7) + AND ($8::text IS NULL OR action LIKE $8 ESCAPE '\' OR resource_type LIKE $8 ESCAPE '\' OR resource_id LIKE $8 ESCAPE '\' OR ip_address LIKE $8 ESCAPE '\' OR metadata_json LIKE $8 ESCAPE '\') + AND ($9::boolean IS NULL OR ($9::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR ($9::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))) + ORDER BY created_at DESC LIMIT $10 OFFSET $11 +"#; + +const FILTER_COUNT_SQL: &str = r#" + SELECT COUNT(*) FROM audit_logs + WHERE ($1::text IS NULL OR actor_user_id = $1) + AND ($2::text IS NULL OR action = $2) + AND ($3::text IS NULL OR resource_type = $3) + AND ($4::text IS NULL OR resource_id = $4) + AND ($5::text IS NULL OR severity = $5) + AND ($6::text IS NULL OR created_at >= $6) + AND ($7::text IS NULL OR created_at <= $7) + AND ($8::text IS NULL OR action LIKE $8 ESCAPE '\' OR resource_type LIKE $8 ESCAPE '\' OR resource_id LIKE $8 ESCAPE '\' OR ip_address LIKE $8 ESCAPE '\' OR metadata_json LIKE $8 ESCAPE '\') + AND ($9::boolean IS NULL OR ($9::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR ($9::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))) +"#; diff --git a/src/db/repository/postgres/users.rs b/src/db/repository/postgres/users.rs index 9ffb26b..657035b 100644 --- a/src/db/repository/postgres/users.rs +++ b/src/db/repository/postgres/users.rs @@ -98,17 +98,6 @@ impl UsersRepository for PostgresUsersRepository { Ok(()) } - async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> { - sqlx::query( - "UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2", - ) - .bind(tenant_id) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - async fn reassign_user_tenant_with_audit( &self, user_id: &str, diff --git a/src/db/repository/sqlite/audit.rs b/src/db/repository/sqlite/audit.rs index 19ae050..a596efa 100644 --- a/src/db/repository/sqlite/audit.rs +++ b/src/db/repository/sqlite/audit.rs @@ -1,23 +1,21 @@ +use crate::db::models::{AuditFilter, AuditLog}; use crate::db::repository::traits::AuditRepository; use async_trait::async_trait; use sqlx::SqlitePool; -use crate::db::models::{AuditFilter, AuditLog}; - pub struct SqliteAuditRepository { pub pool: SqlitePool, } #[async_trait] impl AuditRepository for SqliteAuditRepository { - /// Count all audit log entries. async fn count(&self) -> Result { let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs") .fetch_one(&self.pool) .await?; Ok(row.0) } - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] async fn insert( &self, @@ -32,29 +30,13 @@ impl AuditRepository for SqliteAuditRepository { user_agent: Option<&str>, metadata_json: Option<&str>, ) -> Result { - sqlx::query_as::<_, AuditLog>( - r#" - INSERT INTO audit_logs ( - id, actor_user_id, target_user_id, - action, resource_type, resource_id, - severity, ip_address, user_agent, metadata_json - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING * - "#, - ) - .bind(id) - .bind(actor_user_id) - .bind(target_user_id) - .bind(action) - .bind(resource_type) - .bind(resource_id) - .bind(severity) - .bind(ip_address) - .bind(user_agent) - .bind(metadata_json) - .fetch_one(&self.pool) - .await + sqlx::query_as::<_, AuditLog>(r#" + INSERT INTO audit_logs (id, actor_user_id, target_user_id, action, resource_type, resource_id, severity, ip_address, user_agent, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING * + "#) + .bind(id).bind(actor_user_id).bind(target_user_id).bind(action).bind(resource_type) + .bind(resource_id).bind(severity).bind(ip_address).bind(user_agent).bind(metadata_json) + .fetch_one(&self.pool).await } async fn list_recent(&self, limit: i64) -> Result, sqlx::Error> { @@ -65,41 +47,46 @@ impl AuditRepository for SqliteAuditRepository { } async fn list_filtered(&self, filter: &AuditFilter) -> Result, sqlx::Error> { - let search_like = filter - .search - .as_ref() - .map(|s| format!("%{}%", s.replace('%', "\\%"))); - let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 }); + list_filtered(&self.pool, filter).await + } - sqlx::query_as::<_, AuditLog>( - r#" - SELECT * FROM audit_logs - WHERE (?1 IS NULL OR actor_user_id = ?1) - AND (?2 IS NULL OR action = ?2) - AND (?3 IS NULL OR resource_type = ?3) - AND (?4 IS NULL OR severity = ?4) - AND (?5 IS NULL OR created_at >= ?5) - AND (?6 IS NULL OR created_at <= ?6) - AND ( - ?7 IS NULL - OR action LIKE ?7 ESCAPE '\' - OR resource_type LIKE ?7 ESCAPE '\' - OR resource_id LIKE ?7 ESCAPE '\' - OR ip_address LIKE ?7 ESCAPE '\' - OR metadata_json LIKE ?7 ESCAPE '\' - ) - AND ( - ?8 IS NULL - OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') - OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) - ) - ORDER BY created_at DESC - LIMIT ?9 OFFSET ?10 - "#, - ) + async fn count_filtered(&self, filter: &AuditFilter) -> Result { + let search_like = search_like(filter); + let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 }); + let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL) + .bind(filter.actor_user_id.as_deref()) + .bind(filter.action.as_deref()) + .bind(filter.resource_type.as_deref()) + .bind(filter.resource_id.as_deref()) + .bind(filter.severity.as_deref()) + .bind(filter.since.as_deref()) + .bind(filter.until.as_deref()) + .bind(search_like.as_deref()) + .bind(success_val) + .fetch_one(&self.pool) + .await?; + Ok(row.0) + } +} + +fn search_like(filter: &AuditFilter) -> Option { + filter + .search + .as_ref() + .map(|s| format!("%{}%", s.replace('%', "\\%"))) +} + +async fn list_filtered( + pool: &SqlitePool, + filter: &AuditFilter, +) -> Result, sqlx::Error> { + let search_like = search_like(filter); + let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 }); + sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL) .bind(filter.actor_user_id.as_deref()) .bind(filter.action.as_deref()) .bind(filter.resource_type.as_deref()) + .bind(filter.resource_id.as_deref()) .bind(filter.severity.as_deref()) .bind(filter.since.as_deref()) .bind(filter.until.as_deref()) @@ -107,51 +94,33 @@ impl AuditRepository for SqliteAuditRepository { .bind(success_val) .bind(filter.limit) .bind(filter.offset) - .fetch_all(&self.pool) + .fetch_all(pool) .await - } - - async fn count_filtered(&self, filter: &AuditFilter) -> Result { - let search_like = filter - .search - .as_ref() - .map(|s| format!("%{}%", s.replace('%', "\\%"))); - let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 }); - - let row: (i64,) = sqlx::query_as( - r#" - SELECT COUNT(*) FROM audit_logs - WHERE (?1 IS NULL OR actor_user_id = ?1) - AND (?2 IS NULL OR action = ?2) - AND (?3 IS NULL OR resource_type = ?3) - AND (?4 IS NULL OR severity = ?4) - AND (?5 IS NULL OR created_at >= ?5) - AND (?6 IS NULL OR created_at <= ?6) - AND ( - ?7 IS NULL - OR action LIKE ?7 ESCAPE '\' - OR resource_type LIKE ?7 ESCAPE '\' - OR resource_id LIKE ?7 ESCAPE '\' - OR ip_address LIKE ?7 ESCAPE '\' - OR metadata_json LIKE ?7 ESCAPE '\' - ) - AND ( - ?8 IS NULL - OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') - OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) - ) - "#, - ) - .bind(filter.actor_user_id.as_deref()) - .bind(filter.action.as_deref()) - .bind(filter.resource_type.as_deref()) - .bind(filter.severity.as_deref()) - .bind(filter.since.as_deref()) - .bind(filter.until.as_deref()) - .bind(search_like.as_deref()) - .bind(success_val) - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } } + +const FILTER_LIST_SQL: &str = r#" + SELECT * FROM audit_logs + WHERE (?1 IS NULL OR actor_user_id = ?1) + AND (?2 IS NULL OR action = ?2) + AND (?3 IS NULL OR resource_type = ?3) + AND (?4 IS NULL OR resource_id = ?4) + AND (?5 IS NULL OR severity = ?5) + AND (?6 IS NULL OR created_at >= ?6) + AND (?7 IS NULL OR created_at <= ?7) + AND (?8 IS NULL OR action LIKE ?8 ESCAPE '\' OR resource_type LIKE ?8 ESCAPE '\' OR resource_id LIKE ?8 ESCAPE '\' OR ip_address LIKE ?8 ESCAPE '\' OR metadata_json LIKE ?8 ESCAPE '\') + AND (?9 IS NULL OR (?9 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR (?9 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))) + ORDER BY created_at DESC LIMIT ?10 OFFSET ?11 +"#; + +const FILTER_COUNT_SQL: &str = r#" + SELECT COUNT(*) FROM audit_logs + WHERE (?1 IS NULL OR actor_user_id = ?1) + AND (?2 IS NULL OR action = ?2) + AND (?3 IS NULL OR resource_type = ?3) + AND (?4 IS NULL OR resource_id = ?4) + AND (?5 IS NULL OR severity = ?5) + AND (?6 IS NULL OR created_at >= ?6) + AND (?7 IS NULL OR created_at <= ?7) + AND (?8 IS NULL OR action LIKE ?8 ESCAPE '\' OR resource_type LIKE ?8 ESCAPE '\' OR resource_id LIKE ?8 ESCAPE '\' OR ip_address LIKE ?8 ESCAPE '\' OR metadata_json LIKE ?8 ESCAPE '\') + AND (?9 IS NULL OR (?9 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR (?9 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical'))) +"#; diff --git a/src/db/repository/sqlite/users.rs b/src/db/repository/sqlite/users.rs index 5b6c4a3..b6cc63a 100644 --- a/src/db/repository/sqlite/users.rs +++ b/src/db/repository/sqlite/users.rs @@ -100,17 +100,6 @@ impl UsersRepository for SqliteUsersRepository { Ok(()) } - async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error> { - sqlx::query( - "UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", - ) - .bind(tenant_id) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - async fn reassign_user_tenant_with_audit( &self, user_id: &str, diff --git a/src/db/repository/traits.rs b/src/db/repository/traits.rs index 88a6708..e2af061 100644 --- a/src/db/repository/traits.rs +++ b/src/db/repository/traits.rs @@ -21,7 +21,6 @@ pub trait UsersRepository: Send + Sync { password_hash: &str, ) -> Result; async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>; - async fn update_user_tenant(&self, id: &str, tenant_id: &str) -> Result<(), sqlx::Error>; async fn reassign_user_tenant_with_audit( &self, user_id: &str, diff --git a/tests/audit_export_test.rs b/tests/audit_export_test.rs index 81dc3f1..09dd53c 100644 --- a/tests/audit_export_test.rs +++ b/tests/audit_export_test.rs @@ -224,3 +224,55 @@ fn test_rfc4180_csv_escaping_rules() { assert_eq!(esc("with \"quotes\""), "\"with \"\"quotes\"\"\""); assert_eq!(esc("multi\nline"), "\"multi\nline\""); } + +#[tokio::test] +async fn test_exact_resource_id_filter_excludes_generic_text_matches() { + let (provider, db_path) = setup_test_provider().await; + let tenant_id = "tenant-exact"; + + provider + .audit() + .insert( + &uuid::Uuid::new_v4().to_string(), + None, + None, + "tenant.updated", + "tenant", + Some(tenant_id), + "info", + None, + None, + Some("{}"), + ) + .await + .unwrap(); + provider + .audit() + .insert( + &uuid::Uuid::new_v4().to_string(), + None, + None, + "tenant.updated", + "tenant", + Some("other-tenant"), + "info", + None, + None, + Some(&format!("{{\"mentioned\":\"{tenant_id}\"}}")), + ) + .await + .unwrap(); + + let filter = nx9_auth::db::models::AuditFilter { + resource_type: Some("tenant".into()), + resource_id: Some(tenant_id.into()), + limit: 50, + ..Default::default() + }; + assert_eq!(provider.audit().count_filtered(&filter).await.unwrap(), 1); + let entries = provider.audit().list_filtered(&filter).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].resource_id.as_deref(), Some(tenant_id)); + + teardown_test_db(db_path).await; +} diff --git a/tests/tenant_management_ui_test.rs b/tests/tenant_management_ui_test.rs index 7e2c9df..7247283 100644 --- a/tests/tenant_management_ui_test.rs +++ b/tests/tenant_management_ui_test.rs @@ -57,7 +57,7 @@ async fn test_tenant_user_listing_and_assignment() { // Reassign user to Acme Org provider .users() - .update_user_tenant(&user.id, &tenant_id) + .reassign_user_tenant_with_audit(&user.id, &tenant_id, None, None, None) .await .expect("Tenant assignment should succeed"); @@ -251,7 +251,7 @@ async fn test_session_identity_immediately_reflects_tenant_reassignment() { // 4. Reassign user to Tenant B provider .users() - .update_user_tenant(&user.id, &tenant_b) + .reassign_user_tenant_with_audit(&user.id, &tenant_b, None, None, None) .await .unwrap(); diff --git a/ui/src/app.rs b/ui/src/app.rs index 4cd20c2..8343653 100644 --- a/ui/src/app.rs +++ b/ui/src/app.rs @@ -15,7 +15,7 @@ pub fn App() -> Element { if !matches!(auth(), BootstrapState::Initializing) { return; } - + match api::me().await { Ok(Some(me)) => { auth.set(BootstrapState::Authenticated(me)); diff --git a/ui/src/components/feedback/mod.rs b/ui/src/components/feedback/mod.rs index 2a6930a..fade74c 100644 --- a/ui/src/components/feedback/mod.rs +++ b/ui/src/components/feedback/mod.rs @@ -71,8 +71,7 @@ pub fn Modal( title: String, open: bool, on_close: EventHandler<()>, - #[props(default)] - large: bool, + #[props(default)] large: bool, children: Element, ) -> Element { if !open { @@ -112,10 +111,8 @@ pub fn ConfirmDialog( title: String, message: String, open: bool, - #[props(default = "Confirm".to_string())] - confirm_label: String, - #[props(default)] - danger: bool, + #[props(default = "Confirm".to_string())] confirm_label: String, + #[props(default)] danger: bool, on_confirm: EventHandler<()>, on_cancel: EventHandler<()>, ) -> Element { diff --git a/ui/src/components/navigation/mod.rs b/ui/src/components/navigation/mod.rs index aabd79a..6715e1c 100644 --- a/ui/src/components/navigation/mod.rs +++ b/ui/src/components/navigation/mod.rs @@ -22,10 +22,14 @@ pub fn Header() -> Element { let auth_state = state.auth.read(); let can_create_user = auth_state.has_permission("users:create") || auth_state.is_adminish(); let can_create_tenant = auth_state.has_permission("roles:manage") || auth_state.is_adminish(); - let can_create_app = auth_state.has_permission("applications:manage") || auth_state.is_adminish(); + let can_create_app = + auth_state.has_permission("applications:manage") || auth_state.is_adminish(); let can_create_role = auth_state.has_permission("roles:manage") || auth_state.is_adminish(); - let can_create_sa = auth_state.has_permission("service_accounts:manage") || auth_state.has_permission("roles:manage") || auth_state.is_adminish(); - let has_any_create = can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa; + let can_create_sa = auth_state.has_permission("service_accounts:manage") + || auth_state.has_permission("roles:manage") + || auth_state.is_adminish(); + let has_any_create = + can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa; drop(auth_state); rsx! { @@ -201,8 +205,6 @@ pub fn Header() -> Element { } } - - #[component] pub fn Sidebar() -> Element { let state = use_context::(); @@ -218,8 +220,7 @@ pub fn Sidebar() -> Element { }; let active = |r: &Route| -> bool { - format!("{path:?}").split_whitespace().next() - == format!("{r:?}").split_whitespace().next() + format!("{path:?}").split_whitespace().next() == format!("{r:?}").split_whitespace().next() }; rsx! { diff --git a/ui/src/components/navigation/registry.rs b/ui/src/components/navigation/registry.rs index 30717e7..fc49cc7 100644 --- a/ui/src/components/navigation/registry.rs +++ b/ui/src/components/navigation/registry.rs @@ -1,6 +1,5 @@ use crate::routes::Route; - #[derive(Clone, Debug, PartialEq)] pub struct NavigationItem { pub id: String, diff --git a/ui/src/components/tables/datatable.rs b/ui/src/components/tables/datatable.rs index f3d8fa6..1fc0ade 100644 --- a/ui/src/components/tables/datatable.rs +++ b/ui/src/components/tables/datatable.rs @@ -14,10 +14,10 @@ pub fn DataTable( on_search: EventHandler, search_value: String, search_placeholder: String, - + on_sort: EventHandler, sort_key: String, - + on_page: EventHandler, page: usize, page_size: usize, @@ -25,7 +25,7 @@ pub fn DataTable( // Row Actions or other toolbar slots #[props(default)] toolbar_actions: Option, - + // The actual table body and header will be rendered internally // We expect the caller to just give us the table rows children: Element, @@ -40,11 +40,11 @@ pub fn DataTable( oninput: move |v| on_search.call(v), placeholder: "{search_placeholder}", } - + div { class: "spacer" } - + {toolbar_actions} - + // Column Visibility div { class: "dropdown", button { @@ -68,7 +68,7 @@ pub fn DataTable( } } } - + div { class: "table-wrap", table { class: "data-table", thead { @@ -98,7 +98,7 @@ pub fn DataTable( } } } - + crate::components::tables::Pagination { page: page, page_size: page_size, diff --git a/ui/src/components/tables/mod.rs b/ui/src/components/tables/mod.rs index 1fa25e3..f0ccea8 100644 --- a/ui/src/components/tables/mod.rs +++ b/ui/src/components/tables/mod.rs @@ -1,6 +1,6 @@ //! Table helpers: search toolbar + pagination. pub mod datatable; -pub use datatable::{DataTable, ColumnDef}; +pub use datatable::{ColumnDef, DataTable}; use dioxus::prelude::*; diff --git a/ui/src/components/widgets/mod.rs b/ui/src/components/widgets/mod.rs index aefc6f5..a3555ad 100644 --- a/ui/src/components/widgets/mod.rs +++ b/ui/src/components/widgets/mod.rs @@ -65,11 +65,7 @@ pub fn Card( } #[component] -pub fn StatCard( - label: String, - value: String, - #[props(default)] hint: String, -) -> Element { +pub fn StatCard(label: String, value: String, #[props(default)] hint: String) -> Element { rsx! { div { class: "stat-card", div { class: "label", "{label}" } diff --git a/ui/src/main.rs b/ui/src/main.rs index 76d9a45..da36784 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -13,8 +13,8 @@ mod utils; fn main() { // Surface panics in the browser console instead of a silent blank page. console_error_panic_hook::set_once(); - - // Clear any pre-rendered loading banner in index.html (boot.js) + + // Clear any pre-rendered loading banner in index.html (boot.js) // before Dioxus takes over `#main` and appends its root elements. if let Some(window) = web_sys::window() { if let Some(doc) = window.document() { diff --git a/ui/src/pages/applications/mod.rs b/ui/src/pages/applications/mod.rs index 3327f7a..ed9c437 100644 --- a/ui/src/pages/applications/mod.rs +++ b/ui/src/pages/applications/mod.rs @@ -472,14 +472,9 @@ pub fn ApplicationDetailPage(id: String) -> Element { } let id = app_id_activity.clone(); spawn(async move { - let q = format!("resource_type=application&q={id}&limit=50"); + let q = format!("resource_type=application&resource_id={id}&limit=50"); if let Ok(resp) = api::list_audit(&q).await { - let filtered: Vec<_> = resp - .entries - .into_iter() - .filter(|e| e.resource_id.as_deref() == Some(id.as_str())) - .collect(); - activity.set(filtered); + activity.set(resp.entries); } }); }); diff --git a/ui/src/pages/audit/mod.rs b/ui/src/pages/audit/mod.rs index d08ca4f..2c6d829 100644 --- a/ui/src/pages/audit/mod.rs +++ b/ui/src/pages/audit/mod.rs @@ -69,13 +69,17 @@ pub fn AuditPage() -> Element { }); }); - use_effect(move || { load.call(()); }); + use_effect(move || { + load.call(()); + }); rsx! { - Breadcrumb { items: vec![ - ("Dashboard".to_string(), Some(Route::DashboardPage {})), - ("Audit Log".to_string(), None), - ]} + Breadcrumb { + items: vec![ + ("Dashboard".to_string(), Some(Route::DashboardPage {})), + ("Audit Log".to_string(), None), + ], + } div { class: "page-header", div { @@ -88,36 +92,77 @@ pub fn AuditPage() -> Element { r#type: "button", title: "Export filtered audit log records as CSV", onclick: move |_| { - let mut parts = vec!["limit=5000".to_string(), "offset=0".to_string()]; - if !query().is_empty() { parts.push(format!("q={}", urlencoding_lite(&query()))); } - if !action().is_empty() { parts.push(format!("action={}", urlencoding_lite(&action()))); } - if !resource().is_empty() { parts.push(format!("resource_type={}", urlencoding_lite(&resource()))); } - if severity() != "all" { parts.push(format!("severity={}", severity())); } - if success() == "true" { parts.push("success=true".to_string()); } - else if success() == "false" { parts.push("success=false".to_string()); } - if !since().is_empty() { parts.push(format!("since={}", urlencoding_lite(&since()))); } - if !until().is_empty() { parts.push(format!("until={}", urlencoding_lite(&until()))); } + let mut parts = vec![ + "limit=5000".to_string(), + "offset=0".to_string(), + ]; + + if !query().is_empty() { + parts.push(format!("q={}", urlencoding_lite(&query()))); + } + if !action().is_empty() { + parts.push(format!("action={}", urlencoding_lite(&action()))); + } + if !resource().is_empty() { + parts.push(format!( + "resource_type={}", + urlencoding_lite(&resource()) + )); + } + if severity() != "all" { + parts.push(format!("severity={}", severity())); + } + if success() == "true" { + parts.push("success=true".to_string()); + } else if success() == "false" { + parts.push("success=false".to_string()); + } + if !since().is_empty() { + parts.push(format!("since={}", urlencoding_lite(&since()))); + } + if !until().is_empty() { + parts.push(format!("until={}", urlencoding_lite(&until()))); + } + let qs = parts.join("&"); let export_url = format!("/api/v1/audit/export?{qs}"); + #[cfg(target_arch = "wasm32")] { use wasm_bindgen::JsCast; + if let Some(window) = web_sys::window() { if let Some(document) = window.document() { if let Ok(element) = document.create_element("a") { let _ = element.set_attribute("href", &export_url); - let _ = element.set_attribute("download", "audit_export.csv"); - if let Ok(html_elem) = element.dyn_into::() { - html_elem.click(); + let _ = element.set_attribute( + "download", + "audit_export.csv", + ); + + if let Ok(html_element) = + element.dyn_into::() + { + html_element.click(); } } } } } + + #[cfg(not(target_arch = "wasm32"))] + { + let _ = export_url; + } }, "Export CSV" } - button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" } + button { + class: "btn btn-outline", + r#type: "button", + onclick: move |_| load.call(()), + "Refresh" + } } } @@ -130,19 +175,22 @@ pub fn AuditPage() -> Element { placeholder: "Search action, resource, IP…", } input { - class: "form-control", style: "width:auto;max-width:140px;", + class: "form-control", + style: "width:auto;max-width:140px;", placeholder: "Action", value: "{action()}", oninput: move |e| action.set(e.value()), } input { - class: "form-control", style: "width:auto;max-width:140px;", + class: "form-control", + style: "width:auto;max-width:140px;", placeholder: "Resource", value: "{resource()}", oninput: move |e| resource.set(e.value()), } select { - class: "form-control", style: "width:auto;", + class: "form-control", + style: "width:auto;", value: "{severity()}", onchange: move |e| severity.set(e.value()), option { value: "all", "All severities" } @@ -151,7 +199,8 @@ pub fn AuditPage() -> Element { option { value: "critical", "Critical" } } select { - class: "form-control", style: "width:auto;", + class: "form-control", + style: "width:auto;", value: "{success()}", onchange: move |e| success.set(e.value()), option { value: "all", "Success/Fail" } @@ -159,22 +208,28 @@ pub fn AuditPage() -> Element { option { value: "false", "Failure" } } input { - class: "form-control", style: "width:auto;", + class: "form-control", + style: "width:auto;", r#type: "date", value: "{since()}", oninput: move |e| since.set(e.value()), title: "Since", } input { - class: "form-control", style: "width:auto;", + class: "form-control", + style: "width:auto;", r#type: "date", value: "{until()}", oninput: move |e| until.set(e.value()), title: "Until", } button { - class: "btn btn-primary", r#type: "button", - onclick: move |_| { page.set(0); load.call(()); }, + class: "btn btn-primary", + r#type: "button", + onclick: move |_| { + page.set(0); + load.call(()); + }, "Apply" } } @@ -210,17 +265,23 @@ pub fn AuditPage() -> Element { for e in d.entries { tr { key: "{e.id}", td { class: "mono", "{format_datetime(&e.created_at)}" } - td { code { "{e.action}" } } + td { + code { "{e.action}" } + } td { span { "{e.resource_type}" } if let Some(rid) = &e.resource_id { - div { class: "mono text-muted", style: "font-size:11px;", + div { + class: "mono text-muted", + style: "font-size:11px;", "{rid}" } } } td { - span { class: "{severity_badge_class(&e.severity)}", "{e.severity}" } + span { class: "{severity_badge_class(&e.severity)}", + "{e.severity}" + } } td { if e.success { @@ -232,9 +293,7 @@ pub fn AuditPage() -> Element { td { class: "mono", "{e.actor_user_id.as_deref().unwrap_or(\"—\")}" } - td { class: "mono", - "{e.ip_address.as_deref().unwrap_or(\"—\")}" - } + td { class: "mono", "{e.ip_address.as_deref().unwrap_or(\"—\")}" } } } } @@ -242,9 +301,12 @@ pub fn AuditPage() -> Element { } Pagination { page: page(), - page_size: page_size, + page_size, total: d.total as usize, - on_page: move |p| { page.set(p); load.call(()); }, + on_page: move |p| { + page.set(p); + load.call(()); + }, } } } @@ -259,44 +321,3 @@ fn urlencoding_lite(s: &str) -> String { }) .collect() } - -fn export_audit_csv(entries: &[crate::models::AuditEntry]) { - let mut csv = String::from("id,created_at,action,resource_type,resource_id,severity,success,actor_user_id,target_user_id,ip_address,user_agent,metadata_json\n"); - for e in entries { - let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\"")); - let line = format!( - "{},{},{},{},{},{},{},{},{},{},{},{}\n", - esc(&e.id), - esc(&e.created_at), - esc(&e.action), - esc(&e.resource_type), - esc(e.resource_id.as_deref().unwrap_or("")), - esc(&e.severity), - e.success, - esc(e.actor_user_id.as_deref().unwrap_or("")), - esc(e.target_user_id.as_deref().unwrap_or("")), - esc(e.ip_address.as_deref().unwrap_or("")), - esc(e.user_agent.as_deref().unwrap_or("")), - esc(e.metadata_json.as_deref().unwrap_or("")), - ); - csv.push_str(&line); - } - - #[cfg(target_arch = "wasm32")] - { - use wasm_bindgen::JsCast; - if let Some(window) = web_sys::window() { - if let Some(document) = window.document() { - let encoded = urlencoding_lite(&csv); - let data_url = format!("data:text/csv;charset=utf-8,{}", encoded); - if let Ok(element) = document.create_element("a") { - let _ = element.set_attribute("href", &data_url); - let _ = element.set_attribute("download", "audit_export.csv"); - if let Ok(html_elem) = element.dyn_into::() { - html_elem.click(); - } - } - } - } - } -} diff --git a/ui/src/pages/auth/mod.rs b/ui/src/pages/auth/mod.rs index 1a5b4ad..81659e6 100644 --- a/ui/src/pages/auth/mod.rs +++ b/ui/src/pages/auth/mod.rs @@ -129,7 +129,9 @@ pub fn LoginPage() -> Element { nav.replace(Route::DashboardPage {}); } Err(e) => { - let _ = web_sys::console::warn_1(&format!("[nx9-auth-ui] Login failed: {e:?}").into()); + let _ = web_sys::console::warn_1( + &format!("[nx9-auth-ui] Login failed: {e:?}").into(), + ); // Map API errors to a safe, non-enumerating message for creds. let msg = match e { api::ApiError::Unauthorized diff --git a/ui/src/pages/dashboard/mod.rs b/ui/src/pages/dashboard/mod.rs index 79cf044..04c475c 100644 --- a/ui/src/pages/dashboard/mod.rs +++ b/ui/src/pages/dashboard/mod.rs @@ -32,7 +32,9 @@ pub fn DashboardPage() -> Element { }); }); - use_effect(move || { load.call(()); }); + use_effect(move || { + load.call(()); + }); rsx! { Breadcrumb { items: vec![("Dashboard".to_string(), None)] } @@ -243,12 +245,6 @@ fn AdminSummary(admin: Value) -> Element { .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); - let health = admin - .get("system_health") - .and_then(|v| v.get("status")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - rsx! { div { class: "mb-2", h2 { style: "margin-bottom: 0.75rem;", "Administrator overview" } diff --git a/ui/src/pages/groups/mod.rs b/ui/src/pages/groups/mod.rs index e06694e..933e2ed 100644 --- a/ui/src/pages/groups/mod.rs +++ b/ui/src/pages/groups/mod.rs @@ -1,7 +1,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::forms::TextInput; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::models::{GroupView, UserView}; use crate::routes::Route; use crate::services::api; @@ -30,27 +30,45 @@ pub fn GroupsPage() -> Element { error.set(None); spawn(async move { match api::list_groups().await { - Ok(list) => { groups.set(list); loading.set(false); } - Err(e) => { error.set(Some(e.to_string())); loading.set(false); } + Ok(list) => { + groups.set(list); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); let mut filtered: Vec = groups() .into_iter() - .filter(|g| matches_query(&g.name, &query()) || g.description.as_deref().map(|d| matches_query(d, &query())).unwrap_or(false)) + .filter(|g| { + matches_query(&g.name, &query()) + || g.description + .as_deref() + .map(|d| matches_query(d, &query())) + .unwrap_or(false) + }) .collect(); - + let sk = sort_key(); filtered.sort_by(|a, b| match sk.as_str() { "members" => b.member_count.cmp(&a.member_count), _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }); - + let total = filtered.len(); - let page_items: Vec = filtered.into_iter().skip(page() * page_size).take(page_size).collect(); + let page_items: Vec = filtered + .into_iter() + .skip(page() * page_size) + .take(page_size) + .collect(); rsx! { Breadcrumb { items: vec![ @@ -207,7 +225,7 @@ pub fn GroupDetailPage(id: String) -> Element { let mut all_users = use_signal(Vec::::new); let mut error = use_signal(|| Option::::None); let mut loading = use_signal(|| true); - + let mut add_user_id = use_signal(String::new); let mut edit_mode = use_signal(|| false); @@ -237,7 +255,9 @@ pub fn GroupDetailPage(id: String) -> Element { }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); rsx! { Breadcrumb { items: vec![ diff --git a/ui/src/pages/permissions/mod.rs b/ui/src/pages/permissions/mod.rs index 38c48b4..91af9f7 100644 --- a/ui/src/pages/permissions/mod.rs +++ b/ui/src/pages/permissions/mod.rs @@ -2,7 +2,7 @@ use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner}; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::models::PermissionsResponse; use crate::routes::Route; use crate::services::api; @@ -35,7 +35,9 @@ pub fn PermissionsPage() -> Element { } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); rsx! { Breadcrumb { items: vec![ @@ -60,7 +62,7 @@ pub fn PermissionsPage() -> Element { let groups: Vec = d.groups.iter().map(|g| g.group.clone()).collect(); let q = query(); let gf = group_filter(); - + // Flatten permissions for data table let mut all_perms: Vec<(String, crate::models::PermissionView)> = Vec::new(); for g in &d.groups { @@ -72,14 +74,14 @@ pub fn PermissionsPage() -> Element { } } } - + let sk = sort_key(); all_perms.sort_by(|a, b| match sk.as_str() { "group" => a.0.cmp(&b.0).then_with(|| a.1.name.cmp(&b.1.name)), "description" => a.1.description.cmp(&b.1.description), _ => a.1.name.cmp(&b.1.name), }); - + let total = all_perms.len(); let page_items: Vec<_> = all_perms.into_iter().skip(page() * page_size).take(page_size).collect(); diff --git a/ui/src/pages/profile/mod.rs b/ui/src/pages/profile/mod.rs index f544092..74d2347 100644 --- a/ui/src/pages/profile/mod.rs +++ b/ui/src/pages/profile/mod.rs @@ -40,7 +40,9 @@ pub fn ProfilePage() -> Element { } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); rsx! { Breadcrumb { items: vec![ diff --git a/ui/src/pages/roles/mod.rs b/ui/src/pages/roles/mod.rs index 3a39b3f..70e0299 100644 --- a/ui/src/pages/roles/mod.rs +++ b/ui/src/pages/roles/mod.rs @@ -3,7 +3,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::forms::{Checkbox, TextInput}; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::models::{PermissionView, RoleView}; use crate::routes::Route; use crate::services::api; @@ -56,7 +56,8 @@ pub fn RolesPage() -> Element { reload.call(()); }); - let can_create = state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish(); + let can_create = + state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish(); use_effect(move || { if can_create && crate::utils::check_and_clear_create_intent() { show_create.set(true); diff --git a/ui/src/pages/service_accounts/mod.rs b/ui/src/pages/service_accounts/mod.rs index d529360..014c98f 100644 --- a/ui/src/pages/service_accounts/mod.rs +++ b/ui/src/pages/service_accounts/mod.rs @@ -3,7 +3,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::forms::TextInput; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::components::widgets::StatusChip; use crate::models::ServiceAccountView; use crate::routes::Route; @@ -44,9 +44,12 @@ pub fn ServiceAccountsPage() -> Element { } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); - let can_create = state.auth.read().has_permission("service_accounts:manage") || state.auth.read().is_adminish(); + let can_create = state.auth.read().has_permission("service_accounts:manage") + || state.auth.read().is_adminish(); use_effect(move || { if can_create && crate::utils::check_and_clear_create_intent() { show_create.set(true); @@ -69,9 +72,13 @@ pub fn ServiceAccountsPage() -> Element { "created" => b.created_at.cmp(&a.created_at), _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }); - + let total = filtered.len(); - let page_items: Vec<_> = filtered.into_iter().skip(page() * page_size).take(page_size).collect(); + let page_items: Vec<_> = filtered + .into_iter() + .skip(page() * page_size) + .take(page_size) + .collect(); rsx! { Breadcrumb { items: vec![ diff --git a/ui/src/pages/sessions/mod.rs b/ui/src/pages/sessions/mod.rs index 3c67383..9edaa09 100644 --- a/ui/src/pages/sessions/mod.rs +++ b/ui/src/pages/sessions/mod.rs @@ -1,6 +1,6 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner}; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::models::SessionView; use crate::routes::Route; use crate::services::api; @@ -9,12 +9,19 @@ use crate::utils::{format_datetime, matches_query}; use dioxus::prelude::*; fn parse_browser(ua: &str) -> String { - if ua.contains("Chrome") && !ua.contains("Edg") { "Chrome".to_string() } - else if ua.contains("Firefox") { "Firefox".to_string() } - else if ua.contains("Safari") && !ua.contains("Chrome") { "Safari".to_string() } - else if ua.contains("Edg") { "Edge".to_string() } - else if ua.is_empty() { "Unknown".to_string() } - else { ua.chars().take(30).collect::() + "..." } + if ua.contains("Chrome") && !ua.contains("Edg") { + "Chrome".to_string() + } else if ua.contains("Firefox") { + "Firefox".to_string() + } else if ua.contains("Safari") && !ua.contains("Chrome") { + "Safari".to_string() + } else if ua.contains("Edg") { + "Edge".to_string() + } else if ua.is_empty() { + "Unknown".to_string() + } else { + ua.chars().take(30).collect::() + "..." + } } #[component] @@ -32,13 +39,21 @@ pub fn SessionsPage() -> Element { error.set(None); spawn(async move { match api::list_sessions().await { - Ok(r) => { sessions.set(r.sessions); loading.set(false); } - Err(e) => { error.set(Some(e.to_string())); loading.set(false); } + Ok(r) => { + sessions.set(r.sessions); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); let filtered: Vec = sessions() .into_iter() diff --git a/ui/src/pages/settings/mod.rs b/ui/src/pages/settings/mod.rs index 9fdad1d..a60e7fa 100644 --- a/ui/src/pages/settings/mod.rs +++ b/ui/src/pages/settings/mod.rs @@ -1,8 +1,8 @@ use crate::components::navigation::Breadcrumb; use crate::routes::Route; +use crate::services::api; use crate::state::{AppState, ToastKind}; use crate::theme::ThemeMode; -use crate::services::api; use dioxus::prelude::*; #[component] diff --git a/ui/src/pages/tenants/mod.rs b/ui/src/pages/tenants/mod.rs index d39e885..bdae886 100644 --- a/ui/src/pages/tenants/mod.rs +++ b/ui/src/pages/tenants/mod.rs @@ -1,7 +1,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::forms::TextInput; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::models::{ApplicationView, AuditEntry, TenantView, UserView}; use crate::routes::Route; use crate::services::api; @@ -30,15 +30,24 @@ pub fn TenantsPage() -> Element { error.set(None); spawn(async move { match api::list_tenants().await { - Ok(list) => { tenants.set(list); loading.set(false); } - Err(e) => { error.set(Some(e.to_string())); loading.set(false); } + Ok(list) => { + tenants.set(list); + loading.set(false); + } + Err(e) => { + error.set(Some(e.to_string())); + loading.set(false); + } } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); - let can_create = state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish(); + let can_create = + state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish(); use_effect(move || { if can_create && crate::utils::check_and_clear_create_intent() { show_create.set(true); @@ -59,7 +68,11 @@ pub fn TenantsPage() -> Element { list }; let total = filtered.len(); - let page_items: Vec = filtered.into_iter().skip(page() * page_size).take(page_size).collect(); + let page_items: Vec = filtered + .into_iter() + .skip(page() * page_size) + .take(page_size) + .collect(); rsx! { Breadcrumb { items: vec![ @@ -290,7 +303,7 @@ pub fn TenantDetailPage(id: String) -> Element { let load_activity = use_callback(move |_: ()| { let id = tenant_id_act.clone(); spawn(async move { - let q = format!("resource_type=tenant&q={id}&limit=50"); + let q = format!("resource_type=tenant&resource_id={id}&limit=50"); if let Ok(resp) = api::list_audit(&q).await { activity.set(resp.entries); } diff --git a/ui/src/pages/tokens/mod.rs b/ui/src/pages/tokens/mod.rs index 143c5f6..119372f 100644 --- a/ui/src/pages/tokens/mod.rs +++ b/ui/src/pages/tokens/mod.rs @@ -41,7 +41,9 @@ pub fn TokensPage() -> Element { } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); let filtered: Vec = tokens() .into_iter() diff --git a/ui/src/pages/users/mod.rs b/ui/src/pages/users/mod.rs index 89a3ffe..cdb6a0b 100644 --- a/ui/src/pages/users/mod.rs +++ b/ui/src/pages/users/mod.rs @@ -3,7 +3,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::forms::{PasswordInput, TextInput}; use crate::components::navigation::Breadcrumb; -use crate::components::tables::{DataTable, ColumnDef}; +use crate::components::tables::{ColumnDef, DataTable}; use crate::components::widgets::StatusChip; use crate::models::UserView; use crate::routes::Route; @@ -47,9 +47,12 @@ pub fn UsersPage() -> Element { }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); - let can_create = state.auth.read().has_permission("users:create") || state.auth.read().is_adminish(); + let can_create = + state.auth.read().has_permission("users:create") || state.auth.read().is_adminish(); use_effect(move || { if can_create && crate::utils::check_and_clear_create_intent() { show_create.set(true); @@ -318,8 +321,7 @@ pub fn UserDetailPage(id: String) -> Element { let mut user = use_signal(|| Option::::None); let mut roles = use_signal(Vec::::new); let mut all_roles = use_signal(Vec::::new); - let mut user_apps = - use_signal(Vec::::new); + let mut user_apps = use_signal(Vec::::new); let mut error = use_signal(|| Option::::None); let mut loading = use_signal(|| true); let mut new_pass = use_signal(String::new); @@ -352,7 +354,9 @@ pub fn UserDetailPage(id: String) -> Element { } }); }); - use_effect(move || { reload.call(()); }); + use_effect(move || { + reload.call(()); + }); rsx! { Breadcrumb { items: vec![ diff --git a/ui/src/routes/mod.rs b/ui/src/routes/mod.rs index bd3d7bf..a58c31e 100644 --- a/ui/src/routes/mod.rs +++ b/ui/src/routes/mod.rs @@ -7,7 +7,7 @@ use crate::pages::{ audit::AuditPage, auth::{ForbiddenPage, LoginPage, UnauthorizedPage}, dashboard::DashboardPage, - groups::{GroupsPage, GroupDetailPage}, + groups::{GroupDetailPage, GroupsPage}, not_found::NotFoundPage, permissions::PermissionsPage, profile::ProfilePage, @@ -15,7 +15,7 @@ use crate::pages::{ service_accounts::ServiceAccountsPage, sessions::SessionsPage, settings::SettingsPage, - tenants::{TenantsPage, TenantDetailPage}, + tenants::{TenantDetailPage, TenantsPage}, tokens::TokensPage, users::{UserDetailPage, UsersPage}, }; diff --git a/ui/src/services/api.rs b/ui/src/services/api.rs index 63823b4..fc54c84 100644 --- a/ui/src/services/api.rs +++ b/ui/src/services/api.rs @@ -252,7 +252,10 @@ pub async fn list_tenants() -> Result, ApiError> { Ok(r.tenants) } -pub async fn update_profile(email: Option<&str>, full_name: Option<&str>) -> Result { +pub async fn update_profile( + email: Option<&str>, + full_name: Option<&str>, +) -> Result { let body = serde_json::json!({ "email": email, "full_name": full_name }); patch_json("/profile", &body).await } @@ -425,8 +428,11 @@ pub async fn update_application( } pub async fn rotate_application_secret(id: &str) -> Result { - let r: RotateSecretResponse = - post_json(&format!("/applications/{id}/secret"), &serde_json::json!({})).await?; + let r: RotateSecretResponse = post_json( + &format!("/applications/{id}/secret"), + &serde_json::json!({}), + ) + .await?; Ok(r.client_secret) } @@ -441,7 +447,9 @@ pub async fn get_application(id: &str) -> Result { .map_err(|e| ApiError::Other(e.to_string())) } -pub async fn list_application_members(app_id: &str) -> Result, ApiError> { +pub async fn list_application_members( + app_id: &str, +) -> Result, ApiError> { let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?; Ok(r.members) } @@ -516,8 +524,11 @@ pub async fn delete_service_account(id: &str) -> Result<(), ApiError> { } pub async fn rotate_service_account_secret(id: &str) -> Result { - let r: Value = - post_json(&format!("/service-accounts/{id}/secret"), &serde_json::json!({})).await?; + let r: Value = post_json( + &format!("/service-accounts/{id}/secret"), + &serde_json::json!({}), + ) + .await?; Ok(r.get("raw_secret") .and_then(|v| v.as_str()) .unwrap_or("") @@ -569,7 +580,11 @@ pub async fn get_group(id: &str) -> Result { get(&format!("/groups/{id}")).await } -pub async fn update_group(id: &str, name: &str, description: Option<&str>) -> Result { +pub async fn update_group( + id: &str, + name: &str, + description: Option<&str>, +) -> Result { let body = serde_json::json!({ "name": name, "description": description }); let r: Value = patch_json(&format!("/groups/{id}"), &body).await?; serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null)) @@ -601,7 +616,11 @@ pub async fn create_tenant(name: &str, slug: Option<&str>) -> Result) -> Result { +pub async fn update_tenant( + id: &str, + name: &str, + slug: Option<&str>, +) -> Result { let body = serde_json::json!({ "name": name, "slug": slug }); let r: Value = patch_json(&format!("/tenants/{id}"), &body).await?; serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null)) @@ -639,6 +658,10 @@ pub async fn remove_tenant_user(tenant_id: &str, user_id: &str) -> Result<(), Ap pub async fn list_tenant_applications(tenant_id: &str) -> Result, ApiError> { let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?; - serde_json::from_value(r.get("applications").cloned().unwrap_or(Value::Array(vec![]))) - .map_err(|e| ApiError::Other(e.to_string())) + serde_json::from_value( + r.get("applications") + .cloned() + .unwrap_or(Value::Array(vec![])), + ) + .map_err(|e| ApiError::Other(e.to_string())) } diff --git a/ui/src/services/session.rs b/ui/src/services/session.rs index 6f6df14..7139438 100644 --- a/ui/src/services/session.rs +++ b/ui/src/services/session.rs @@ -33,5 +33,3 @@ pub fn clear() { SessionStorage::delete(ACCESS_KEY); SessionStorage::delete(REFRESH_KEY); } - - diff --git a/ui/src/state/mod.rs b/ui/src/state/mod.rs index 7a69b9f..3eb1c99 100644 --- a/ui/src/state/mod.rs +++ b/ui/src/state/mod.rs @@ -1,9 +1,9 @@ //! Global application state via Dioxus signals / context. -use crate::models::MeResponse; -use crate::theme::{self, ThemeMode}; -use crate::routes::Route; use crate::components::navigation::registry::{NavigationItem, NavigationRegistry}; +use crate::models::MeResponse; +use crate::routes::Route; +use crate::theme::{self, ThemeMode}; use dioxus::prelude::*; /// Toast notification. @@ -134,7 +134,7 @@ impl AppState { permission: None, children: vec![], }, - ] + ], ); registry.register_section( "Security", @@ -179,7 +179,7 @@ impl AppState { permission: Some("roles:manage".into()), children: vec![], }, - ] + ], ); registry.register_section( "Audit & Logs", @@ -200,7 +200,7 @@ impl AppState { permission: Some("audit:view".into()), children: vec![], }, - ] + ], ); registry.register_section( "System", @@ -221,7 +221,7 @@ impl AppState { permission: None, children: vec![], }, - ] + ], ); let state = Self { @@ -276,4 +276,3 @@ impl AppState { self.set_theme(next); } } - diff --git a/ui/src/utils/mod.rs b/ui/src/utils/mod.rs index 2b5f7f0..e01ab84 100644 --- a/ui/src/utils/mod.rs +++ b/ui/src/utils/mod.rs @@ -2,7 +2,10 @@ /// Initials from a username (up to 2 chars). pub fn initials(name: &str) -> String { - let parts: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|s| !s.is_empty()).collect(); + let parts: Vec<&str> = name + .split(|c: char| !c.is_alphanumeric()) + .filter(|s| !s.is_empty()) + .collect(); if parts.is_empty() { return "?".to_string(); } @@ -109,7 +112,11 @@ pub fn check_and_clear_create_intent() -> bool { }; let new_url = format!("{pathname}{new_search}"); let _ = window.history().and_then(|h| { - h.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url)) + h.replace_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(&new_url), + ) }); } return true;