refactor: harden audit filtering and management UI

This commit is contained in:
thakares committed 2026-07-24 17:36:14 +05:30
1 parent a969f9c571
commit 3d14061795
35 files changed
+489 -422

No files matched your search

+3 -1
View File
@@ -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: 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. - **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. - **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 ## Rate Limiting & Protection
+3
View File
@@ -56,6 +56,7 @@ pub struct AuditQuery {
pub actor: Option<String>, pub actor: Option<String>,
pub action: Option<String>, pub action: Option<String>,
pub resource_type: Option<String>, pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub severity: Option<String>, pub severity: Option<String>,
pub since: Option<String>, pub since: Option<String>,
pub until: Option<String>, pub until: Option<String>,
@@ -80,6 +81,7 @@ pub async fn list_audit(
actor_user_id: query.actor, actor_user_id: query.actor,
action: query.action, action: query.action,
resource_type: query.resource_type, resource_type: query.resource_type,
resource_id: query.resource_id,
severity: query.severity, severity: query.severity,
since: query.since, since: query.since,
until: query.until, until: query.until,
@@ -124,6 +126,7 @@ pub async fn export_audit(
actor_user_id: query.actor, actor_user_id: query.actor,
action: query.action, action: query.action,
resource_type: query.resource_type, resource_type: query.resource_type,
resource_id: query.resource_id,
severity: query.severity, severity: query.severity,
since: query.since, since: query.since,
until: query.until, until: query.until,
+10 -10
View File
@@ -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. /// Serve a static file from the UI dist dir, or SPA fallback for app routes.
pub async fn serve_ui(uri: Uri) -> Response { 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. // Security Hardening: Reject & sanitize any GET request containing credentials in query string.
if let Some(query) = uri.query() { if let Some(query) = uri.query() {
let q_lower = query.to_ascii_lowercase(); 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 // Normalize and reject path traversal
if path.contains("..") { if path.contains("..") {
return StatusCode::BAD_REQUEST.into_response(); return StatusCode::BAD_REQUEST.into_response();
+1
View File
@@ -44,6 +44,7 @@ pub struct AuditFilter {
pub actor_user_id: Option<String>, pub actor_user_id: Option<String>,
pub action: Option<String>, pub action: Option<String>,
pub resource_type: Option<String>, pub resource_type: Option<String>,
pub resource_id: Option<String>,
pub severity: Option<String>, pub severity: Option<String>,
pub since: Option<String>, pub since: Option<String>,
pub until: Option<String>, pub until: Option<String>,
+70 -107
View File
@@ -1,9 +1,8 @@
use crate::db::models::{AuditFilter, AuditLog};
use crate::db::repository::traits::AuditRepository; use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;
use crate::db::models::{AuditFilter, AuditLog};
pub struct PostgresAuditRepository { pub struct PostgresAuditRepository {
pub pool: PgPool, pub pool: PgPool,
} }
@@ -31,29 +30,13 @@ impl AuditRepository for PostgresAuditRepository {
user_agent: Option<&str>, user_agent: Option<&str>,
metadata_json: Option<&str>, metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> { ) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>( sqlx::query_as::<_, AuditLog>(r#"
r#" INSERT INTO audit_logs (id, actor_user_id, target_user_id, action, resource_type, resource_id, severity, ip_address, user_agent, metadata_json)
INSERT INTO audit_logs ( VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *
id, actor_user_id, target_user_id, "#)
action, resource_type, resource_id, .bind(id).bind(actor_user_id).bind(target_user_id).bind(action).bind(resource_type)
severity, ip_address, user_agent, metadata_json .bind(resource_id).bind(severity).bind(ip_address).bind(user_agent).bind(metadata_json)
) .fetch_one(&self.pool).await
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<Vec<AuditLog>, sqlx::Error> { async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
@@ -64,91 +47,71 @@ impl AuditRepository for PostgresAuditRepository {
} }
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> { async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
let search_like = filter let search_like = search_like(filter);
.search sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL)
.as_ref() .bind(filter.actor_user_id.as_deref())
.map(|s| format!("%{}%", s.replace('%', "\\%"))); .bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
sqlx::query_as::<_, AuditLog>( .bind(filter.resource_id.as_deref())
r#" .bind(filter.severity.as_deref())
SELECT * FROM audit_logs .bind(filter.since.as_deref())
WHERE ($1::text IS NULL OR actor_user_id = $1) .bind(filter.until.as_deref())
AND ($2::text IS NULL OR action = $2) .bind(search_like.as_deref())
AND ($3::text IS NULL OR resource_type = $3) .bind(filter.success)
AND ($4::text IS NULL OR severity = $4) .bind(filter.limit)
AND ($5::text IS NULL OR created_at >= $5) .bind(filter.offset)
AND ($6::text IS NULL OR created_at <= $6) .fetch_all(&self.pool)
AND ( .await
$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
} }
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> { async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
let search_like = filter let search_like = search_like(filter);
.search let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL)
.as_ref() .bind(filter.actor_user_id.as_deref())
.map(|s| format!("%{}%", s.replace('%', "\\%"))); .bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref())
let row: (i64,) = sqlx::query_as( .bind(filter.resource_id.as_deref())
r#" .bind(filter.severity.as_deref())
SELECT COUNT(*) FROM audit_logs .bind(filter.since.as_deref())
WHERE ($1::text IS NULL OR actor_user_id = $1) .bind(filter.until.as_deref())
AND ($2::text IS NULL OR action = $2) .bind(search_like.as_deref())
AND ($3::text IS NULL OR resource_type = $3) .bind(filter.success)
AND ($4::text IS NULL OR severity = $4) .fetch_one(&self.pool)
AND ($5::text IS NULL OR created_at >= $5) .await?;
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?;
Ok(row.0) Ok(row.0)
} }
} }
fn search_like(filter: &AuditFilter) -> Option<String> {
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')))
"#;
-11
View File
@@ -98,17 +98,6 @@ impl UsersRepository for PostgresUsersRepository {
Ok(()) 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( async fn reassign_user_tenant_with_audit(
&self, &self,
user_id: &str, user_id: &str,
+73 -104
View File
@@ -1,23 +1,21 @@
use crate::db::models::{AuditFilter, AuditLog};
use crate::db::repository::traits::AuditRepository; use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait; use async_trait::async_trait;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::db::models::{AuditFilter, AuditLog};
pub struct SqliteAuditRepository { pub struct SqliteAuditRepository {
pub pool: SqlitePool, pub pool: SqlitePool,
} }
#[async_trait] #[async_trait]
impl AuditRepository for SqliteAuditRepository { impl AuditRepository for SqliteAuditRepository {
/// Count all audit log entries.
async fn count(&self) -> Result<i64, sqlx::Error> { async fn count(&self) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs") let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs")
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
Ok(row.0) Ok(row.0)
} }
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn insert( async fn insert(
&self, &self,
@@ -32,29 +30,13 @@ impl AuditRepository for SqliteAuditRepository {
user_agent: Option<&str>, user_agent: Option<&str>,
metadata_json: Option<&str>, metadata_json: Option<&str>,
) -> Result<AuditLog, sqlx::Error> { ) -> Result<AuditLog, sqlx::Error> {
sqlx::query_as::<_, AuditLog>( sqlx::query_as::<_, AuditLog>(r#"
r#" INSERT INTO audit_logs (id, actor_user_id, target_user_id, action, resource_type, resource_id, severity, ip_address, user_agent, metadata_json)
INSERT INTO audit_logs ( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *
id, actor_user_id, target_user_id, "#)
action, resource_type, resource_id, .bind(id).bind(actor_user_id).bind(target_user_id).bind(action).bind(resource_type)
severity, ip_address, user_agent, metadata_json .bind(resource_id).bind(severity).bind(ip_address).bind(user_agent).bind(metadata_json)
) .fetch_one(&self.pool).await
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<Vec<AuditLog>, sqlx::Error> { async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
@@ -65,41 +47,46 @@ impl AuditRepository for SqliteAuditRepository {
} }
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> { async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
let search_like = filter list_filtered(&self.pool, filter).await
.search }
.as_ref()
.map(|s| format!("%{}%", s.replace('%', "\\%")));
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
sqlx::query_as::<_, AuditLog>( async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
r#" let search_like = search_like(filter);
SELECT * FROM audit_logs let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
WHERE (?1 IS NULL OR actor_user_id = ?1) let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL)
AND (?2 IS NULL OR action = ?2) .bind(filter.actor_user_id.as_deref())
AND (?3 IS NULL OR resource_type = ?3) .bind(filter.action.as_deref())
AND (?4 IS NULL OR severity = ?4) .bind(filter.resource_type.as_deref())
AND (?5 IS NULL OR created_at >= ?5) .bind(filter.resource_id.as_deref())
AND (?6 IS NULL OR created_at <= ?6) .bind(filter.severity.as_deref())
AND ( .bind(filter.since.as_deref())
?7 IS NULL .bind(filter.until.as_deref())
OR action LIKE ?7 ESCAPE '\' .bind(search_like.as_deref())
OR resource_type LIKE ?7 ESCAPE '\' .bind(success_val)
OR resource_id LIKE ?7 ESCAPE '\' .fetch_one(&self.pool)
OR ip_address LIKE ?7 ESCAPE '\' .await?;
OR metadata_json LIKE ?7 ESCAPE '\' Ok(row.0)
) }
AND ( }
?8 IS NULL
OR (?8 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') fn search_like(filter: &AuditFilter) -> Option<String> {
OR (?8 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')) filter
) .search
ORDER BY created_at DESC .as_ref()
LIMIT ?9 OFFSET ?10 .map(|s| format!("%{}%", s.replace('%', "\\%")))
"#, }
)
async fn list_filtered(
pool: &SqlitePool,
filter: &AuditFilter,
) -> Result<Vec<AuditLog>, 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.actor_user_id.as_deref())
.bind(filter.action.as_deref()) .bind(filter.action.as_deref())
.bind(filter.resource_type.as_deref()) .bind(filter.resource_type.as_deref())
.bind(filter.resource_id.as_deref())
.bind(filter.severity.as_deref()) .bind(filter.severity.as_deref())
.bind(filter.since.as_deref()) .bind(filter.since.as_deref())
.bind(filter.until.as_deref()) .bind(filter.until.as_deref())
@@ -107,51 +94,33 @@ impl AuditRepository for SqliteAuditRepository {
.bind(success_val) .bind(success_val)
.bind(filter.limit) .bind(filter.limit)
.bind(filter.offset) .bind(filter.offset)
.fetch_all(&self.pool) .fetch_all(pool)
.await .await
}
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, 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 });
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')))
"#;
-11
View File
@@ -100,17 +100,6 @@ impl UsersRepository for SqliteUsersRepository {
Ok(()) 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( async fn reassign_user_tenant_with_audit(
&self, &self,
user_id: &str, user_id: &str,
-1
View File
@@ -21,7 +21,6 @@ pub trait UsersRepository: Send + Sync {
password_hash: &str, password_hash: &str,
) -> Result<User, sqlx::Error>; ) -> Result<User, sqlx::Error>;
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>; 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( async fn reassign_user_tenant_with_audit(
&self, &self,
user_id: &str, user_id: &str,
+52
View File
@@ -224,3 +224,55 @@ fn test_rfc4180_csv_escaping_rules() {
assert_eq!(esc("with \"quotes\""), "\"with \"\"quotes\"\"\""); assert_eq!(esc("with \"quotes\""), "\"with \"\"quotes\"\"\"");
assert_eq!(esc("multi\nline"), "\"multi\nline\""); 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;
}
+2 -2
View File
@@ -57,7 +57,7 @@ async fn test_tenant_user_listing_and_assignment() {
// Reassign user to Acme Org // Reassign user to Acme Org
provider provider
.users() .users()
.update_user_tenant(&user.id, &tenant_id) .reassign_user_tenant_with_audit(&user.id, &tenant_id, None, None, None)
.await .await
.expect("Tenant assignment should succeed"); .expect("Tenant assignment should succeed");
@@ -251,7 +251,7 @@ async fn test_session_identity_immediately_reflects_tenant_reassignment() {
// 4. Reassign user to Tenant B // 4. Reassign user to Tenant B
provider provider
.users() .users()
.update_user_tenant(&user.id, &tenant_b) .reassign_user_tenant_with_audit(&user.id, &tenant_b, None, None, None)
.await .await
.unwrap(); .unwrap();
+3 -6
View File
@@ -71,8 +71,7 @@ pub fn Modal(
title: String, title: String,
open: bool, open: bool,
on_close: EventHandler<()>, on_close: EventHandler<()>,
#[props(default)] #[props(default)] large: bool,
large: bool,
children: Element, children: Element,
) -> Element { ) -> Element {
if !open { if !open {
@@ -112,10 +111,8 @@ pub fn ConfirmDialog(
title: String, title: String,
message: String, message: String,
open: bool, open: bool,
#[props(default = "Confirm".to_string())] #[props(default = "Confirm".to_string())] confirm_label: String,
confirm_label: String, #[props(default)] danger: bool,
#[props(default)]
danger: bool,
on_confirm: EventHandler<()>, on_confirm: EventHandler<()>,
on_cancel: EventHandler<()>, on_cancel: EventHandler<()>,
) -> Element { ) -> Element {
+8 -7
View File
@@ -22,10 +22,14 @@ pub fn Header() -> Element {
let auth_state = state.auth.read(); let auth_state = state.auth.read();
let can_create_user = auth_state.has_permission("users:create") || auth_state.is_adminish(); 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_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_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 can_create_sa = auth_state.has_permission("service_accounts:manage")
let has_any_create = can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa; || 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); drop(auth_state);
rsx! { rsx! {
@@ -201,8 +205,6 @@ pub fn Header() -> Element {
} }
} }
#[component] #[component]
pub fn Sidebar() -> Element { pub fn Sidebar() -> Element {
let state = use_context::<AppState>(); let state = use_context::<AppState>();
@@ -218,8 +220,7 @@ pub fn Sidebar() -> Element {
}; };
let active = |r: &Route| -> bool { let active = |r: &Route| -> bool {
format!("{path:?}").split_whitespace().next() format!("{path:?}").split_whitespace().next() == format!("{r:?}").split_whitespace().next()
== format!("{r:?}").split_whitespace().next()
}; };
rsx! { rsx! {
-1
View File
@@ -1,6 +1,5 @@
use crate::routes::Route; use crate::routes::Route;
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct NavigationItem { pub struct NavigationItem {
pub id: String, pub id: String,
+1 -1
View File
@@ -1,6 +1,6 @@
//! Table helpers: search toolbar + pagination. //! Table helpers: search toolbar + pagination.
pub mod datatable; pub mod datatable;
pub use datatable::{DataTable, ColumnDef}; pub use datatable::{ColumnDef, DataTable};
use dioxus::prelude::*; use dioxus::prelude::*;
+1 -5
View File
@@ -65,11 +65,7 @@ pub fn Card(
} }
#[component] #[component]
pub fn StatCard( pub fn StatCard(label: String, value: String, #[props(default)] hint: String) -> Element {
label: String,
value: String,
#[props(default)] hint: String,
) -> Element {
rsx! { rsx! {
div { class: "stat-card", div { class: "stat-card",
div { class: "label", "{label}" } div { class: "label", "{label}" }
+2 -7
View File
@@ -472,14 +472,9 @@ pub fn ApplicationDetailPage(id: String) -> Element {
} }
let id = app_id_activity.clone(); let id = app_id_activity.clone();
spawn(async move { 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 { if let Ok(resp) = api::list_audit(&q).await {
let filtered: Vec<_> = resp activity.set(resp.entries);
.entries
.into_iter()
.filter(|e| e.resource_id.as_deref() == Some(id.as_str()))
.collect();
activity.set(filtered);
} }
}); });
}); });
+96 -75
View File
@@ -69,13 +69,17 @@ pub fn AuditPage() -> Element {
}); });
}); });
use_effect(move || { load.call(()); }); use_effect(move || {
load.call(());
});
rsx! { rsx! {
Breadcrumb { items: vec![ Breadcrumb {
("Dashboard".to_string(), Some(Route::DashboardPage {})), items: vec![
("Audit Log".to_string(), None), ("Dashboard".to_string(), Some(Route::DashboardPage {})),
]} ("Audit Log".to_string(), None),
],
}
div { class: "page-header", div { class: "page-header",
div { div {
@@ -88,36 +92,77 @@ pub fn AuditPage() -> Element {
r#type: "button", r#type: "button",
title: "Export filtered audit log records as CSV", title: "Export filtered audit log records as CSV",
onclick: move |_| { onclick: move |_| {
let mut parts = vec!["limit=5000".to_string(), "offset=0".to_string()]; let mut parts = vec![
if !query().is_empty() { parts.push(format!("q={}", urlencoding_lite(&query()))); } "limit=5000".to_string(),
if !action().is_empty() { parts.push(format!("action={}", urlencoding_lite(&action()))); } "offset=0".to_string(),
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()); } if !query().is_empty() {
else if success() == "false" { parts.push("success=false".to_string()); } parts.push(format!("q={}", urlencoding_lite(&query())));
if !since().is_empty() { parts.push(format!("since={}", urlencoding_lite(&since()))); } }
if !until().is_empty() { parts.push(format!("until={}", urlencoding_lite(&until()))); } 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 qs = parts.join("&");
let export_url = format!("/api/v1/audit/export?{qs}"); let export_url = format!("/api/v1/audit/export?{qs}");
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
if let Some(window) = web_sys::window() { if let Some(window) = web_sys::window() {
if let Some(document) = window.document() { if let Some(document) = window.document() {
if let Ok(element) = document.create_element("a") { if let Ok(element) = document.create_element("a") {
let _ = element.set_attribute("href", &export_url); let _ = element.set_attribute("href", &export_url);
let _ = element.set_attribute("download", "audit_export.csv"); let _ = element.set_attribute(
if let Ok(html_elem) = element.dyn_into::<web_sys::HtmlElement>() { "download",
html_elem.click(); "audit_export.csv",
);
if let Ok(html_element) =
element.dyn_into::<web_sys::HtmlElement>()
{
html_element.click();
} }
} }
} }
} }
} }
#[cfg(not(target_arch = "wasm32"))]
{
let _ = export_url;
}
}, },
"Export CSV" "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…", placeholder: "Search action, resource, IP…",
} }
input { input {
class: "form-control", style: "width:auto;max-width:140px;", class: "form-control",
style: "width:auto;max-width:140px;",
placeholder: "Action", placeholder: "Action",
value: "{action()}", value: "{action()}",
oninput: move |e| action.set(e.value()), oninput: move |e| action.set(e.value()),
} }
input { input {
class: "form-control", style: "width:auto;max-width:140px;", class: "form-control",
style: "width:auto;max-width:140px;",
placeholder: "Resource", placeholder: "Resource",
value: "{resource()}", value: "{resource()}",
oninput: move |e| resource.set(e.value()), oninput: move |e| resource.set(e.value()),
} }
select { select {
class: "form-control", style: "width:auto;", class: "form-control",
style: "width:auto;",
value: "{severity()}", value: "{severity()}",
onchange: move |e| severity.set(e.value()), onchange: move |e| severity.set(e.value()),
option { value: "all", "All severities" } option { value: "all", "All severities" }
@@ -151,7 +199,8 @@ pub fn AuditPage() -> Element {
option { value: "critical", "Critical" } option { value: "critical", "Critical" }
} }
select { select {
class: "form-control", style: "width:auto;", class: "form-control",
style: "width:auto;",
value: "{success()}", value: "{success()}",
onchange: move |e| success.set(e.value()), onchange: move |e| success.set(e.value()),
option { value: "all", "Success/Fail" } option { value: "all", "Success/Fail" }
@@ -159,22 +208,28 @@ pub fn AuditPage() -> Element {
option { value: "false", "Failure" } option { value: "false", "Failure" }
} }
input { input {
class: "form-control", style: "width:auto;", class: "form-control",
style: "width:auto;",
r#type: "date", r#type: "date",
value: "{since()}", value: "{since()}",
oninput: move |e| since.set(e.value()), oninput: move |e| since.set(e.value()),
title: "Since", title: "Since",
} }
input { input {
class: "form-control", style: "width:auto;", class: "form-control",
style: "width:auto;",
r#type: "date", r#type: "date",
value: "{until()}", value: "{until()}",
oninput: move |e| until.set(e.value()), oninput: move |e| until.set(e.value()),
title: "Until", title: "Until",
} }
button { button {
class: "btn btn-primary", r#type: "button", class: "btn btn-primary",
onclick: move |_| { page.set(0); load.call(()); }, r#type: "button",
onclick: move |_| {
page.set(0);
load.call(());
},
"Apply" "Apply"
} }
} }
@@ -210,17 +265,23 @@ pub fn AuditPage() -> Element {
for e in d.entries { for e in d.entries {
tr { key: "{e.id}", tr { key: "{e.id}",
td { class: "mono", "{format_datetime(&e.created_at)}" } td { class: "mono", "{format_datetime(&e.created_at)}" }
td { code { "{e.action}" } } td {
code { "{e.action}" }
}
td { td {
span { "{e.resource_type}" } span { "{e.resource_type}" }
if let Some(rid) = &e.resource_id { 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}" "{rid}"
} }
} }
} }
td { td {
span { class: "{severity_badge_class(&e.severity)}", "{e.severity}" } span { class: "{severity_badge_class(&e.severity)}",
"{e.severity}"
}
} }
td { td {
if e.success { if e.success {
@@ -232,9 +293,7 @@ pub fn AuditPage() -> Element {
td { class: "mono", td { class: "mono",
"{e.actor_user_id.as_deref().unwrap_or(\"—\")}" "{e.actor_user_id.as_deref().unwrap_or(\"—\")}"
} }
td { class: "mono", td { class: "mono", "{e.ip_address.as_deref().unwrap_or(\"—\")}" }
"{e.ip_address.as_deref().unwrap_or(\"—\")}"
}
} }
} }
} }
@@ -242,9 +301,12 @@ pub fn AuditPage() -> Element {
} }
Pagination { Pagination {
page: page(), page: page(),
page_size: page_size, page_size,
total: d.total as usize, 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() .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::<web_sys::HtmlElement>() {
html_elem.click();
}
}
}
}
}
}
+3 -1
View File
@@ -129,7 +129,9 @@ pub fn LoginPage() -> Element {
nav.replace(Route::DashboardPage {}); nav.replace(Route::DashboardPage {});
} }
Err(e) => { 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. // Map API errors to a safe, non-enumerating message for creds.
let msg = match e { let msg = match e {
api::ApiError::Unauthorized api::ApiError::Unauthorized
+3 -7
View File
@@ -32,7 +32,9 @@ pub fn DashboardPage() -> Element {
}); });
}); });
use_effect(move || { load.call(()); }); use_effect(move || {
load.call(());
});
rsx! { rsx! {
Breadcrumb { items: vec![("Dashboard".to_string(), None)] } Breadcrumb { items: vec![("Dashboard".to_string(), None)] }
@@ -243,12 +245,6 @@ fn AdminSummary(admin: Value) -> Element {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.cloned() .cloned()
.unwrap_or_default(); .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! { rsx! {
div { class: "mb-2", div { class: "mb-2",
h2 { style: "margin-bottom: 0.75rem;", "Administrator overview" } h2 { style: "margin-bottom: 0.75rem;", "Administrator overview" }
+27 -7
View File
@@ -1,7 +1,7 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::TextInput; use crate::components::forms::TextInput;
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef}; use crate::components::tables::{ColumnDef, DataTable};
use crate::models::{GroupView, UserView}; use crate::models::{GroupView, UserView};
use crate::routes::Route; use crate::routes::Route;
use crate::services::api; use crate::services::api;
@@ -30,17 +30,31 @@ pub fn GroupsPage() -> Element {
error.set(None); error.set(None);
spawn(async move { spawn(async move {
match api::list_groups().await { match api::list_groups().await {
Ok(list) => { groups.set(list); loading.set(false); } Ok(list) => {
Err(e) => { error.set(Some(e.to_string())); loading.set(false); } 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<GroupView> = groups() let mut filtered: Vec<GroupView> = groups()
.into_iter() .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(); .collect();
let sk = sort_key(); let sk = sort_key();
@@ -50,7 +64,11 @@ pub fn GroupsPage() -> Element {
}); });
let total = filtered.len(); let total = filtered.len();
let page_items: Vec<GroupView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect(); let page_items: Vec<GroupView> = filtered
.into_iter()
.skip(page() * page_size)
.take(page_size)
.collect();
rsx! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
@@ -237,7 +255,9 @@ pub fn GroupDetailPage(id: String) -> Element {
}); });
}); });
use_effect(move || { reload.call(()); }); use_effect(move || {
reload.call(());
});
rsx! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
+4 -2
View File
@@ -2,7 +2,7 @@
use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner}; use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef}; use crate::components::tables::{ColumnDef, DataTable};
use crate::models::PermissionsResponse; use crate::models::PermissionsResponse;
use crate::routes::Route; use crate::routes::Route;
use crate::services::api; use crate::services::api;
@@ -35,7 +35,9 @@ pub fn PermissionsPage() -> Element {
} }
}); });
}); });
use_effect(move || { reload.call(()); }); use_effect(move || {
reload.call(());
});
rsx! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
+3 -1
View File
@@ -40,7 +40,9 @@ pub fn ProfilePage() -> Element {
} }
}); });
}); });
use_effect(move || { reload.call(()); }); use_effect(move || {
reload.call(());
});
rsx! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
+3 -2
View File
@@ -3,7 +3,7 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::{Checkbox, TextInput}; use crate::components::forms::{Checkbox, TextInput};
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef}; use crate::components::tables::{ColumnDef, DataTable};
use crate::models::{PermissionView, RoleView}; use crate::models::{PermissionView, RoleView};
use crate::routes::Route; use crate::routes::Route;
use crate::services::api; use crate::services::api;
@@ -56,7 +56,8 @@ pub fn RolesPage() -> Element {
reload.call(()); 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 || { use_effect(move || {
if can_create && crate::utils::check_and_clear_create_intent() { if can_create && crate::utils::check_and_clear_create_intent() {
show_create.set(true); show_create.set(true);
+11 -4
View File
@@ -3,7 +3,7 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::TextInput; use crate::components::forms::TextInput;
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef}; use crate::components::tables::{ColumnDef, DataTable};
use crate::components::widgets::StatusChip; use crate::components::widgets::StatusChip;
use crate::models::ServiceAccountView; use crate::models::ServiceAccountView;
use crate::routes::Route; 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 || { use_effect(move || {
if can_create && crate::utils::check_and_clear_create_intent() { if can_create && crate::utils::check_and_clear_create_intent() {
show_create.set(true); show_create.set(true);
@@ -71,7 +74,11 @@ pub fn ServiceAccountsPage() -> Element {
}); });
let total = filtered.len(); 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! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
+25 -10
View File
@@ -1,6 +1,6 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner}; use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef}; use crate::components::tables::{ColumnDef, DataTable};
use crate::models::SessionView; use crate::models::SessionView;
use crate::routes::Route; use crate::routes::Route;
use crate::services::api; use crate::services::api;
@@ -9,12 +9,19 @@ use crate::utils::{format_datetime, matches_query};
use dioxus::prelude::*; use dioxus::prelude::*;
fn parse_browser(ua: &str) -> String { fn parse_browser(ua: &str) -> String {
if ua.contains("Chrome") && !ua.contains("Edg") { "Chrome".to_string() } if ua.contains("Chrome") && !ua.contains("Edg") {
else if ua.contains("Firefox") { "Firefox".to_string() } "Chrome".to_string()
else if ua.contains("Safari") && !ua.contains("Chrome") { "Safari".to_string() } } else if ua.contains("Firefox") {
else if ua.contains("Edg") { "Edge".to_string() } "Firefox".to_string()
else if ua.is_empty() { "Unknown".to_string() } } else if ua.contains("Safari") && !ua.contains("Chrome") {
else { ua.chars().take(30).collect::<String>() + "..." } "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::<String>() + "..."
}
} }
#[component] #[component]
@@ -32,13 +39,21 @@ pub fn SessionsPage() -> Element {
error.set(None); error.set(None);
spawn(async move { spawn(async move {
match api::list_sessions().await { match api::list_sessions().await {
Ok(r) => { sessions.set(r.sessions); loading.set(false); } Ok(r) => {
Err(e) => { error.set(Some(e.to_string())); loading.set(false); } 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<SessionView> = sessions() let filtered: Vec<SessionView> = sessions()
.into_iter() .into_iter()
+1 -1
View File
@@ -1,8 +1,8 @@
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::routes::Route; use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind}; use crate::state::{AppState, ToastKind};
use crate::theme::ThemeMode; use crate::theme::ThemeMode;
use crate::services::api;
use dioxus::prelude::*; use dioxus::prelude::*;
#[component] #[component]
+20 -7
View File
@@ -1,7 +1,7 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::TextInput; use crate::components::forms::TextInput;
use crate::components::navigation::Breadcrumb; 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::models::{ApplicationView, AuditEntry, TenantView, UserView};
use crate::routes::Route; use crate::routes::Route;
use crate::services::api; use crate::services::api;
@@ -30,15 +30,24 @@ pub fn TenantsPage() -> Element {
error.set(None); error.set(None);
spawn(async move { spawn(async move {
match api::list_tenants().await { match api::list_tenants().await {
Ok(list) => { tenants.set(list); loading.set(false); } Ok(list) => {
Err(e) => { error.set(Some(e.to_string())); loading.set(false); } 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 || { use_effect(move || {
if can_create && crate::utils::check_and_clear_create_intent() { if can_create && crate::utils::check_and_clear_create_intent() {
show_create.set(true); show_create.set(true);
@@ -59,7 +68,11 @@ pub fn TenantsPage() -> Element {
list list
}; };
let total = filtered.len(); let total = filtered.len();
let page_items: Vec<TenantView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect(); let page_items: Vec<TenantView> = filtered
.into_iter()
.skip(page() * page_size)
.take(page_size)
.collect();
rsx! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
@@ -290,7 +303,7 @@ pub fn TenantDetailPage(id: String) -> Element {
let load_activity = use_callback(move |_: ()| { let load_activity = use_callback(move |_: ()| {
let id = tenant_id_act.clone(); let id = tenant_id_act.clone();
spawn(async move { 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 { if let Ok(resp) = api::list_audit(&q).await {
activity.set(resp.entries); activity.set(resp.entries);
} }
+3 -1
View File
@@ -41,7 +41,9 @@ pub fn TokensPage() -> Element {
} }
}); });
}); });
use_effect(move || { reload.call(()); }); use_effect(move || {
reload.call(());
});
let filtered: Vec<TokenView> = tokens() let filtered: Vec<TokenView> = tokens()
.into_iter() .into_iter()
+10 -6
View File
@@ -3,7 +3,7 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal}; use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::{PasswordInput, TextInput}; use crate::components::forms::{PasswordInput, TextInput};
use crate::components::navigation::Breadcrumb; use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef}; use crate::components::tables::{ColumnDef, DataTable};
use crate::components::widgets::StatusChip; use crate::components::widgets::StatusChip;
use crate::models::UserView; use crate::models::UserView;
use crate::routes::Route; 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 || { use_effect(move || {
if can_create && crate::utils::check_and_clear_create_intent() { if can_create && crate::utils::check_and_clear_create_intent() {
show_create.set(true); show_create.set(true);
@@ -318,8 +321,7 @@ pub fn UserDetailPage(id: String) -> Element {
let mut user = use_signal(|| Option::<UserView>::None); let mut user = use_signal(|| Option::<UserView>::None);
let mut roles = use_signal(Vec::<crate::models::RoleView>::new); let mut roles = use_signal(Vec::<crate::models::RoleView>::new);
let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new); let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new);
let mut user_apps = let mut user_apps = use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
let mut error = use_signal(|| Option::<String>::None); let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let mut new_pass = use_signal(String::new); 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! { rsx! {
Breadcrumb { items: vec![ Breadcrumb { items: vec![
+2 -2
View File
@@ -7,7 +7,7 @@ use crate::pages::{
audit::AuditPage, audit::AuditPage,
auth::{ForbiddenPage, LoginPage, UnauthorizedPage}, auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
dashboard::DashboardPage, dashboard::DashboardPage,
groups::{GroupsPage, GroupDetailPage}, groups::{GroupDetailPage, GroupsPage},
not_found::NotFoundPage, not_found::NotFoundPage,
permissions::PermissionsPage, permissions::PermissionsPage,
profile::ProfilePage, profile::ProfilePage,
@@ -15,7 +15,7 @@ use crate::pages::{
service_accounts::ServiceAccountsPage, service_accounts::ServiceAccountsPage,
sessions::SessionsPage, sessions::SessionsPage,
settings::SettingsPage, settings::SettingsPage,
tenants::{TenantsPage, TenantDetailPage}, tenants::{TenantDetailPage, TenantsPage},
tokens::TokensPage, tokens::TokensPage,
users::{UserDetailPage, UsersPage}, users::{UserDetailPage, UsersPage},
}; };
+33 -10
View File
@@ -252,7 +252,10 @@ pub async fn list_tenants() -> Result<Vec<TenantView>, ApiError> {
Ok(r.tenants) Ok(r.tenants)
} }
pub async fn update_profile(email: Option<&str>, full_name: Option<&str>) -> Result<Value, ApiError> { pub async fn update_profile(
email: Option<&str>,
full_name: Option<&str>,
) -> Result<Value, ApiError> {
let body = serde_json::json!({ "email": email, "full_name": full_name }); let body = serde_json::json!({ "email": email, "full_name": full_name });
patch_json("/profile", &body).await patch_json("/profile", &body).await
} }
@@ -425,8 +428,11 @@ pub async fn update_application(
} }
pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> { pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> {
let r: RotateSecretResponse = let r: RotateSecretResponse = post_json(
post_json(&format!("/applications/{id}/secret"), &serde_json::json!({})).await?; &format!("/applications/{id}/secret"),
&serde_json::json!({}),
)
.await?;
Ok(r.client_secret) Ok(r.client_secret)
} }
@@ -441,7 +447,9 @@ pub async fn get_application(id: &str) -> Result<ApplicationView, ApiError> {
.map_err(|e| ApiError::Other(e.to_string())) .map_err(|e| ApiError::Other(e.to_string()))
} }
pub async fn list_application_members(app_id: &str) -> Result<Vec<ApplicationMemberView>, ApiError> { pub async fn list_application_members(
app_id: &str,
) -> Result<Vec<ApplicationMemberView>, ApiError> {
let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?; let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?;
Ok(r.members) 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<String, ApiError> { pub async fn rotate_service_account_secret(id: &str) -> Result<String, ApiError> {
let r: Value = let r: Value = post_json(
post_json(&format!("/service-accounts/{id}/secret"), &serde_json::json!({})).await?; &format!("/service-accounts/{id}/secret"),
&serde_json::json!({}),
)
.await?;
Ok(r.get("raw_secret") Ok(r.get("raw_secret")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
@@ -569,7 +580,11 @@ pub async fn get_group(id: &str) -> Result<GroupDetailResponse, ApiError> {
get(&format!("/groups/{id}")).await get(&format!("/groups/{id}")).await
} }
pub async fn update_group(id: &str, name: &str, description: Option<&str>) -> Result<GroupView, ApiError> { pub async fn update_group(
id: &str,
name: &str,
description: Option<&str>,
) -> Result<GroupView, ApiError> {
let body = serde_json::json!({ "name": name, "description": description }); let body = serde_json::json!({ "name": name, "description": description });
let r: Value = patch_json(&format!("/groups/{id}"), &body).await?; let r: Value = patch_json(&format!("/groups/{id}"), &body).await?;
serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null)) 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<TenantView,
.map_err(|e| ApiError::Other(e.to_string())) .map_err(|e| ApiError::Other(e.to_string()))
} }
pub async fn update_tenant(id: &str, name: &str, slug: Option<&str>) -> Result<TenantView, ApiError> { pub async fn update_tenant(
id: &str,
name: &str,
slug: Option<&str>,
) -> Result<TenantView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug }); let body = serde_json::json!({ "name": name, "slug": slug });
let r: Value = patch_json(&format!("/tenants/{id}"), &body).await?; let r: Value = patch_json(&format!("/tenants/{id}"), &body).await?;
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null)) 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<Vec<ApplicationView>, ApiError> { pub async fn list_tenant_applications(tenant_id: &str) -> Result<Vec<ApplicationView>, ApiError> {
let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?; let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?;
serde_json::from_value(r.get("applications").cloned().unwrap_or(Value::Array(vec![]))) serde_json::from_value(
.map_err(|e| ApiError::Other(e.to_string())) r.get("applications")
.cloned()
.unwrap_or(Value::Array(vec![])),
)
.map_err(|e| ApiError::Other(e.to_string()))
} }
-2
View File
@@ -33,5 +33,3 @@ pub fn clear() {
SessionStorage::delete(ACCESS_KEY); SessionStorage::delete(ACCESS_KEY);
SessionStorage::delete(REFRESH_KEY); SessionStorage::delete(REFRESH_KEY);
} }
+7 -8
View File
@@ -1,9 +1,9 @@
//! Global application state via Dioxus signals / context. //! 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::components::navigation::registry::{NavigationItem, NavigationRegistry};
use crate::models::MeResponse;
use crate::routes::Route;
use crate::theme::{self, ThemeMode};
use dioxus::prelude::*; use dioxus::prelude::*;
/// Toast notification. /// Toast notification.
@@ -134,7 +134,7 @@ impl AppState {
permission: None, permission: None,
children: vec![], children: vec![],
}, },
] ],
); );
registry.register_section( registry.register_section(
"Security", "Security",
@@ -179,7 +179,7 @@ impl AppState {
permission: Some("roles:manage".into()), permission: Some("roles:manage".into()),
children: vec![], children: vec![],
}, },
] ],
); );
registry.register_section( registry.register_section(
"Audit & Logs", "Audit & Logs",
@@ -200,7 +200,7 @@ impl AppState {
permission: Some("audit:view".into()), permission: Some("audit:view".into()),
children: vec![], children: vec![],
}, },
] ],
); );
registry.register_section( registry.register_section(
"System", "System",
@@ -221,7 +221,7 @@ impl AppState {
permission: None, permission: None,
children: vec![], children: vec![],
}, },
] ],
); );
let state = Self { let state = Self {
@@ -276,4 +276,3 @@ impl AppState {
self.set_theme(next); self.set_theme(next);
} }
} }
+9 -2
View File
@@ -2,7 +2,10 @@
/// Initials from a username (up to 2 chars). /// Initials from a username (up to 2 chars).
pub fn initials(name: &str) -> String { 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() { if parts.is_empty() {
return "?".to_string(); return "?".to_string();
} }
@@ -109,7 +112,11 @@ pub fn check_and_clear_create_intent() -> bool {
}; };
let new_url = format!("{pathname}{new_search}"); let new_url = format!("{pathname}{new_search}");
let _ = window.history().and_then(|h| { 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; return true;