feat: complete NX9-Auth management and integrity hardening

This commit is contained in:
thakares committed 2026-07-24 16:18:48 +05:30
1 parent dc5417334b
commit a969f9c571
59 files changed
+6508 -290

No files matched your search

+97 -57
View File
@@ -12,13 +12,22 @@ pub fn Header() -> Element {
let auth = state.auth;
let theme = state.theme;
let mut menu_open = use_signal(|| false);
let mut tenant_menu_open = use_signal(|| false);
let mut quick_create_open = use_signal(|| false);
let mut mobile = state.mobile_nav_open;
let username = auth().username().to_string();
let theme_icon = theme().icon();
let theme_label = theme().label();
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_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;
drop(auth_state);
rsx! {
header { class: "app-header",
button {
@@ -36,66 +45,97 @@ pub fn Header() -> Element {
span { "nx9-auth" }
}
// Tenant Switcher
div { class: "dropdown", style: "margin-left: 1rem;",
button {
class: "btn btn-ghost",
r#type: "button",
"aria-haspopup": "menu",
"aria-expanded": "{tenant_menu_open()}",
onclick: move |_| tenant_menu_open.set(!tenant_menu_open()),
span { class: "icon", "🏢" }
span { style: "margin-left: 0.4rem; font-weight: 500;",
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
}
span { style: "margin-left: 0.25rem; opacity: 0.6;", "▾" }
// Tenant Indicator & Management Link
div { class: "tenant-badge", style: "margin-left: 1rem; display: flex; align-items: center; gap: 0.5rem;",
span { class: "icon", "🏢" }
span { style: "font-weight: 500; font-size: 13px;",
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
}
if tenant_menu_open() {
div { class: "dropdown-menu", role: "menu",
button { class: "dropdown-item", r#type: "button", "Default Tenant" }
div { class: "dropdown-divider" }
Link {
class: "dropdown-item text-primary",
to: Route::TenantsPage {},
onclick: move |_| tenant_menu_open.set(false),
"Manage tenants…"
Link {
class: "btn btn-xs btn-ghost text-primary",
to: Route::TenantsPage {},
"Manage tenants…"
}
}
div { style: "flex: 1;" }
div { class: "header-actions",
if has_any_create {
div { class: "dropdown", style: "position: relative; margin-right: 0.5rem;",
button {
class: "btn btn-primary btn-sm",
r#type: "button",
title: "Quick Create",
"aria-haspopup": "menu",
"aria-expanded": "{quick_create_open()}",
onclick: move |_| quick_create_open.set(!quick_create_open()),
onkeydown: move |evt: KeyboardEvent| {
if evt.key() == Key::Escape {
quick_create_open.set(false);
}
},
"➕ New ▾"
}
if quick_create_open() {
div {
class: "dropdown-backdrop",
style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;",
onclick: move |_| quick_create_open.set(false),
}
div {
class: "dropdown-menu",
role: "menu",
style: "display: block; position: absolute; right: 0; top: 100%; z-index: 1000;",
onkeydown: move |evt: KeyboardEvent| {
if evt.key() == Key::Escape {
quick_create_open.set(false);
}
},
if can_create_user {
Link {
class: "dropdown-item",
to: "/users?create=1",
onclick: move |_| quick_create_open.set(false),
"👤 Create User"
}
}
if can_create_tenant {
Link {
class: "dropdown-item",
to: "/tenants?create=1",
onclick: move |_| quick_create_open.set(false),
"🏢 Create Tenant"
}
}
if can_create_app {
Link {
class: "dropdown-item",
to: "/applications?create=1",
onclick: move |_| quick_create_open.set(false),
"🚀 Create Application"
}
}
if can_create_role {
Link {
class: "dropdown-item",
to: "/roles?create=1",
onclick: move |_| quick_create_open.set(false),
"🛡️ Create Role"
}
}
if can_create_sa {
Link {
class: "dropdown-item",
to: "/service-accounts?create=1",
onclick: move |_| quick_create_open.set(false),
"🤖 Create Service Account"
}
}
}
}
}
}
}
// Global Search (Ctrl+K)
div { class: "search", style: "flex: 1; max-width: 400px; margin: 0 2rem;",
div { style: "position: relative;",
span { style: "position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); opacity: 0.5;", "🔍" }
input {
class: "form-control",
style: "padding-left: 2rem; width: 100%;",
r#type: "search",
placeholder: "Search… (Ctrl+K)",
"aria-label": "Global search",
}
}
}
div { class: "header-actions",
// Quick Create
button {
class: "btn btn-primary btn-sm",
style: "margin-right: 0.5rem;",
r#type: "button",
title: "Quick Create",
"➕ New"
}
// Notifications
button {
class: "btn btn-ghost btn-icon",
r#type: "button",
title: "Notifications",
"aria-label": "Notifications",
"🔔"
}
// Theme
button {
-9
View File
@@ -60,7 +60,6 @@ pub fn DataTable(
input {
r#type: "checkbox",
checked: col.visible,
// TODO: emit event
}
span { "{col.label}" }
}
@@ -68,14 +67,6 @@ pub fn DataTable(
}
}
}
// CSV Export (future ready)
button {
class: "btn btn-outline",
r#type: "button",
title: "Export to CSV (Coming Soon)",
"⬇ Export"
}
}
div { class: "table-wrap",
+56
View File
@@ -21,6 +21,8 @@ pub struct TenantsResponse {
pub struct UserView {
pub id: String,
pub username: String,
#[serde(default)]
pub tenant_id: Option<String>,
pub status: String,
#[serde(default)]
pub last_login_at: Option<String>,
@@ -156,6 +158,60 @@ pub struct RotateSecretResponse {
pub client_secret: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ApplicationMemberView {
pub id: String,
pub application_id: String,
pub user_id: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub user_status: String,
pub role: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ApplicationMembersResponse {
#[serde(default)]
pub members: Vec<ApplicationMemberView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct UserApplicationMembershipView {
pub id: String,
pub application_id: String,
pub user_id: String,
pub role: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub application_name: String,
#[serde(default)]
pub application_slug: String,
#[serde(default)]
pub application_enabled: bool,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub credentials_configured: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct UserApplicationsResponse {
#[serde(default)]
pub applications: Vec<UserApplicationMembershipView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ServiceAccountView {
pub id: String,
File diff suppressed because it is too large. Load diff
+73 -4
View File
@@ -84,10 +84,38 @@ pub fn AuditPage() -> Element {
}
div { class: "row",
button {
class: "btn btn-outline", r#type: "button",
title: "Export is a placeholder",
onclick: move |_| {},
"Export (soon)"
class: "btn btn-outline",
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 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();
}
}
}
}
}
},
"Export CSV"
}
button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" }
}
@@ -231,3 +259,44 @@ 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();
}
}
}
}
}
}
+4
View File
@@ -74,6 +74,10 @@ pub fn LoginPage() -> Element {
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
tenant_id: user_val
.get("tenant_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
status: user_val
.get("status")
.and_then(|v| v.as_str())
-10
View File
@@ -303,16 +303,6 @@ fn AdminSummary(admin: Value) -> Element {
}
}
}
div { class: "card mt-2",
div { class: "card-body row", style: "justify-content:space-between;",
span {
strong { "System health: " }
span { class: "badge badge-success", "{health}" }
}
span { class: "text-muted", "Placeholder probe — expand in a future release" }
}
}
}
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ pub fn ProfilePage() -> Element {
}
div { class: "card",
div { class: "card-header", h3 { "Coming soon" } }
div { class: "card-header", h3 { "Planned security features" } }
div { class: "card-body stack",
div { class: "row", style: "justify-content:space-between;",
span { "Avatar upload" }
+10 -1
View File
@@ -52,7 +52,16 @@ pub fn RolesPage() -> Element {
}
});
});
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();
use_effect(move || {
if can_create && crate::utils::check_and_clear_create_intent() {
show_create.set(true);
}
});
let mut filtered: Vec<RoleView> = roles()
.into_iter()
+7
View File
@@ -46,6 +46,13 @@ pub fn ServiceAccountsPage() -> Element {
});
use_effect(move || { reload.call(()); });
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);
}
});
let mut filtered: Vec<_> = items()
.into_iter()
.filter(|s| {
+440 -41
View File
@@ -2,7 +2,7 @@ use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, Loading
use crate::components::forms::TextInput;
use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef};
use crate::models::TenantView;
use crate::models::{ApplicationView, AuditEntry, TenantView, UserView};
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
@@ -38,6 +38,13 @@ pub fn TenantsPage() -> Element {
use_effect(move || { reload.call(()); });
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);
}
});
let filtered = {
let q = query();
let sk = sort_key();
@@ -227,21 +234,44 @@ pub fn TenantDetailPage(id: String) -> Element {
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut tab = use_signal(|| "overview".to_string());
let mut edit_name = use_signal(String::new);
let mut edit_slug = use_signal(String::new);
let mut tenant_users = use_signal(Vec::<UserView>::new);
let mut all_users = use_signal(Vec::<UserView>::new);
let mut tenant_apps = use_signal(Vec::<ApplicationView>::new);
let mut activity = use_signal(Vec::<AuditEntry>::new);
let mut user_query = use_signal(String::new);
let mut show_assign_modal = use_signal(|| false);
let mut selected_assign_user_id = use_signal(String::new);
let mut confirm_reassign_user = use_signal(|| Option::<(UserView, String, String)>::None);
let mut confirm_move_default = use_signal(|| Option::<UserView>::None);
let mut confirm_delete = use_signal(|| false);
let tenant_id = id.clone();
let reload = use_callback(move |_: ()| {
let id = tenant_id.clone();
loading.set(true);
error.set(None);
spawn(async move {
match api::get_tenant(&id).await {
Ok(t) => {
edit_name.set(t.name.clone());
edit_slug.set(t.slug.clone());
tenant.set(Some(t));
if let Ok(users) = api::list_tenant_users(&id).await {
tenant_users.set(users);
}
if let Ok(users) = api::list_users().await {
all_users.set(users);
}
if let Ok(apps) = api::list_tenant_applications(&id).await {
tenant_apps.set(apps);
}
loading.set(false);
}
Err(e) => {
@@ -252,7 +282,20 @@ pub fn TenantDetailPage(id: String) -> Element {
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
let tenant_id_act = id.clone();
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");
if let Ok(resp) = api::list_audit(&q).await {
activity.set(resp.entries);
}
});
});
rsx! {
Breadcrumb { items: vec![
@@ -267,68 +310,424 @@ pub fn TenantDetailPage(id: String) -> Element {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if let Some(t) = tenant() {
{
let tid = t.id.clone();
let tid2 = t.id.clone();
let tid3 = t.id.clone();
let tid_save = t.id.clone();
let tid_assign = t.id.clone();
let tid_move_default = t.id.clone();
let tid_delete = t.id.clone();
let tenant_name = t.name.clone();
let is_default_tenant = t.id == "00000000-0000-0000-0000-000000000001";
let current_member_ids: Vec<String> = tenant_users().iter().map(|u| u.id.clone()).collect();
let assignable_users: Vec<UserView> = all_users()
.into_iter()
.filter(|u| !current_member_ids.contains(&u.id))
.collect();
let filtered_members: Vec<UserView> = {
let q = user_query();
tenant_users()
.into_iter()
.filter(|u| matches_query(&u.username, &q))
.collect()
};
rsx! {
div { class: "page-header",
div {
h1 { "{t.name}" }
p { class: "desc", "Tenant configuration and overview" }
p { class: "desc",
code { "{t.slug}" }
" · Tenant ID: "
code { "{t.id}" }
}
}
}
div { class: "grid-2",
div { class: "tabs", style: "display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap;",
button {
class: if tab() == "overview" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| tab.set("overview".into()),
"Overview"
}
button {
class: if tab() == "users" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| tab.set("users".into()),
"Users ({tenant_users().len()})"
}
button {
class: if tab() == "applications" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| tab.set("applications".into()),
"Applications ({tenant_apps().len()})"
}
button {
class: if tab() == "activity" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| {
tab.set("activity".into());
load_activity.call(());
},
"Activity"
}
}
// ── Tab: Overview ──────────────────────────────────────────
if tab() == "overview" {
div { class: "grid-2",
div { class: "card",
div { class: "card-header", h3 { "Tenant Details" } }
div { class: "card-body",
TextInput {
label: "Name",
value: edit_name(),
oninput: move |v| edit_name.set(v),
}
TextInput {
label: "Slug",
value: edit_slug(),
oninput: move |v| edit_slug.set(v),
}
p { class: "desc text-muted", style: "font-size:12px; margin-top:-0.5rem;",
"Leaving slug blank derives it automatically from name. Changing a tenant slug may affect existing references."
}
button {
class: "btn btn-primary mt-2",
r#type: "button",
onclick: move |_| {
let n = edit_name();
let s = edit_slug();
let tid = tid_save.clone();
spawn(async move {
match api::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await {
Ok(_) => {
state.toast(ToastKind::Success, "Tenant updated");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Save changes"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Danger Zone" } }
div { class: "card-body",
p { "Deleting a tenant is permanent and cannot be undone." }
button {
class: "btn btn-danger",
r#type: "button",
disabled: is_default_tenant,
onclick: move |_| confirm_delete.set(true),
"Delete Tenant"
}
}
}
}
}
// ── Tab: Users (Tenant User Assignment) ───────────────────
if tab() == "users" {
div { class: "card",
div { class: "card-header", h3 { "Tenant Details" } }
div { class: "card-header", style: "display:flex; justify-content:space-between; align-items:center;",
div {
h3 { "Tenant User Assignment" }
p { class: "desc", "Users assigned to this tenant owner. Every user has exactly one tenant owner." }
}
div { class: "row", style: "gap:0.5rem;",
button {
class: "btn btn-primary btn-sm",
r#type: "button",
onclick: move |_| {
selected_assign_user_id.set(String::new());
show_assign_modal.set(true);
},
"+ Assign User"
}
Link {
class: "btn btn-outline btn-sm",
to: Route::UsersPage {},
"+ Create User"
}
}
}
div { class: "card-body",
TextInput {
label: "Name",
value: edit_name(),
oninput: move |v| edit_name.set(v),
DataTable {
columns: vec![
ColumnDef { key: "username".into(), label: "Username".into(), sortable: true, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true },
ColumnDef { key: "created_at".into(), label: "Created".into(), sortable: true, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| user_query.set(v),
search_value: user_query(),
search_placeholder: "Filter tenant users…".to_string(),
on_sort: move |_| {},
sort_key: "username".to_string(),
on_page: move |_| {},
page: 0,
page_size: 100,
total: filtered_members.len(),
toolbar_actions: rsx! {
button { class: "btn btn-outline btn-sm", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for u in filtered_members {
{
let u_clone = u.clone();
rsx! {
tr { key: "{u.id}",
td {
Link {
to: Route::UserDetailPage { id: u.id.clone() },
strong { "{u.username}" }
}
div { class: "mono text-muted", style: "font-size:11px;", "{u.id}" }
}
td {
span { class: "badge badge-success", "{u.status}" }
}
td { "{u.created_at}" }
td { style: "text-align: right;",
if !is_default_tenant {
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| confirm_move_default.set(Some(u_clone.clone())),
"Move to Default Tenant"
}
} else {
span { class: "text-muted", style: "font-size:12px;", "Default Tenant Owner" }
}
}
}
}
}
}
}
TextInput {
label: "Slug",
value: edit_slug(),
oninput: move |v| edit_slug.set(v),
}
}
}
// ── Tab: Applications ─────────────────────────────────────
if tab() == "applications" {
div { class: "card",
div { class: "card-header",
h3 { "Tenant Applications" }
p { class: "desc", "Applications associated with this tenant." }
}
div { class: "card-body",
if tenant_apps().is_empty() {
EmptyState {
title: "No applications found".to_string(),
description: "No applications are currently associated with this tenant.".to_string(),
icon: "🚀".to_string(),
}
} else {
table { class: "table",
thead {
tr {
th { "Application" }
th { "Slug" }
th { "Client ID" }
th { "Status" }
th { style: "text-align: right;", "Actions" }
}
}
tbody {
for app in tenant_apps() {
tr { key: "{app.id}",
td {
Link {
to: Route::ApplicationDetailPage { id: app.id.clone() },
strong { "{app.name}" }
}
}
td { code { "{app.slug}" } }
td { code { "{app.client_id}" } }
td {
span {
class: if app.enabled { "badge badge-success" } else { "badge badge-secondary" },
if app.enabled { "Active" } else { "Disabled" }
}
}
td { style: "text-align: right;",
Link {
class: "btn btn-sm btn-outline",
to: Route::ApplicationDetailPage { id: app.id.clone() },
"View"
}
}
}
}
}
}
}
button {
class: "btn btn-primary mt-2",
r#type: "button",
onclick: move |_| {
let n = edit_name();
let s = edit_slug();
let tid = tid.clone();
}
}
}
// ── Tab: Activity ──────────────────────────────────────────
if tab() == "activity" {
div { class: "card",
div { class: "card-header",
h3 { "Tenant Audit Log" }
p { class: "desc", "Audit events scoped to this tenant." }
}
div { class: "card-body",
if activity().is_empty() {
EmptyState {
title: "No activity recorded".to_string(),
description: "No audit events found for this tenant.".to_string(),
icon: "📜".to_string(),
}
} else {
table { class: "table",
thead {
tr {
th { "Timestamp" }
th { "Action" }
th { "Actor" }
th { "Severity" }
th { "IP Address" }
}
}
tbody {
for act in activity() {
tr { key: "{act.id}",
td { "{act.created_at}" }
td { strong { "{act.action}" } }
td { "{act.actor_user_id.as_deref().unwrap_or(\"—\")}" }
td {
span { class: "badge badge-info", "{act.severity}" }
}
td { "{act.ip_address.as_deref().unwrap_or(\"—\")}" }
}
}
}
}
}
}
}
}
// ── Assign User Modal ──────────────────────────────────────
Modal {
title: "Assign User to Tenant".to_string(),
open: show_assign_modal(),
on_close: move |_| show_assign_modal.set(false),
p { class: "desc", "Select an existing NX9-Auth user to reassign to tenant \"{tenant_name}\"." }
div { class: "form-group", style: "margin-top:1rem;",
label { class: "form-label", "Select User" }
select {
class: "form-control",
value: selected_assign_user_id(),
onchange: move |evt: Event<FormData>| selected_assign_user_id.set(evt.value()),
option { value: "", "— Select an existing user —" }
for u in assignable_users.clone() {
option {
value: "{u.id}",
"{u.username} (currently in tenant: {u.tenant_id.as_deref().unwrap_or(\"default\")})"
}
}
}
}
div { class: "modal-footer", style: "margin-top:1.5rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button", onclick: move |_| show_assign_modal.set(false), "Cancel" }
button {
class: "btn btn-primary",
r#type: "button",
disabled: selected_assign_user_id().is_empty(),
onclick: move |_| {
let uid = selected_assign_user_id();
if let Some(target_u) = assignable_users.iter().find(|u| u.id == uid) {
let from = target_u.tenant_id.clone().unwrap_or_else(|| "default".to_string());
confirm_reassign_user.set(Some((target_u.clone(), from, tid_assign.clone())));
show_assign_modal.set(false);
}
},
"Assign User"
}
}
}
// ── Confirm Reassign User Dialog ─────────────────────────
if let Some((target_u, from_tenant, to_tenant_id)) = confirm_reassign_user() {
{
let u_id = target_u.id.clone();
let u_name = target_u.username.clone();
let to_tid = to_tenant_id.clone();
let dest_name = tenant_name.clone();
rsx! {
ConfirmDialog {
title: "Confirm Tenant Reassignment".to_string(),
message: format!(
"Reassign user \"{}\" from tenant \"{}\" to \"{}\"?",
u_name, from_tenant, dest_name
),
open: true,
confirm_label: "Reassign User".to_string(),
danger: false,
on_confirm: move |_| {
let uid = u_id.clone();
let tid = to_tid.clone();
confirm_reassign_user.set(None);
spawn(async move {
match api::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await {
match api::assign_tenant_user(&tid, &uid).await {
Ok(_) => {
state.toast(ToastKind::Success, "Tenant updated");
state.toast(ToastKind::Success, "User reassigned to tenant");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Save changes"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Danger Zone" } }
div { class: "card-body",
p { "Deleting a tenant is permanent and cannot be undone." }
button {
class: "btn btn-danger",
r#type: "button",
disabled: tid2 == "00000000-0000-0000-0000-000000000001",
onclick: move |_| confirm_delete.set(true),
"Delete Tenant"
on_cancel: move |_| confirm_reassign_user.set(None),
}
}
}
}
// ── Confirm Move to Default Tenant Dialog ────────────────
if let Some(target_u) = confirm_move_default() {
{
let u_id = target_u.id.clone();
let u_name = target_u.username.clone();
let tid_curr = tid_move_default.clone();
rsx! {
ConfirmDialog {
title: "Move to Default Tenant".to_string(),
message: format!(
"Reassign user \"{}\" from tenant \"{}\" to Default Tenant?",
u_name, tenant_name
),
open: true,
confirm_label: "Move to Default Tenant".to_string(),
danger: false,
on_confirm: move |_| {
let uid = u_id.clone();
let tid = tid_curr.clone();
confirm_move_default.set(None);
spawn(async move {
match api::remove_tenant_user(&tid, &uid).await {
Ok(_) => {
state.toast(ToastKind::Success, "User reassigned to Default Tenant");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
on_cancel: move |_| confirm_move_default.set(None),
}
}
}
}
// ── Confirm Delete Tenant Dialog ─────────────────────────
ConfirmDialog {
title: "Delete tenant".to_string(),
message: format!("Delete tenant \"{}\"? This cannot be undone.", t.name),
@@ -336,7 +735,7 @@ pub fn TenantDetailPage(id: String) -> Element {
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
let tid = tid3.clone();
let tid = tid_delete.clone();
spawn(async move {
match api::delete_tenant(&tid).await {
Ok(_) => {
+69
View File
@@ -49,6 +49,13 @@ pub fn UsersPage() -> Element {
use_effect(move || { reload.call(()); });
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);
}
});
let filtered = {
let q = query();
let sf = status_filter();
@@ -305,9 +312,14 @@ pub fn UsersPage() -> Element {
#[component]
pub fn UserDetailPage(id: String) -> Element {
let state = use_context::<AppState>();
let state_auth = state.auth;
let can_manage_apps = state_auth().has_permission("applications:manage");
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 error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut new_pass = use_signal(String::new);
@@ -326,6 +338,11 @@ pub fn UserDetailPage(id: String) -> Element {
if let Ok(ar) = api::list_roles().await {
all_roles.set(ar);
}
if let Ok(apps) = api::list_user_applications(&id).await {
user_apps.set(apps);
} else {
user_apps.set(Vec::new());
}
loading.set(false);
}
Err(e) => {
@@ -485,6 +502,58 @@ pub fn UserDetailPage(id: String) -> Element {
}
}
}
if can_manage_apps {
div { class: "card", style: "grid-column: 1 / -1;",
div { class: "card-header", h3 { "Applications" } }
div { class: "card-body",
p { class: "text-secondary", style: "font-size:0.9rem; margin-bottom:0.75rem;",
"Applications this user is assigned to. Membership roles are metadata only and do not change global RBAC."
}
if user_apps().is_empty() {
p { class: "text-muted", "Not assigned to any applications." }
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Application".into(), sortable: false, visible: true },
ColumnDef { key: "role".into(), label: "Membership Role".into(), sortable: false, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: false, visible: true },
ColumnDef { key: "assigned".into(), label: "Assigned".into(), sortable: false, visible: true },
],
on_search: |_| {},
search_value: "".to_string(),
search_placeholder: "".to_string(),
on_sort: |_| {},
sort_key: "".to_string(),
on_page: |_| {},
page: 0,
page_size: user_apps().len().max(1),
total: user_apps().len(),
for m in user_apps() {
tr { key: "{m.id}",
td {
Link {
to: Route::ApplicationDetailPage { id: m.application_id.clone() },
strong { "{m.application_name}" }
}
div { class: "text-muted", style: "font-size:0.8rem;",
code { "{m.application_slug}" }
}
}
td { span { class: "badge badge-accent", "{m.role}" } }
td {
StatusChip {
status: if m.enabled { "active".to_string() } else { "disabled".to_string() }
}
}
td { "{format_datetime(&m.created_at)}" }
}
}
}
}
}
}
}
}
}
}
+4 -1
View File
@@ -3,7 +3,7 @@
use crate::components::layout::AppLayout;
use crate::pages::{
about::AboutPage,
applications::ApplicationsPage,
applications::{ApplicationDetailPage, ApplicationsPage},
audit::AuditPage,
auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
dashboard::DashboardPage,
@@ -77,6 +77,9 @@ pub enum Route {
#[route("/applications")]
ApplicationsPage {},
#[route("/applications/:id")]
ApplicationDetailPage { id: String },
#[route("/service-accounts")]
ServiceAccountsPage {},
+76
View File
@@ -435,6 +435,58 @@ pub async fn delete_application(id: &str) -> Result<(), ApiError> {
Ok(())
}
pub async fn get_application(id: &str) -> Result<ApplicationView, ApiError> {
let r: Value = get(&format!("/applications/{id}")).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
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)
}
pub async fn add_application_member(
app_id: &str,
user_id: &str,
role: Option<&str>,
) -> Result<ApplicationMemberView, ApiError> {
let body = serde_json::json!({
"user_id": user_id,
"role": role,
});
let r: Value = post_json(&format!("/applications/{app_id}/members"), &body).await?;
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn update_application_member(
app_id: &str,
user_id: &str,
role: Option<&str>,
enabled: Option<bool>,
) -> Result<ApplicationMemberView, ApiError> {
let body = serde_json::json!({
"role": role,
"enabled": enabled,
});
let r: Value = patch_json(&format!("/applications/{app_id}/members/{user_id}"), &body).await?;
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn remove_application_member(app_id: &str, user_id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/applications/{app_id}/members/{user_id}")).await?;
Ok(())
}
pub async fn list_user_applications(
user_id: &str,
) -> Result<Vec<UserApplicationMembershipView>, ApiError> {
let r: UserApplicationsResponse = get(&format!("/users/{user_id}/applications")).await?;
Ok(r.applications)
}
// ── Service accounts ──────────────────────────────────────────────────────────
pub async fn list_service_accounts() -> Result<Vec<ServiceAccountView>, ApiError> {
@@ -566,3 +618,27 @@ pub async fn delete_tenant(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/tenants/{id}")).await?;
Ok(())
}
pub async fn list_tenant_users(tenant_id: &str) -> Result<Vec<UserView>, ApiError> {
let r: Value = get(&format!("/tenants/{tenant_id}/users")).await?;
serde_json::from_value(r.get("users").cloned().unwrap_or(Value::Array(vec![])))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn assign_tenant_user(tenant_id: &str, user_id: &str) -> Result<UserView, ApiError> {
let body = serde_json::json!({ "user_id": user_id });
let r: Value = post_json(&format!("/tenants/{tenant_id}/users"), &body).await?;
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn remove_tenant_user(tenant_id: &str, user_id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/tenants/{tenant_id}/users/{user_id}")).await?;
Ok(())
}
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()))
}
+44
View File
@@ -75,3 +75,47 @@ pub fn slugify(s: &str) -> String {
.collect::<Vec<_>>()
.join("-")
}
/// Check if location search contains exact `create=1` query parameter, and clear `create=1` from history URL while preserving other parameters.
pub fn check_and_clear_create_intent() -> bool {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
if let Ok(search) = window.location().search() {
let query_str = search.trim_start_matches('?');
let mut has_create = false;
let mut remaining_params = Vec::new();
for part in query_str.split('&') {
if part.is_empty() {
continue;
}
let mut key_val = part.splitn(2, '=');
let key = key_val.next().unwrap_or("");
let val = key_val.next().unwrap_or("");
if key == "create" && val == "1" {
has_create = true;
} else {
remaining_params.push(part);
}
}
if has_create {
if let Ok(pathname) = window.location().pathname() {
let new_search = if remaining_params.is_empty() {
String::new()
} else {
format!("?{}", remaining_params.join("&"))
};
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))
});
}
return true;
}
}
}
}
false
}