refactor: harden audit filtering and management UI
This commit is contained in:
1 parent
a969f9c571
commit
3d14061795
38 files changed
+507
-440
No files matched your search
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct AuditQuery {
|
||||
pub actor: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<String>,
|
||||
pub severity: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
@@ -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,
|
||||
|
||||
+10
-10
@@ -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();
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct AuditFilter {
|
||||
pub actor_user_id: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<String>,
|
||||
pub severity: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
|
||||
@@ -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<AuditLog, sqlx::Error> {
|
||||
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<Vec<AuditLog>, sqlx::Error> {
|
||||
@@ -64,91 +47,71 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
}
|
||||
|
||||
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, 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<i64, sqlx::Error> {
|
||||
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<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')))
|
||||
"#;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<i64, sqlx::Error> {
|
||||
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<AuditLog, sqlx::Error> {
|
||||
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<Vec<AuditLog>, sqlx::Error> {
|
||||
@@ -65,41 +47,46 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
}
|
||||
|
||||
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, 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<i64, sqlx::Error> {
|
||||
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<String> {
|
||||
filter
|
||||
.search
|
||||
.as_ref()
|
||||
.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.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<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')))
|
||||
"#;
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,6 @@ pub trait UsersRepository: Send + Sync {
|
||||
password_hash: &str,
|
||||
) -> Result<User, 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(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<AppState>();
|
||||
@@ -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! {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::routes::Route;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NavigationItem {
|
||||
pub id: String,
|
||||
|
||||
@@ -14,10 +14,10 @@ pub fn DataTable(
|
||||
on_search: EventHandler<String>,
|
||||
search_value: String,
|
||||
search_placeholder: String,
|
||||
|
||||
|
||||
on_sort: EventHandler<String>,
|
||||
sort_key: String,
|
||||
|
||||
|
||||
on_page: EventHandler<usize>,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
@@ -25,7 +25,7 @@ pub fn DataTable(
|
||||
|
||||
// Row Actions or other toolbar slots
|
||||
#[props(default)] toolbar_actions: Option<Element>,
|
||||
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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}" }
|
||||
|
||||
+2
-2
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+96
-75
@@ -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::<web_sys::HtmlElement>() {
|
||||
html_elem.click();
|
||||
let _ = element.set_attribute(
|
||||
"download",
|
||||
"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"
|
||||
}
|
||||
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::<web_sys::HtmlElement>() {
|
||||
html_elem.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+30
-10
@@ -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<GroupView> = 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<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! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -207,7 +225,7 @@ pub fn GroupDetailPage(id: String) -> Element {
|
||||
let mut all_users = use_signal(Vec::<UserView>::new);
|
||||
let mut error = use_signal(|| Option::<String>::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![
|
||||
|
||||
@@ -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<String> = 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();
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ pub fn ProfilePage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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::<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::<String>() + "..."
|
||||
}
|
||||
}
|
||||
|
||||
#[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<SessionView> = sessions()
|
||||
.into_iter()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<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! {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ pub fn TokensPage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let filtered: Vec<TokenView> = tokens()
|
||||
.into_iter()
|
||||
|
||||
@@ -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::<UserView>::None);
|
||||
let mut roles = use_signal(Vec::<crate::models::RoleView>::new);
|
||||
let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new);
|
||||
let mut user_apps =
|
||||
use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
|
||||
let mut user_apps = use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
|
||||
let mut error = use_signal(|| Option::<String>::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![
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
+33
-10
@@ -252,7 +252,10 @@ pub async fn list_tenants() -> Result<Vec<TenantView>, ApiError> {
|
||||
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 });
|
||||
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> {
|
||||
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<ApplicationView, ApiError> {
|
||||
.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?;
|
||||
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> {
|
||||
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<GroupDetailResponse, ApiError> {
|
||||
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 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<TenantView,
|
||||
.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 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<Vec<ApplicationView>, 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()))
|
||||
}
|
||||
@@ -33,5 +33,3 @@ pub fn clear() {
|
||||
SessionStorage::delete(ACCESS_KEY);
|
||||
SessionStorage::delete(REFRESH_KEY);
|
||||
}
|
||||
|
||||
|
||||
+7
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user