refactor: harden audit filtering and management UI

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

No files matched your search

+1 -1
View File
@@ -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));
+3 -6
View File
@@ -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 {
+8 -7
View File
@@ -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
View File
@@ -1,6 +1,5 @@
use crate::routes::Route;
#[derive(Clone, Debug, PartialEq)]
pub struct NavigationItem {
pub id: String,
+8 -8
View File
@@ -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 -1
View File
@@ -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::*;
+1 -5
View File
@@ -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
View File
@@ -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() {
+2 -7
View File
@@ -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
View File
@@ -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();
}
}
}
}
}
}
+3 -1
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -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![
+7 -5
View File
@@ -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();
+3 -1
View File
@@ -40,7 +40,9 @@ pub fn ProfilePage() -> Element {
}
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
rsx! {
Breadcrumb { items: vec![
+3 -2
View File
@@ -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);
+12 -5
View File
@@ -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![
+25 -10
View File
@@ -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 -1
View File
@@ -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]
+20 -7
View File
@@ -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);
}
+3 -1
View File
@@ -41,7 +41,9 @@ pub fn TokensPage() -> Element {
}
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
let filtered: Vec<TokenView> = tokens()
.into_iter()
+10 -6
View File
@@ -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![
+2 -2
View File
@@ -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
View File
@@ -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()))
}
-2
View File
@@ -33,5 +33,3 @@ pub fn clear() {
SessionStorage::delete(ACCESS_KEY);
SessionStorage::delete(REFRESH_KEY);
}
+7 -8
View File
@@ -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
View File
@@ -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;