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:
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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')))
|
||||||
|
"#;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')))
|
||||||
|
"#;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ pub fn App() -> Element {
|
|||||||
if !matches!(auth(), BootstrapState::Initializing) {
|
if !matches!(auth(), BootstrapState::Initializing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match api::me().await {
|
match api::me().await {
|
||||||
Ok(Some(me)) => {
|
Ok(Some(me)) => {
|
||||||
auth.set(BootstrapState::Authenticated(me));
|
auth.set(BootstrapState::Authenticated(me));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ pub fn DataTable(
|
|||||||
on_search: EventHandler<String>,
|
on_search: EventHandler<String>,
|
||||||
search_value: String,
|
search_value: String,
|
||||||
search_placeholder: String,
|
search_placeholder: String,
|
||||||
|
|
||||||
on_sort: EventHandler<String>,
|
on_sort: EventHandler<String>,
|
||||||
sort_key: String,
|
sort_key: String,
|
||||||
|
|
||||||
on_page: EventHandler<usize>,
|
on_page: EventHandler<usize>,
|
||||||
page: usize,
|
page: usize,
|
||||||
page_size: usize,
|
page_size: usize,
|
||||||
@@ -25,7 +25,7 @@ pub fn DataTable(
|
|||||||
|
|
||||||
// Row Actions or other toolbar slots
|
// Row Actions or other toolbar slots
|
||||||
#[props(default)] toolbar_actions: Option<Element>,
|
#[props(default)] toolbar_actions: Option<Element>,
|
||||||
|
|
||||||
// The actual table body and header will be rendered internally
|
// The actual table body and header will be rendered internally
|
||||||
// We expect the caller to just give us the table rows
|
// We expect the caller to just give us the table rows
|
||||||
children: Element,
|
children: Element,
|
||||||
@@ -40,11 +40,11 @@ pub fn DataTable(
|
|||||||
oninput: move |v| on_search.call(v),
|
oninput: move |v| on_search.call(v),
|
||||||
placeholder: "{search_placeholder}",
|
placeholder: "{search_placeholder}",
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "spacer" }
|
div { class: "spacer" }
|
||||||
|
|
||||||
{toolbar_actions}
|
{toolbar_actions}
|
||||||
|
|
||||||
// Column Visibility
|
// Column Visibility
|
||||||
div { class: "dropdown",
|
div { class: "dropdown",
|
||||||
button {
|
button {
|
||||||
@@ -68,7 +68,7 @@ pub fn DataTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "table-wrap",
|
div { class: "table-wrap",
|
||||||
table { class: "data-table",
|
table { class: "data-table",
|
||||||
thead {
|
thead {
|
||||||
@@ -98,7 +98,7 @@ pub fn DataTable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::components::tables::Pagination {
|
crate::components::tables::Pagination {
|
||||||
page: page,
|
page: page,
|
||||||
page_size: page_size,
|
page_size: page_size,
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|
||||||
|
|||||||
@@ -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
-2
@@ -13,8 +13,8 @@ mod utils;
|
|||||||
fn main() {
|
fn main() {
|
||||||
// Surface panics in the browser console instead of a silent blank page.
|
// Surface panics in the browser console instead of a silent blank page.
|
||||||
console_error_panic_hook::set_once();
|
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.
|
// before Dioxus takes over `#main` and appends its root elements.
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Some(doc) = window.document() {
|
if let Some(doc) = window.document() {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
+30
-10
@@ -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,27 +30,45 @@ 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();
|
||||||
filtered.sort_by(|a, b| match sk.as_str() {
|
filtered.sort_by(|a, b| match sk.as_str() {
|
||||||
"members" => b.member_count.cmp(&a.member_count),
|
"members" => b.member_count.cmp(&a.member_count),
|
||||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
});
|
});
|
||||||
|
|
||||||
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![
|
||||||
@@ -207,7 +225,7 @@ pub fn GroupDetailPage(id: String) -> Element {
|
|||||||
let mut all_users = use_signal(Vec::<UserView>::new);
|
let mut all_users = use_signal(Vec::<UserView>::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 add_user_id = use_signal(String::new);
|
let mut add_user_id = use_signal(String::new);
|
||||||
|
|
||||||
let mut edit_mode = use_signal(|| false);
|
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! {
|
rsx! {
|
||||||
Breadcrumb { items: vec![
|
Breadcrumb { items: vec![
|
||||||
|
|||||||
@@ -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![
|
||||||
@@ -60,7 +62,7 @@ pub fn PermissionsPage() -> Element {
|
|||||||
let groups: Vec<String> = d.groups.iter().map(|g| g.group.clone()).collect();
|
let groups: Vec<String> = d.groups.iter().map(|g| g.group.clone()).collect();
|
||||||
let q = query();
|
let q = query();
|
||||||
let gf = group_filter();
|
let gf = group_filter();
|
||||||
|
|
||||||
// Flatten permissions for data table
|
// Flatten permissions for data table
|
||||||
let mut all_perms: Vec<(String, crate::models::PermissionView)> = Vec::new();
|
let mut all_perms: Vec<(String, crate::models::PermissionView)> = Vec::new();
|
||||||
for g in &d.groups {
|
for g in &d.groups {
|
||||||
@@ -72,14 +74,14 @@ pub fn PermissionsPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sk = sort_key();
|
let sk = sort_key();
|
||||||
all_perms.sort_by(|a, b| match sk.as_str() {
|
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)),
|
"group" => a.0.cmp(&b.0).then_with(|| a.1.name.cmp(&b.1.name)),
|
||||||
"description" => a.1.description.cmp(&b.1.description),
|
"description" => a.1.description.cmp(&b.1.description),
|
||||||
_ => a.1.name.cmp(&b.1.name),
|
_ => a.1.name.cmp(&b.1.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
let total = all_perms.len();
|
let total = all_perms.len();
|
||||||
let page_items: Vec<_> = all_perms.into_iter().skip(page() * page_size).take(page_size).collect();
|
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! {
|
rsx! {
|
||||||
Breadcrumb { items: vec![
|
Breadcrumb { items: vec![
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -69,9 +72,13 @@ pub fn ServiceAccountsPage() -> Element {
|
|||||||
"created" => b.created_at.cmp(&a.created_at),
|
"created" => b.created_at.cmp(&a.created_at),
|
||||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
});
|
});
|
||||||
|
|
||||||
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![
|
||||||
|
|||||||
@@ -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,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]
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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
@@ -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()))
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user