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
+1
-1
@@ -15,7 +15,7 @@ pub fn App() -> Element {
|
||||
if !matches!(auth(), BootstrapState::Initializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
match api::me().await {
|
||||
Ok(Some(me)) => {
|
||||
auth.set(BootstrapState::Authenticated(me));
|
||||
|
||||
@@ -71,8 +71,7 @@ pub fn Modal(
|
||||
title: String,
|
||||
open: bool,
|
||||
on_close: EventHandler<()>,
|
||||
#[props(default)]
|
||||
large: bool,
|
||||
#[props(default)] large: bool,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
if !open {
|
||||
@@ -112,10 +111,8 @@ pub fn ConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
open: bool,
|
||||
#[props(default = "Confirm".to_string())]
|
||||
confirm_label: String,
|
||||
#[props(default)]
|
||||
danger: bool,
|
||||
#[props(default = "Confirm".to_string())] confirm_label: String,
|
||||
#[props(default)] danger: bool,
|
||||
on_confirm: EventHandler<()>,
|
||||
on_cancel: EventHandler<()>,
|
||||
) -> Element {
|
||||
|
||||
@@ -22,10 +22,14 @@ pub fn Header() -> Element {
|
||||
let auth_state = state.auth.read();
|
||||
let can_create_user = auth_state.has_permission("users:create") || auth_state.is_adminish();
|
||||
let can_create_tenant = auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||
let can_create_app = auth_state.has_permission("applications:manage") || auth_state.is_adminish();
|
||||
let can_create_app =
|
||||
auth_state.has_permission("applications:manage") || auth_state.is_adminish();
|
||||
let can_create_role = auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||
let can_create_sa = auth_state.has_permission("service_accounts:manage") || auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||
let has_any_create = can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa;
|
||||
let can_create_sa = auth_state.has_permission("service_accounts:manage")
|
||||
|| auth_state.has_permission("roles:manage")
|
||||
|| auth_state.is_adminish();
|
||||
let has_any_create =
|
||||
can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa;
|
||||
drop(auth_state);
|
||||
|
||||
rsx! {
|
||||
@@ -201,8 +205,6 @@ pub fn Header() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> Element {
|
||||
let state = use_context::<AppState>();
|
||||
@@ -218,8 +220,7 @@ pub fn Sidebar() -> Element {
|
||||
};
|
||||
|
||||
let active = |r: &Route| -> bool {
|
||||
format!("{path:?}").split_whitespace().next()
|
||||
== format!("{r:?}").split_whitespace().next()
|
||||
format!("{path:?}").split_whitespace().next() == format!("{r:?}").split_whitespace().next()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::routes::Route;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NavigationItem {
|
||||
pub id: String,
|
||||
|
||||
@@ -14,10 +14,10 @@ pub fn DataTable(
|
||||
on_search: EventHandler<String>,
|
||||
search_value: String,
|
||||
search_placeholder: String,
|
||||
|
||||
|
||||
on_sort: EventHandler<String>,
|
||||
sort_key: String,
|
||||
|
||||
|
||||
on_page: EventHandler<usize>,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
@@ -25,7 +25,7 @@ pub fn DataTable(
|
||||
|
||||
// Row Actions or other toolbar slots
|
||||
#[props(default)] toolbar_actions: Option<Element>,
|
||||
|
||||
|
||||
// The actual table body and header will be rendered internally
|
||||
// We expect the caller to just give us the table rows
|
||||
children: Element,
|
||||
@@ -40,11 +40,11 @@ pub fn DataTable(
|
||||
oninput: move |v| on_search.call(v),
|
||||
placeholder: "{search_placeholder}",
|
||||
}
|
||||
|
||||
|
||||
div { class: "spacer" }
|
||||
|
||||
|
||||
{toolbar_actions}
|
||||
|
||||
|
||||
// Column Visibility
|
||||
div { class: "dropdown",
|
||||
button {
|
||||
@@ -68,7 +68,7 @@ pub fn DataTable(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
div { class: "table-wrap",
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
@@ -98,7 +98,7 @@ pub fn DataTable(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
crate::components::tables::Pagination {
|
||||
page: page,
|
||||
page_size: page_size,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Table helpers: search toolbar + pagination.
|
||||
pub mod datatable;
|
||||
pub use datatable::{DataTable, ColumnDef};
|
||||
pub use datatable::{ColumnDef, DataTable};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
|
||||
@@ -65,11 +65,7 @@ pub fn Card(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn StatCard(
|
||||
label: String,
|
||||
value: String,
|
||||
#[props(default)] hint: String,
|
||||
) -> Element {
|
||||
pub fn StatCard(label: String, value: String, #[props(default)] hint: String) -> Element {
|
||||
rsx! {
|
||||
div { class: "stat-card",
|
||||
div { class: "label", "{label}" }
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ mod utils;
|
||||
fn main() {
|
||||
// Surface panics in the browser console instead of a silent blank page.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Clear any pre-rendered loading banner in index.html (boot.js)
|
||||
|
||||
// Clear any pre-rendered loading banner in index.html (boot.js)
|
||||
// before Dioxus takes over `#main` and appends its root elements.
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(doc) = window.document() {
|
||||
|
||||
@@ -472,14 +472,9 @@ pub fn ApplicationDetailPage(id: String) -> Element {
|
||||
}
|
||||
let id = app_id_activity.clone();
|
||||
spawn(async move {
|
||||
let q = format!("resource_type=application&q={id}&limit=50");
|
||||
let q = format!("resource_type=application&resource_id={id}&limit=50");
|
||||
if let Ok(resp) = api::list_audit(&q).await {
|
||||
let filtered: Vec<_> = resp
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.resource_id.as_deref() == Some(id.as_str()))
|
||||
.collect();
|
||||
activity.set(filtered);
|
||||
activity.set(resp.entries);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+96
-75
@@ -69,13 +69,17 @@ pub fn AuditPage() -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { load.call(()); });
|
||||
use_effect(move || {
|
||||
load.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
("Dashboard".to_string(), Some(Route::DashboardPage {})),
|
||||
("Audit Log".to_string(), None),
|
||||
]}
|
||||
Breadcrumb {
|
||||
items: vec![
|
||||
("Dashboard".to_string(), Some(Route::DashboardPage {})),
|
||||
("Audit Log".to_string(), None),
|
||||
],
|
||||
}
|
||||
|
||||
div { class: "page-header",
|
||||
div {
|
||||
@@ -88,36 +92,77 @@ pub fn AuditPage() -> Element {
|
||||
r#type: "button",
|
||||
title: "Export filtered audit log records as CSV",
|
||||
onclick: move |_| {
|
||||
let mut parts = vec!["limit=5000".to_string(), "offset=0".to_string()];
|
||||
if !query().is_empty() { parts.push(format!("q={}", urlencoding_lite(&query()))); }
|
||||
if !action().is_empty() { parts.push(format!("action={}", urlencoding_lite(&action()))); }
|
||||
if !resource().is_empty() { parts.push(format!("resource_type={}", urlencoding_lite(&resource()))); }
|
||||
if severity() != "all" { parts.push(format!("severity={}", severity())); }
|
||||
if success() == "true" { parts.push("success=true".to_string()); }
|
||||
else if success() == "false" { parts.push("success=false".to_string()); }
|
||||
if !since().is_empty() { parts.push(format!("since={}", urlencoding_lite(&since()))); }
|
||||
if !until().is_empty() { parts.push(format!("until={}", urlencoding_lite(&until()))); }
|
||||
let mut parts = vec![
|
||||
"limit=5000".to_string(),
|
||||
"offset=0".to_string(),
|
||||
];
|
||||
|
||||
if !query().is_empty() {
|
||||
parts.push(format!("q={}", urlencoding_lite(&query())));
|
||||
}
|
||||
if !action().is_empty() {
|
||||
parts.push(format!("action={}", urlencoding_lite(&action())));
|
||||
}
|
||||
if !resource().is_empty() {
|
||||
parts.push(format!(
|
||||
"resource_type={}",
|
||||
urlencoding_lite(&resource())
|
||||
));
|
||||
}
|
||||
if severity() != "all" {
|
||||
parts.push(format!("severity={}", severity()));
|
||||
}
|
||||
if success() == "true" {
|
||||
parts.push("success=true".to_string());
|
||||
} else if success() == "false" {
|
||||
parts.push("success=false".to_string());
|
||||
}
|
||||
if !since().is_empty() {
|
||||
parts.push(format!("since={}", urlencoding_lite(&since())));
|
||||
}
|
||||
if !until().is_empty() {
|
||||
parts.push(format!("until={}", urlencoding_lite(&until())));
|
||||
}
|
||||
|
||||
let qs = parts.join("&");
|
||||
let export_url = format!("/api/v1/audit/export?{qs}");
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Ok(element) = document.create_element("a") {
|
||||
let _ = element.set_attribute("href", &export_url);
|
||||
let _ = element.set_attribute("download", "audit_export.csv");
|
||||
if let Ok(html_elem) = element.dyn_into::<web_sys::HtmlElement>() {
|
||||
html_elem.click();
|
||||
let _ = element.set_attribute(
|
||||
"download",
|
||||
"audit_export.csv",
|
||||
);
|
||||
|
||||
if let Ok(html_element) =
|
||||
element.dyn_into::<web_sys::HtmlElement>()
|
||||
{
|
||||
html_element.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = export_url;
|
||||
}
|
||||
},
|
||||
"Export CSV"
|
||||
}
|
||||
button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" }
|
||||
button {
|
||||
class: "btn btn-outline",
|
||||
r#type: "button",
|
||||
onclick: move |_| load.call(()),
|
||||
"Refresh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,19 +175,22 @@ pub fn AuditPage() -> Element {
|
||||
placeholder: "Search action, resource, IP…",
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;max-width:140px;",
|
||||
class: "form-control",
|
||||
style: "width:auto;max-width:140px;",
|
||||
placeholder: "Action",
|
||||
value: "{action()}",
|
||||
oninput: move |e| action.set(e.value()),
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;max-width:140px;",
|
||||
class: "form-control",
|
||||
style: "width:auto;max-width:140px;",
|
||||
placeholder: "Resource",
|
||||
value: "{resource()}",
|
||||
oninput: move |e| resource.set(e.value()),
|
||||
}
|
||||
select {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
value: "{severity()}",
|
||||
onchange: move |e| severity.set(e.value()),
|
||||
option { value: "all", "All severities" }
|
||||
@@ -151,7 +199,8 @@ pub fn AuditPage() -> Element {
|
||||
option { value: "critical", "Critical" }
|
||||
}
|
||||
select {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
value: "{success()}",
|
||||
onchange: move |e| success.set(e.value()),
|
||||
option { value: "all", "Success/Fail" }
|
||||
@@ -159,22 +208,28 @@ pub fn AuditPage() -> Element {
|
||||
option { value: "false", "Failure" }
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
r#type: "date",
|
||||
value: "{since()}",
|
||||
oninput: move |e| since.set(e.value()),
|
||||
title: "Since",
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
r#type: "date",
|
||||
value: "{until()}",
|
||||
oninput: move |e| until.set(e.value()),
|
||||
title: "Until",
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary", r#type: "button",
|
||||
onclick: move |_| { page.set(0); load.call(()); },
|
||||
class: "btn btn-primary",
|
||||
r#type: "button",
|
||||
onclick: move |_| {
|
||||
page.set(0);
|
||||
load.call(());
|
||||
},
|
||||
"Apply"
|
||||
}
|
||||
}
|
||||
@@ -210,17 +265,23 @@ pub fn AuditPage() -> Element {
|
||||
for e in d.entries {
|
||||
tr { key: "{e.id}",
|
||||
td { class: "mono", "{format_datetime(&e.created_at)}" }
|
||||
td { code { "{e.action}" } }
|
||||
td {
|
||||
code { "{e.action}" }
|
||||
}
|
||||
td {
|
||||
span { "{e.resource_type}" }
|
||||
if let Some(rid) = &e.resource_id {
|
||||
div { class: "mono text-muted", style: "font-size:11px;",
|
||||
div {
|
||||
class: "mono text-muted",
|
||||
style: "font-size:11px;",
|
||||
"{rid}"
|
||||
}
|
||||
}
|
||||
}
|
||||
td {
|
||||
span { class: "{severity_badge_class(&e.severity)}", "{e.severity}" }
|
||||
span { class: "{severity_badge_class(&e.severity)}",
|
||||
"{e.severity}"
|
||||
}
|
||||
}
|
||||
td {
|
||||
if e.success {
|
||||
@@ -232,9 +293,7 @@ pub fn AuditPage() -> Element {
|
||||
td { class: "mono",
|
||||
"{e.actor_user_id.as_deref().unwrap_or(\"—\")}"
|
||||
}
|
||||
td { class: "mono",
|
||||
"{e.ip_address.as_deref().unwrap_or(\"—\")}"
|
||||
}
|
||||
td { class: "mono", "{e.ip_address.as_deref().unwrap_or(\"—\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,9 +301,12 @@ pub fn AuditPage() -> Element {
|
||||
}
|
||||
Pagination {
|
||||
page: page(),
|
||||
page_size: page_size,
|
||||
page_size,
|
||||
total: d.total as usize,
|
||||
on_page: move |p| { page.set(p); load.call(()); },
|
||||
on_page: move |p| {
|
||||
page.set(p);
|
||||
load.call(());
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,44 +321,3 @@ fn urlencoding_lite(s: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn export_audit_csv(entries: &[crate::models::AuditEntry]) {
|
||||
let mut csv = String::from("id,created_at,action,resource_type,resource_id,severity,success,actor_user_id,target_user_id,ip_address,user_agent,metadata_json\n");
|
||||
for e in entries {
|
||||
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
let line = format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||
esc(&e.id),
|
||||
esc(&e.created_at),
|
||||
esc(&e.action),
|
||||
esc(&e.resource_type),
|
||||
esc(e.resource_id.as_deref().unwrap_or("")),
|
||||
esc(&e.severity),
|
||||
e.success,
|
||||
esc(e.actor_user_id.as_deref().unwrap_or("")),
|
||||
esc(e.target_user_id.as_deref().unwrap_or("")),
|
||||
esc(e.ip_address.as_deref().unwrap_or("")),
|
||||
esc(e.user_agent.as_deref().unwrap_or("")),
|
||||
esc(e.metadata_json.as_deref().unwrap_or("")),
|
||||
);
|
||||
csv.push_str(&line);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
let encoded = urlencoding_lite(&csv);
|
||||
let data_url = format!("data:text/csv;charset=utf-8,{}", encoded);
|
||||
if let Ok(element) = document.create_element("a") {
|
||||
let _ = element.set_attribute("href", &data_url);
|
||||
let _ = element.set_attribute("download", "audit_export.csv");
|
||||
if let Ok(html_elem) = element.dyn_into::<web_sys::HtmlElement>() {
|
||||
html_elem.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,9 @@ pub fn LoginPage() -> Element {
|
||||
nav.replace(Route::DashboardPage {});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = web_sys::console::warn_1(&format!("[nx9-auth-ui] Login failed: {e:?}").into());
|
||||
let _ = web_sys::console::warn_1(
|
||||
&format!("[nx9-auth-ui] Login failed: {e:?}").into(),
|
||||
);
|
||||
// Map API errors to a safe, non-enumerating message for creds.
|
||||
let msg = match e {
|
||||
api::ApiError::Unauthorized
|
||||
|
||||
@@ -32,7 +32,9 @@ pub fn DashboardPage() -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { load.call(()); });
|
||||
use_effect(move || {
|
||||
load.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![("Dashboard".to_string(), None)] }
|
||||
@@ -243,12 +245,6 @@ fn AdminSummary(admin: Value) -> Element {
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let health = admin
|
||||
.get("system_health")
|
||||
.and_then(|v| v.get("status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
rsx! {
|
||||
div { class: "mb-2",
|
||||
h2 { style: "margin-bottom: 0.75rem;", "Administrator overview" }
|
||||
|
||||
+30
-10
@@ -1,7 +1,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::TextInput;
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::{GroupView, UserView};
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -30,27 +30,45 @@ pub fn GroupsPage() -> Element {
|
||||
error.set(None);
|
||||
spawn(async move {
|
||||
match api::list_groups().await {
|
||||
Ok(list) => { groups.set(list); loading.set(false); }
|
||||
Err(e) => { error.set(Some(e.to_string())); loading.set(false); }
|
||||
Ok(list) => {
|
||||
groups.set(list);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let mut filtered: Vec<GroupView> = groups()
|
||||
.into_iter()
|
||||
.filter(|g| matches_query(&g.name, &query()) || g.description.as_deref().map(|d| matches_query(d, &query())).unwrap_or(false))
|
||||
.filter(|g| {
|
||||
matches_query(&g.name, &query())
|
||||
|| g.description
|
||||
.as_deref()
|
||||
.map(|d| matches_query(d, &query()))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
let sk = sort_key();
|
||||
filtered.sort_by(|a, b| match sk.as_str() {
|
||||
"members" => b.member_count.cmp(&a.member_count),
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
|
||||
let total = filtered.len();
|
||||
let page_items: Vec<GroupView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
|
||||
let page_items: Vec<GroupView> = filtered
|
||||
.into_iter()
|
||||
.skip(page() * page_size)
|
||||
.take(page_size)
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -207,7 +225,7 @@ pub fn GroupDetailPage(id: String) -> Element {
|
||||
let mut all_users = use_signal(Vec::<UserView>::new);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
|
||||
|
||||
let mut add_user_id = use_signal(String::new);
|
||||
|
||||
let mut edit_mode = use_signal(|| false);
|
||||
@@ -237,7 +255,9 @@ pub fn GroupDetailPage(id: String) -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner};
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::PermissionsResponse;
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -35,7 +35,9 @@ pub fn PermissionsPage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -60,7 +62,7 @@ pub fn PermissionsPage() -> Element {
|
||||
let groups: Vec<String> = d.groups.iter().map(|g| g.group.clone()).collect();
|
||||
let q = query();
|
||||
let gf = group_filter();
|
||||
|
||||
|
||||
// Flatten permissions for data table
|
||||
let mut all_perms: Vec<(String, crate::models::PermissionView)> = Vec::new();
|
||||
for g in &d.groups {
|
||||
@@ -72,14 +74,14 @@ pub fn PermissionsPage() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let sk = sort_key();
|
||||
all_perms.sort_by(|a, b| match sk.as_str() {
|
||||
"group" => a.0.cmp(&b.0).then_with(|| a.1.name.cmp(&b.1.name)),
|
||||
"description" => a.1.description.cmp(&b.1.description),
|
||||
_ => a.1.name.cmp(&b.1.name),
|
||||
});
|
||||
|
||||
|
||||
let total = all_perms.len();
|
||||
let page_items: Vec<_> = all_perms.into_iter().skip(page() * page_size).take(page_size).collect();
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ pub fn ProfilePage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::{Checkbox, TextInput};
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::{PermissionView, RoleView};
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -56,7 +56,8 @@ pub fn RolesPage() -> Element {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let can_create = state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||
let can_create =
|
||||
state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||
use_effect(move || {
|
||||
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||
show_create.set(true);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::TextInput;
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::components::widgets::StatusChip;
|
||||
use crate::models::ServiceAccountView;
|
||||
use crate::routes::Route;
|
||||
@@ -44,9 +44,12 @@ pub fn ServiceAccountsPage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let can_create = state.auth.read().has_permission("service_accounts:manage") || state.auth.read().is_adminish();
|
||||
let can_create = state.auth.read().has_permission("service_accounts:manage")
|
||||
|| state.auth.read().is_adminish();
|
||||
use_effect(move || {
|
||||
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||
show_create.set(true);
|
||||
@@ -69,9 +72,13 @@ pub fn ServiceAccountsPage() -> Element {
|
||||
"created" => b.created_at.cmp(&a.created_at),
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
|
||||
let total = filtered.len();
|
||||
let page_items: Vec<_> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
|
||||
let page_items: Vec<_> = filtered
|
||||
.into_iter()
|
||||
.skip(page() * page_size)
|
||||
.take(page_size)
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner};
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::SessionView;
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -9,12 +9,19 @@ use crate::utils::{format_datetime, matches_query};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn parse_browser(ua: &str) -> String {
|
||||
if ua.contains("Chrome") && !ua.contains("Edg") { "Chrome".to_string() }
|
||||
else if ua.contains("Firefox") { "Firefox".to_string() }
|
||||
else if ua.contains("Safari") && !ua.contains("Chrome") { "Safari".to_string() }
|
||||
else if ua.contains("Edg") { "Edge".to_string() }
|
||||
else if ua.is_empty() { "Unknown".to_string() }
|
||||
else { ua.chars().take(30).collect::<String>() + "..." }
|
||||
if ua.contains("Chrome") && !ua.contains("Edg") {
|
||||
"Chrome".to_string()
|
||||
} else if ua.contains("Firefox") {
|
||||
"Firefox".to_string()
|
||||
} else if ua.contains("Safari") && !ua.contains("Chrome") {
|
||||
"Safari".to_string()
|
||||
} else if ua.contains("Edg") {
|
||||
"Edge".to_string()
|
||||
} else if ua.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
ua.chars().take(30).collect::<String>() + "..."
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -32,13 +39,21 @@ pub fn SessionsPage() -> Element {
|
||||
error.set(None);
|
||||
spawn(async move {
|
||||
match api::list_sessions().await {
|
||||
Ok(r) => { sessions.set(r.sessions); loading.set(false); }
|
||||
Err(e) => { error.set(Some(e.to_string())); loading.set(false); }
|
||||
Ok(r) => {
|
||||
sessions.set(r.sessions);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let filtered: Vec<SessionView> = sessions()
|
||||
.into_iter()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
use crate::state::{AppState, ToastKind};
|
||||
use crate::theme::ThemeMode;
|
||||
use crate::services::api;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::TextInput;
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::{ApplicationView, AuditEntry, TenantView, UserView};
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -30,15 +30,24 @@ pub fn TenantsPage() -> Element {
|
||||
error.set(None);
|
||||
spawn(async move {
|
||||
match api::list_tenants().await {
|
||||
Ok(list) => { tenants.set(list); loading.set(false); }
|
||||
Err(e) => { error.set(Some(e.to_string())); loading.set(false); }
|
||||
Ok(list) => {
|
||||
tenants.set(list);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let can_create = state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||
let can_create =
|
||||
state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||
use_effect(move || {
|
||||
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||
show_create.set(true);
|
||||
@@ -59,7 +68,11 @@ pub fn TenantsPage() -> Element {
|
||||
list
|
||||
};
|
||||
let total = filtered.len();
|
||||
let page_items: Vec<TenantView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
|
||||
let page_items: Vec<TenantView> = filtered
|
||||
.into_iter()
|
||||
.skip(page() * page_size)
|
||||
.take(page_size)
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -290,7 +303,7 @@ pub fn TenantDetailPage(id: String) -> Element {
|
||||
let load_activity = use_callback(move |_: ()| {
|
||||
let id = tenant_id_act.clone();
|
||||
spawn(async move {
|
||||
let q = format!("resource_type=tenant&q={id}&limit=50");
|
||||
let q = format!("resource_type=tenant&resource_id={id}&limit=50");
|
||||
if let Ok(resp) = api::list_audit(&q).await {
|
||||
activity.set(resp.entries);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ pub fn TokensPage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let filtered: Vec<TokenView> = tokens()
|
||||
.into_iter()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::{PasswordInput, TextInput};
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::components::widgets::StatusChip;
|
||||
use crate::models::UserView;
|
||||
use crate::routes::Route;
|
||||
@@ -47,9 +47,12 @@ pub fn UsersPage() -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let can_create = state.auth.read().has_permission("users:create") || state.auth.read().is_adminish();
|
||||
let can_create =
|
||||
state.auth.read().has_permission("users:create") || state.auth.read().is_adminish();
|
||||
use_effect(move || {
|
||||
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||
show_create.set(true);
|
||||
@@ -318,8 +321,7 @@ pub fn UserDetailPage(id: String) -> Element {
|
||||
let mut user = use_signal(|| Option::<UserView>::None);
|
||||
let mut roles = use_signal(Vec::<crate::models::RoleView>::new);
|
||||
let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new);
|
||||
let mut user_apps =
|
||||
use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
|
||||
let mut user_apps = use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut new_pass = use_signal(String::new);
|
||||
@@ -352,7 +354,9 @@ pub fn UserDetailPage(id: String) -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::pages::{
|
||||
audit::AuditPage,
|
||||
auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
|
||||
dashboard::DashboardPage,
|
||||
groups::{GroupsPage, GroupDetailPage},
|
||||
groups::{GroupDetailPage, GroupsPage},
|
||||
not_found::NotFoundPage,
|
||||
permissions::PermissionsPage,
|
||||
profile::ProfilePage,
|
||||
@@ -15,7 +15,7 @@ use crate::pages::{
|
||||
service_accounts::ServiceAccountsPage,
|
||||
sessions::SessionsPage,
|
||||
settings::SettingsPage,
|
||||
tenants::{TenantsPage, TenantDetailPage},
|
||||
tenants::{TenantDetailPage, TenantsPage},
|
||||
tokens::TokensPage,
|
||||
users::{UserDetailPage, UsersPage},
|
||||
};
|
||||
|
||||
+33
-10
@@ -252,7 +252,10 @@ pub async fn list_tenants() -> Result<Vec<TenantView>, ApiError> {
|
||||
Ok(r.tenants)
|
||||
}
|
||||
|
||||
pub async fn update_profile(email: Option<&str>, full_name: Option<&str>) -> Result<Value, ApiError> {
|
||||
pub async fn update_profile(
|
||||
email: Option<&str>,
|
||||
full_name: Option<&str>,
|
||||
) -> Result<Value, ApiError> {
|
||||
let body = serde_json::json!({ "email": email, "full_name": full_name });
|
||||
patch_json("/profile", &body).await
|
||||
}
|
||||
@@ -425,8 +428,11 @@ pub async fn update_application(
|
||||
}
|
||||
|
||||
pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> {
|
||||
let r: RotateSecretResponse =
|
||||
post_json(&format!("/applications/{id}/secret"), &serde_json::json!({})).await?;
|
||||
let r: RotateSecretResponse = post_json(
|
||||
&format!("/applications/{id}/secret"),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(r.client_secret)
|
||||
}
|
||||
|
||||
@@ -441,7 +447,9 @@ pub async fn get_application(id: &str) -> Result<ApplicationView, ApiError> {
|
||||
.map_err(|e| ApiError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn list_application_members(app_id: &str) -> Result<Vec<ApplicationMemberView>, ApiError> {
|
||||
pub async fn list_application_members(
|
||||
app_id: &str,
|
||||
) -> Result<Vec<ApplicationMemberView>, ApiError> {
|
||||
let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?;
|
||||
Ok(r.members)
|
||||
}
|
||||
@@ -516,8 +524,11 @@ pub async fn delete_service_account(id: &str) -> Result<(), ApiError> {
|
||||
}
|
||||
|
||||
pub async fn rotate_service_account_secret(id: &str) -> Result<String, ApiError> {
|
||||
let r: Value =
|
||||
post_json(&format!("/service-accounts/{id}/secret"), &serde_json::json!({})).await?;
|
||||
let r: Value = post_json(
|
||||
&format!("/service-accounts/{id}/secret"),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(r.get("raw_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
@@ -569,7 +580,11 @@ pub async fn get_group(id: &str) -> Result<GroupDetailResponse, ApiError> {
|
||||
get(&format!("/groups/{id}")).await
|
||||
}
|
||||
|
||||
pub async fn update_group(id: &str, name: &str, description: Option<&str>) -> Result<GroupView, ApiError> {
|
||||
pub async fn update_group(
|
||||
id: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<GroupView, ApiError> {
|
||||
let body = serde_json::json!({ "name": name, "description": description });
|
||||
let r: Value = patch_json(&format!("/groups/{id}"), &body).await?;
|
||||
serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null))
|
||||
@@ -601,7 +616,11 @@ pub async fn create_tenant(name: &str, slug: Option<&str>) -> Result<TenantView,
|
||||
.map_err(|e| ApiError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn update_tenant(id: &str, name: &str, slug: Option<&str>) -> Result<TenantView, ApiError> {
|
||||
pub async fn update_tenant(
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: Option<&str>,
|
||||
) -> Result<TenantView, ApiError> {
|
||||
let body = serde_json::json!({ "name": name, "slug": slug });
|
||||
let r: Value = patch_json(&format!("/tenants/{id}"), &body).await?;
|
||||
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
|
||||
@@ -639,6 +658,10 @@ pub async fn remove_tenant_user(tenant_id: &str, user_id: &str) -> Result<(), Ap
|
||||
|
||||
pub async fn list_tenant_applications(tenant_id: &str) -> Result<Vec<ApplicationView>, ApiError> {
|
||||
let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?;
|
||||
serde_json::from_value(r.get("applications").cloned().unwrap_or(Value::Array(vec![])))
|
||||
.map_err(|e| ApiError::Other(e.to_string()))
|
||||
serde_json::from_value(
|
||||
r.get("applications")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Array(vec![])),
|
||||
)
|
||||
.map_err(|e| ApiError::Other(e.to_string()))
|
||||
}
|
||||
@@ -33,5 +33,3 @@ pub fn clear() {
|
||||
SessionStorage::delete(ACCESS_KEY);
|
||||
SessionStorage::delete(REFRESH_KEY);
|
||||
}
|
||||
|
||||
|
||||
+7
-8
@@ -1,9 +1,9 @@
|
||||
//! Global application state via Dioxus signals / context.
|
||||
|
||||
use crate::models::MeResponse;
|
||||
use crate::theme::{self, ThemeMode};
|
||||
use crate::routes::Route;
|
||||
use crate::components::navigation::registry::{NavigationItem, NavigationRegistry};
|
||||
use crate::models::MeResponse;
|
||||
use crate::routes::Route;
|
||||
use crate::theme::{self, ThemeMode};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Toast notification.
|
||||
@@ -134,7 +134,7 @@ impl AppState {
|
||||
permission: None,
|
||||
children: vec![],
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
registry.register_section(
|
||||
"Security",
|
||||
@@ -179,7 +179,7 @@ impl AppState {
|
||||
permission: Some("roles:manage".into()),
|
||||
children: vec![],
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
registry.register_section(
|
||||
"Audit & Logs",
|
||||
@@ -200,7 +200,7 @@ impl AppState {
|
||||
permission: Some("audit:view".into()),
|
||||
children: vec![],
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
registry.register_section(
|
||||
"System",
|
||||
@@ -221,7 +221,7 @@ impl AppState {
|
||||
permission: None,
|
||||
children: vec![],
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
let state = Self {
|
||||
@@ -276,4 +276,3 @@ impl AppState {
|
||||
self.set_theme(next);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -2,7 +2,10 @@
|
||||
|
||||
/// Initials from a username (up to 2 chars).
|
||||
pub fn initials(name: &str) -> String {
|
||||
let parts: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|s| !s.is_empty()).collect();
|
||||
let parts: Vec<&str> = name
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
return "?".to_string();
|
||||
}
|
||||
@@ -109,7 +112,11 @@ pub fn check_and_clear_create_intent() -> bool {
|
||||
};
|
||||
let new_url = format!("{pathname}{new_search}");
|
||||
let _ = window.history().and_then(|h| {
|
||||
h.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url))
|
||||
h.replace_state_with_url(
|
||||
&wasm_bindgen::JsValue::NULL,
|
||||
"",
|
||||
Some(&new_url),
|
||||
)
|
||||
});
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in new issue
Block a user