feat: complete Phase 0 Enterprise IAM

This commit is contained in:
thakares committed 2026-07-21 15:26:16 +05:30
1 parent 3d2d291006
commit c2f5ba3f54
202 files changed
+21371 -1360

No files matched your search

Generated
+2312
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "nx9-auth-ui"
version = "0.1.0"
edition = "2021"
authors = ["NX9 Team", "Sunil Thakare"]
description = "Dioxus web UI for nx9-auth IAM"
license = "Apache-2.0 OR MIT"
publish = false
[dependencies]
dioxus = { version = "0.6", features = ["web", "router"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# json only — browser handles cookies via fetch_credentials_include()
reqwest = { version = "0.12", default-features = false, features = ["json"] }
gloo-storage = "0.3"
gloo-timers = { version = "0.3", features = ["futures"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
console_error_panic_hook = "0.1"
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement", "Navigator", "Clipboard"] }
js-sys = "0.3"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "wasmbind"] }
futures = "0.3"
+19
View File
@@ -0,0 +1,19 @@
[application]
name = "nx9-auth-ui"
default_platform = "web"
out_dir = "dist"
asset_dir = "assets"
[web.app]
title = "nx9-auth"
[web.watcher]
reload_html = true
watch_path = ["src", "assets"]
[web.resource]
style = ["assets/style.css"]
script = []
[web.resource.dev]
script = []
+41
View File
@@ -0,0 +1,41 @@
// Bootstrap the Dioxus WASM UI (external file so CSP can stay strict).
function setStatus(text) {
const el = document.getElementById("boot-loader");
if (el) {
el.innerHTML =
"<p style='padding:2rem;font-family:system-ui,sans-serif;color:#64748b'>" +
text +
"</p>";
}
}
setStatus("Loading nx9-auth UI…");
import init from "/nx9_auth_ui.js";
// Call with no args: wasm-bindgen resolves nx9_auth_ui_bg.wasm next to
// nx9_auth_ui.js via import.meta.url (avoids the deprecated string form).
init()
.then(() => {
console.info("[nx9-auth-ui] WASM started");
const el = document.getElementById("main");
if (el) el.dataset.dioxusMounted = "1";
const loader = document.getElementById("boot-loader");
if (loader) loader.remove();
})
.catch((err) => {
console.error("[nx9-auth-ui] Bootstrap failed", err);
const loader = document.getElementById("boot-loader");
if (loader) {
loader.innerHTML = `
<div style="max-width:40rem;margin:3rem auto;padding:1.5rem;font-family:system-ui,sans-serif">
<h1 style="font-size:1.25rem;margin:0 0 0.75rem">UI failed to start</h1>
<p>Please refresh the page or check the server logs.</p>
<pre style="white-space:pre-wrap;color:#b91c1c;background:#fef2f2;padding:1rem;border-radius:8px">${err && err.stack ? err.stack : String(err)}</pre>
</div>
`;
}
});
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#2563eb"/>
<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="white">N9</text>
</svg>

After

Width:  |  Height:  |  Size: 269 B

+857
View File
@@ -0,0 +1,857 @@
/* ─── NX9-Auth Enterprise UI ─────────────────────────────────────────────── */
:root {
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--header-h: 56px;
--sidebar-w: 240px;
--sidebar-w-collapsed: 64px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12);
--transition: 150ms ease;
/* Light palette (GitHub / Linear inspired) */
--bg: #f6f8fa;
--bg-elevated: #ffffff;
--bg-muted: #eef1f5;
--bg-hover: #e8ecf1;
--border: #d0d7de;
--border-muted: #e6ebf0;
--text: #1f2328;
--text-secondary: #656d76;
--text-muted: #8b949e;
--text-inverse: #ffffff;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-soft: rgba(37, 99, 235, 0.1);
--success: #1a7f37;
--success-soft: rgba(26, 127, 55, 0.12);
--warning: #9a6700;
--warning-soft: rgba(154, 103, 0, 0.12);
--danger: #cf222e;
--danger-soft: rgba(207, 34, 46, 0.12);
--info: #0969da;
--info-soft: rgba(9, 105, 218, 0.12);
--glass: rgba(255, 255, 255, 0.72);
--glass-border: rgba(255, 255, 255, 0.4);
--overlay: rgba(15, 23, 42, 0.45);
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.35);
}
[data-theme="dark"] {
--bg: #0d1117;
--bg-elevated: #161b22;
--bg-muted: #21262d;
--bg-hover: #2a3139;
--border: #30363d;
--border-muted: #21262d;
--text: #e6edf3;
--text-secondary: #aab2bb;
--text-muted: #7d8590;
--text-inverse: #0d1117;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, 0.15);
--warning: #d29922;
--warning-soft: rgba(210, 153, 34, 0.15);
--danger: #f85149;
--danger-soft: rgba(248, 81, 73, 0.15);
--info: #58a6ff;
--info-soft: rgba(88, 166, 255, 0.15);
--glass: rgba(22, 27, 34, 0.78);
--glass-border: rgba(48, 54, 61, 0.8);
--overlay: rgba(0, 0, 0, 0.6);
--focus-ring: 0 0 0 3px rgba(88, 166, 255, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1117;
--bg-elevated: #161b22;
--bg-muted: #21262d;
--bg-hover: #2a3139;
--border: #30363d;
--border-muted: #21262d;
--text: #e6edf3;
--text-secondary: #aab2bb;
--text-muted: #7d8590;
--text-inverse: #0d1117;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, 0.15);
--warning: #d29922;
--warning-soft: rgba(210, 153, 34, 0.15);
--danger: #f85149;
--danger-soft: rgba(248, 81, 73, 0.15);
--info: #58a6ff;
--info-soft: rgba(88, 166, 255, 0.15);
--glass: rgba(22, 27, 34, 0.78);
--glass-border: rgba(48, 54, 61, 0.8);
--overlay: rgba(0, 0, 0, 0.6);
--focus-ring: 0 0 0 3px rgba(88, 166, 255, 0.4);
}
}
/* ─── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, h3, h4 { margin: 0 0 0.5rem; font-weight: 600; letter-spacing: -0.01em; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.05rem; }
p { margin: 0 0 0.75rem; }
code, kbd {
font-family: var(--mono);
font-size: 0.9em;
background: var(--bg-muted);
padding: 0.1em 0.35em;
border-radius: 4px;
}
/* ─── Shell ──────────────────────────────────────────────────────────────── */
.app-shell {
display: grid;
grid-template-rows: var(--header-h) 1fr;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-areas:
"header header"
"sidebar main";
min-height: 100vh;
}
.app-shell.sidebar-collapsed {
grid-template-columns: var(--sidebar-w-collapsed) 1fr;
}
.app-header {
grid-area: header;
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
height: var(--header-h);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.app-header .brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
min-width: 140px;
}
.app-header .brand:hover { text-decoration: none; }
.app-header .brand-mark {
width: 28px; height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #7c3aed);
display: grid; place-items: center;
color: white; font-size: 13px; font-weight: 800;
}
.app-header .search {
flex: 1;
max-width: 420px;
}
.app-header .header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-sidebar {
grid-area: sidebar;
border-right: 1px solid var(--border);
background: var(--bg-elevated);
padding: 0.75rem 0.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-section {
margin: 0.75rem 0.5rem 0.35rem;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.nav-link {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
transition: background var(--transition), color var(--transition);
}
.nav-link:hover {
background: var(--bg-hover);
color: var(--text);
text-decoration: none;
}
.nav-link.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.nav-link .icon { width: 18px; text-align: center; flex-shrink: 0; }
.app-main {
grid-area: main;
display: flex;
flex-direction: column;
min-width: 0;
}
.main-inner {
flex: 1;
padding: 1.25rem 1.5rem 2rem;
max-width: 1280px;
width: 100%;
margin: 0 auto;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 1rem;
}
.breadcrumb a { color: var(--text-secondary); }
.breadcrumb .sep { opacity: 0.5; }
.breadcrumb .current { color: var(--text); font-weight: 500; }
.app-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-muted);
color: var(--text-muted);
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
/* ─── Auth layout ────────────────────────────────────────────────────────── */
.auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse at 20% 0%, var(--accent-soft), transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(124, 58, 237, 0.08), transparent 45%),
var(--bg);
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 2rem;
}
.auth-card .logo-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.auth-card h1 { font-size: 1.35rem; margin-bottom: 0.25rem; }
.auth-card .subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
/* ─── Components ─────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: var(--bg-muted);
color: var(--text);
font-weight: 500;
transition: background var(--transition), border-color var(--transition), opacity var(--transition);
white-space: nowrap;
}
.btn:hover { background: var(--bg-hover); }
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover { filter: brightness(1.05); }
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
.btn-outline {
background: transparent;
border-color: var(--border);
}
.btn-sm { padding: 0.25rem 0.6rem; font-size: 12px; }
.btn-icon {
width: 34px; height: 34px; padding: 0;
border-radius: var(--radius-sm);
}
.form-group { margin-bottom: 1rem; }
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 0.35rem;
color: var(--text);
}
.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 0.3rem; }
.form-error { font-size: 12px; color: var(--danger); margin-top: 0.3rem; }
.form-control {
width: 100%;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--text);
transition: border-color var(--transition), box-shadow var(--transition);
}
.form-control:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.form-control.is-invalid { border-color: var(--danger); }
.password-field {
position: relative;
display: flex;
align-items: center;
}
.password-field .form-control { padding-right: 2.5rem; }
.password-field .toggle {
position: absolute;
right: 0.35rem;
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.35rem 0.5rem;
border-radius: 4px;
}
.password-field .toggle:hover { color: var(--text); background: var(--bg-hover); }
.checkbox-row, .radio-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--border-muted);
}
.card-body { padding: 1.1rem; }
.card-footer {
padding: 0.75rem 1.1rem;
border-top: 1px solid var(--border-muted);
background: var(--bg-muted);
border-radius: 0 0 var(--radius) var(--radius);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.85rem;
margin-bottom: 1.25rem;
}
.stat-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: var(--shadow-sm);
}
.stat-card .label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stat-card .value {
font-size: 1.65rem;
font-weight: 700;
margin-top: 0.25rem;
letter-spacing: -0.02em;
}
.stat-card .hint { font-size: 12px; color: var(--text-secondary); margin-top: 0.25rem; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.page-header h1 { margin: 0; }
.page-header .desc { color: var(--text-secondary); margin: 0.25rem 0 0; }
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.toolbar .spacer { flex: 1; }
.toolbar .search-input { max-width: 280px; min-width: 180px; }
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.data-table th,
table.data-table td {
text-align: left;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--border-muted);
vertical-align: middle;
}
table.data-table th {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-muted);
white-space: nowrap;
user-select: none;
}
table.data-table th.sortable { cursor: pointer; }
table.data-table th.sortable:hover { color: var(--text); }
table.data-table tr:last-child td { border-bottom: none; }
table.data-table tbody tr:hover { background: var(--bg-hover); }
table.data-table .actions {
display: flex;
gap: 0.35rem;
justify-content: flex-end;
white-space: nowrap;
}
.badge, .chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: var(--bg-muted);
color: var(--text-secondary);
border: 1px solid transparent;
}
.badge-success, .chip-success { background: var(--success-soft); color: var(--success); }
.badge-warning, .chip-warning { background: var(--warning-soft); color: var(--warning); }
.badge-danger, .chip-danger { background: var(--danger-soft); color: var(--danger); }
.badge-info, .chip-info { background: var(--info-soft); color: var(--info); }
.badge-accent { background: var(--accent-soft); color: var(--accent); }
.avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #7c3aed);
color: #fff;
display: grid; place-items: center;
font-weight: 700; font-size: 12px;
flex-shrink: 0;
}
.avatar-sm { width: 24px; height: 24px; font-size: 10px; }
.avatar-lg { width: 48px; height: 48px; font-size: 16px; }
.empty-state, .error-state {
text-align: center;
padding: 2.5rem 1.5rem;
color: var(--text-secondary);
}
.empty-state .icon, .error-state .icon {
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.7;
}
.error-state { color: var(--danger); }
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
.spinner-lg { width: 36px; height: 36px; border-width: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }
.skeleton {
background: linear-gradient(90deg, var(--bg-muted) 25%, var(--bg-hover) 50%, var(--bg-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
border-radius: 4px;
height: 1em;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.loading-center {
display: grid;
place-items: center;
padding: 3rem;
gap: 0.75rem;
color: var(--text-muted);
}
/* ─── Modal / Dialog ─────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: var(--overlay);
display: grid;
place-items: center;
padding: 1rem;
animation: fadeIn 120ms ease;
}
.modal {
width: 100%;
max-width: 480px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-height: 90vh;
overflow: auto;
animation: slideUp 150ms ease;
}
.modal-lg { max-width: 640px; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--border-muted);
}
.modal-body { padding: 1.15rem; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.85rem 1.15rem;
border-top: 1px solid var(--border-muted);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ─── Toast ──────────────────────────────────────────────────────────────── */
.toast-stack {
position: fixed;
top: calc(var(--header-h) + 0.75rem);
right: 1rem;
z-index: 200;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 360px;
width: calc(100% - 2rem);
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.75rem 1rem;
display: flex;
gap: 0.65rem;
align-items: flex-start;
animation: slideUp 150ms ease;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.info { border-left: 3px solid var(--info); }
.toast.warning { border-left: 3px solid var(--warning); }
.toast .msg { flex: 1; font-size: 13px; }
.toast .close {
background: none; border: none; color: var(--text-muted); padding: 0;
line-height: 1; font-size: 16px;
}
/* ─── Dropdown / User menu ───────────────────────────────────────────────── */
.dropdown {
position: relative;
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
min-width: 200px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
z-index: 60;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.7rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text);
text-align: left;
text-decoration: none;
font-size: 13px;
}
.dropdown-item:hover {
background: var(--bg-hover);
text-decoration: none;
}
.dropdown-divider {
height: 1px;
background: var(--border-muted);
margin: 0.3rem 0;
}
/* ─── Permission matrix ──────────────────────────────────────────────────── */
.perm-group {
margin-bottom: 1.25rem;
}
.perm-group h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.perm-list {
display: grid;
gap: 0.35rem;
}
.perm-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--border-muted);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.perm-item code { color: var(--accent); }
/* ─── Pagination ─────────────────────────────────────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.75rem 0;
font-size: 13px;
color: var(--text-secondary);
}
.pagination .pages {
display: flex;
gap: 0.25rem;
}
/* ─── Grid helpers ───────────────────────────────────────────────────────── */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (max-width: 800px) {
.grid-2 { grid-template-columns: 1fr; }
.app-shell {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"main";
}
.app-sidebar {
display: none;
}
.app-shell.mobile-nav-open .app-sidebar {
display: flex;
position: fixed;
top: var(--header-h);
left: 0; bottom: 0;
width: min(280px, 85vw);
z-index: 40;
box-shadow: var(--shadow-lg);
}
}
.stack { display: flex; flex-direction: column; gap: 0.75rem; }
.row { display: flex; align-items: center; gap: 0.5rem; }
.gap-1 { gap: 0.5rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); }
.text-danger { color: var(--danger); }
.text-success { color: var(--success); }
.mono { font-family: var(--mono); font-size: 12px; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); border: 0;
}
/* ─── Alert ──────────────────────────────────────────────────────────────── */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
margin-bottom: 1rem;
font-size: 13px;
}
.alert-error { background: var(--danger-soft); border-color: transparent; color: var(--danger); }
.alert-success { background: var(--success-soft); border-color: transparent; color: var(--success); }
.alert-warning { background: var(--warning-soft); border-color: transparent; color: var(--warning); }
.alert-info { background: var(--info-soft); border-color: transparent; color: var(--info); }
/* ─── Token reveal ───────────────────────────────────────────────────────── */
.secret-box {
background: var(--bg-muted);
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 0.75rem;
font-family: var(--mono);
font-size: 12px;
word-break: break-all;
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.secret-box code { flex: 1; background: none; padding: 0; }
/* Toggle switch */
.toggle {
position: relative;
width: 40px;
height: 22px;
background: var(--bg-muted);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: background var(--transition);
flex-shrink: 0;
}
.toggle.on {
background: var(--accent);
border-color: var(--accent);
}
.toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: #fff;
transition: transform var(--transition);
box-shadow: var(--shadow-sm);
}
.toggle.on::after { transform: translateX(18px); }
+40
View File
@@ -0,0 +1,40 @@
// Bootstrap the Dioxus WASM UI (external file so CSP can stay strict).
function showError(err) {
console.error("[nx9-auth-ui]", err);
const el = document.getElementById("main");
if (!el) return;
const msg = err && err.stack ? err.stack : String(err);
el.innerHTML =
"<div style='max-width:40rem;margin:3rem auto;padding:1.5rem;font-family:system-ui,sans-serif'>" +
"<h1 style='font-size:1.25rem;margin:0 0 0.75rem'>UI failed to start</h1>" +
"<pre style='white-space:pre-wrap;color:#b91c1c;background:#fef2f2;padding:1rem;border-radius:8px'>" +
msg +
"</pre></div>";
}
function setStatus(text) {
const el = document.getElementById("boot-loader");
if (el) {
el.innerHTML =
"<p style='padding:2rem;font-family:system-ui,sans-serif;color:#64748b'>" +
text +
"</p>";
}
}
setStatus("Loading nx9-auth UI…");
import init from "/nx9_auth_ui.js";
// Call with no args: wasm-bindgen resolves nx9_auth_ui_bg.wasm next to
// nx9_auth_ui.js via import.meta.url (avoids the deprecated string form).
init()
.then(() => {
console.info("[nx9-auth-ui] WASM started");
const el = document.getElementById("main");
if (el) el.dataset.dioxusMounted = "1";
const loader = document.getElementById("boot-loader");
if (loader) loader.remove();
})
.catch(showError);
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#2563eb"/>
<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="white">N9</text>
</svg>

After

Width:  |  Height:  |  Size: 269 B

+857
View File
@@ -0,0 +1,857 @@
/* ─── NX9-Auth Enterprise UI ─────────────────────────────────────────────── */
:root {
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--header-h: 56px;
--sidebar-w: 240px;
--sidebar-w-collapsed: 64px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12);
--transition: 150ms ease;
/* Light palette (GitHub / Linear inspired) */
--bg: #f6f8fa;
--bg-elevated: #ffffff;
--bg-muted: #eef1f5;
--bg-hover: #e8ecf1;
--border: #d0d7de;
--border-muted: #e6ebf0;
--text: #1f2328;
--text-secondary: #656d76;
--text-muted: #8b949e;
--text-inverse: #ffffff;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-soft: rgba(37, 99, 235, 0.1);
--success: #1a7f37;
--success-soft: rgba(26, 127, 55, 0.12);
--warning: #9a6700;
--warning-soft: rgba(154, 103, 0, 0.12);
--danger: #cf222e;
--danger-soft: rgba(207, 34, 46, 0.12);
--info: #0969da;
--info-soft: rgba(9, 105, 218, 0.12);
--glass: rgba(255, 255, 255, 0.72);
--glass-border: rgba(255, 255, 255, 0.4);
--overlay: rgba(15, 23, 42, 0.45);
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.35);
}
[data-theme="dark"] {
--bg: #0d1117;
--bg-elevated: #161b22;
--bg-muted: #21262d;
--bg-hover: #2a3139;
--border: #30363d;
--border-muted: #21262d;
--text: #e6edf3;
--text-secondary: #aab2bb;
--text-muted: #7d8590;
--text-inverse: #0d1117;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, 0.15);
--warning: #d29922;
--warning-soft: rgba(210, 153, 34, 0.15);
--danger: #f85149;
--danger-soft: rgba(248, 81, 73, 0.15);
--info: #58a6ff;
--info-soft: rgba(88, 166, 255, 0.15);
--glass: rgba(22, 27, 34, 0.78);
--glass-border: rgba(48, 54, 61, 0.8);
--overlay: rgba(0, 0, 0, 0.6);
--focus-ring: 0 0 0 3px rgba(88, 166, 255, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1117;
--bg-elevated: #161b22;
--bg-muted: #21262d;
--bg-hover: #2a3139;
--border: #30363d;
--border-muted: #21262d;
--text: #e6edf3;
--text-secondary: #aab2bb;
--text-muted: #7d8590;
--text-inverse: #0d1117;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, 0.15);
--warning: #d29922;
--warning-soft: rgba(210, 153, 34, 0.15);
--danger: #f85149;
--danger-soft: rgba(248, 81, 73, 0.15);
--info: #58a6ff;
--info-soft: rgba(88, 166, 255, 0.15);
--glass: rgba(22, 27, 34, 0.78);
--glass-border: rgba(48, 54, 61, 0.8);
--overlay: rgba(0, 0, 0, 0.6);
--focus-ring: 0 0 0 3px rgba(88, 166, 255, 0.4);
}
}
/* ─── Reset ──────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, h3, h4 { margin: 0 0 0.5rem; font-weight: 600; letter-spacing: -0.01em; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.05rem; }
p { margin: 0 0 0.75rem; }
code, kbd {
font-family: var(--mono);
font-size: 0.9em;
background: var(--bg-muted);
padding: 0.1em 0.35em;
border-radius: 4px;
}
/* ─── Shell ──────────────────────────────────────────────────────────────── */
.app-shell {
display: grid;
grid-template-rows: var(--header-h) 1fr;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-areas:
"header header"
"sidebar main";
min-height: 100vh;
}
.app-shell.sidebar-collapsed {
grid-template-columns: var(--sidebar-w-collapsed) 1fr;
}
.app-header {
grid-area: header;
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
height: var(--header-h);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.app-header .brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
min-width: 140px;
}
.app-header .brand:hover { text-decoration: none; }
.app-header .brand-mark {
width: 28px; height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #7c3aed);
display: grid; place-items: center;
color: white; font-size: 13px; font-weight: 800;
}
.app-header .search {
flex: 1;
max-width: 420px;
}
.app-header .header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-sidebar {
grid-area: sidebar;
border-right: 1px solid var(--border);
background: var(--bg-elevated);
padding: 0.75rem 0.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-section {
margin: 0.75rem 0.5rem 0.35rem;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.nav-link {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
transition: background var(--transition), color var(--transition);
}
.nav-link:hover {
background: var(--bg-hover);
color: var(--text);
text-decoration: none;
}
.nav-link.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.nav-link .icon { width: 18px; text-align: center; flex-shrink: 0; }
.app-main {
grid-area: main;
display: flex;
flex-direction: column;
min-width: 0;
}
.main-inner {
flex: 1;
padding: 1.25rem 1.5rem 2rem;
max-width: 1280px;
width: 100%;
margin: 0 auto;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 1rem;
}
.breadcrumb a { color: var(--text-secondary); }
.breadcrumb .sep { opacity: 0.5; }
.breadcrumb .current { color: var(--text); font-weight: 500; }
.app-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-muted);
color: var(--text-muted);
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
/* ─── Auth layout ────────────────────────────────────────────────────────── */
.auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse at 20% 0%, var(--accent-soft), transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(124, 58, 237, 0.08), transparent 45%),
var(--bg);
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 2rem;
}
.auth-card .logo-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.auth-card h1 { font-size: 1.35rem; margin-bottom: 0.25rem; }
.auth-card .subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
/* ─── Components ─────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: var(--bg-muted);
color: var(--text);
font-weight: 500;
transition: background var(--transition), border-color var(--transition), opacity var(--transition);
white-space: nowrap;
}
.btn:hover { background: var(--bg-hover); }
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover { filter: brightness(1.05); }
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
.btn-outline {
background: transparent;
border-color: var(--border);
}
.btn-sm { padding: 0.25rem 0.6rem; font-size: 12px; }
.btn-icon {
width: 34px; height: 34px; padding: 0;
border-radius: var(--radius-sm);
}
.form-group { margin-bottom: 1rem; }
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 0.35rem;
color: var(--text);
}
.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 0.3rem; }
.form-error { font-size: 12px; color: var(--danger); margin-top: 0.3rem; }
.form-control {
width: 100%;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--text);
transition: border-color var(--transition), box-shadow var(--transition);
}
.form-control:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.form-control.is-invalid { border-color: var(--danger); }
.password-field {
position: relative;
display: flex;
align-items: center;
}
.password-field .form-control { padding-right: 2.5rem; }
.password-field .toggle {
position: absolute;
right: 0.35rem;
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.35rem 0.5rem;
border-radius: 4px;
}
.password-field .toggle:hover { color: var(--text); background: var(--bg-hover); }
.checkbox-row, .radio-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1.1rem;
border-bottom: 1px solid var(--border-muted);
}
.card-body { padding: 1.1rem; }
.card-footer {
padding: 0.75rem 1.1rem;
border-top: 1px solid var(--border-muted);
background: var(--bg-muted);
border-radius: 0 0 var(--radius) var(--radius);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.85rem;
margin-bottom: 1.25rem;
}
.stat-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: var(--shadow-sm);
}
.stat-card .label {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stat-card .value {
font-size: 1.65rem;
font-weight: 700;
margin-top: 0.25rem;
letter-spacing: -0.02em;
}
.stat-card .hint { font-size: 12px; color: var(--text-secondary); margin-top: 0.25rem; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.page-header h1 { margin: 0; }
.page-header .desc { color: var(--text-secondary); margin: 0.25rem 0 0; }
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.toolbar .spacer { flex: 1; }
.toolbar .search-input { max-width: 280px; min-width: 180px; }
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.data-table th,
table.data-table td {
text-align: left;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--border-muted);
vertical-align: middle;
}
table.data-table th {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-muted);
white-space: nowrap;
user-select: none;
}
table.data-table th.sortable { cursor: pointer; }
table.data-table th.sortable:hover { color: var(--text); }
table.data-table tr:last-child td { border-bottom: none; }
table.data-table tbody tr:hover { background: var(--bg-hover); }
table.data-table .actions {
display: flex;
gap: 0.35rem;
justify-content: flex-end;
white-space: nowrap;
}
.badge, .chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: var(--bg-muted);
color: var(--text-secondary);
border: 1px solid transparent;
}
.badge-success, .chip-success { background: var(--success-soft); color: var(--success); }
.badge-warning, .chip-warning { background: var(--warning-soft); color: var(--warning); }
.badge-danger, .chip-danger { background: var(--danger-soft); color: var(--danger); }
.badge-info, .chip-info { background: var(--info-soft); color: var(--info); }
.badge-accent { background: var(--accent-soft); color: var(--accent); }
.avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #7c3aed);
color: #fff;
display: grid; place-items: center;
font-weight: 700; font-size: 12px;
flex-shrink: 0;
}
.avatar-sm { width: 24px; height: 24px; font-size: 10px; }
.avatar-lg { width: 48px; height: 48px; font-size: 16px; }
.empty-state, .error-state {
text-align: center;
padding: 2.5rem 1.5rem;
color: var(--text-secondary);
}
.empty-state .icon, .error-state .icon {
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.7;
}
.error-state { color: var(--danger); }
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
.spinner-lg { width: 36px; height: 36px; border-width: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }
.skeleton {
background: linear-gradient(90deg, var(--bg-muted) 25%, var(--bg-hover) 50%, var(--bg-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
border-radius: 4px;
height: 1em;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.loading-center {
display: grid;
place-items: center;
padding: 3rem;
gap: 0.75rem;
color: var(--text-muted);
}
/* ─── Modal / Dialog ─────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: var(--overlay);
display: grid;
place-items: center;
padding: 1rem;
animation: fadeIn 120ms ease;
}
.modal {
width: 100%;
max-width: 480px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-height: 90vh;
overflow: auto;
animation: slideUp 150ms ease;
}
.modal-lg { max-width: 640px; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--border-muted);
}
.modal-body { padding: 1.15rem; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.85rem 1.15rem;
border-top: 1px solid var(--border-muted);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ─── Toast ──────────────────────────────────────────────────────────────── */
.toast-stack {
position: fixed;
top: calc(var(--header-h) + 0.75rem);
right: 1rem;
z-index: 200;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 360px;
width: calc(100% - 2rem);
pointer-events: none;
}
.toast {
pointer-events: auto;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.75rem 1rem;
display: flex;
gap: 0.65rem;
align-items: flex-start;
animation: slideUp 150ms ease;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.info { border-left: 3px solid var(--info); }
.toast.warning { border-left: 3px solid var(--warning); }
.toast .msg { flex: 1; font-size: 13px; }
.toast .close {
background: none; border: none; color: var(--text-muted); padding: 0;
line-height: 1; font-size: 16px;
}
/* ─── Dropdown / User menu ───────────────────────────────────────────────── */
.dropdown {
position: relative;
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
min-width: 200px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
z-index: 60;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.7rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text);
text-align: left;
text-decoration: none;
font-size: 13px;
}
.dropdown-item:hover {
background: var(--bg-hover);
text-decoration: none;
}
.dropdown-divider {
height: 1px;
background: var(--border-muted);
margin: 0.3rem 0;
}
/* ─── Permission matrix ──────────────────────────────────────────────────── */
.perm-group {
margin-bottom: 1.25rem;
}
.perm-group h3 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.perm-list {
display: grid;
gap: 0.35rem;
}
.perm-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--border-muted);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.perm-item code { color: var(--accent); }
/* ─── Pagination ─────────────────────────────────────────────────────────── */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.75rem 0;
font-size: 13px;
color: var(--text-secondary);
}
.pagination .pages {
display: flex;
gap: 0.25rem;
}
/* ─── Grid helpers ───────────────────────────────────────────────────────── */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (max-width: 800px) {
.grid-2 { grid-template-columns: 1fr; }
.app-shell {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"main";
}
.app-sidebar {
display: none;
}
.app-shell.mobile-nav-open .app-sidebar {
display: flex;
position: fixed;
top: var(--header-h);
left: 0; bottom: 0;
width: min(280px, 85vw);
z-index: 40;
box-shadow: var(--shadow-lg);
}
}
.stack { display: flex; flex-direction: column; gap: 0.75rem; }
.row { display: flex; align-items: center; gap: 0.5rem; }
.gap-1 { gap: 0.5rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.text-muted { color: var(--text-muted); }
.text-secondary { color: var(--text-secondary); }
.text-danger { color: var(--danger); }
.text-success { color: var(--success); }
.mono { font-family: var(--mono); font-size: 12px; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); border: 0;
}
/* ─── Alert ──────────────────────────────────────────────────────────────── */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
margin-bottom: 1rem;
font-size: 13px;
}
.alert-error { background: var(--danger-soft); border-color: transparent; color: var(--danger); }
.alert-success { background: var(--success-soft); border-color: transparent; color: var(--success); }
.alert-warning { background: var(--warning-soft); border-color: transparent; color: var(--warning); }
.alert-info { background: var(--info-soft); border-color: transparent; color: var(--info); }
/* ─── Token reveal ───────────────────────────────────────────────────────── */
.secret-box {
background: var(--bg-muted);
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 0.75rem;
font-family: var(--mono);
font-size: 12px;
word-break: break-all;
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.secret-box code { flex: 1; background: none; padding: 0; }
/* Toggle switch */
.toggle {
position: relative;
width: 40px;
height: 22px;
background: var(--bg-muted);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: background var(--transition);
flex-shrink: 0;
}
.toggle.on {
background: var(--accent);
border-color: var(--accent);
}
.toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: #fff;
transition: transform var(--transition);
box-shadow: var(--shadow-sm);
}
.toggle.on::after { transform: translateX(18px); }
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="nx9-auth — Identity and Access Management" />
<title>nx9-auth</title>
<!-- Ensure relative module/snippet/wasm URLs resolve from site root on SPA routes -->
<base href="/" />
<link rel="stylesheet" href="/assets/style.css" />
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
</head>
<body>
<div id="boot-loader"></div>
<div id="main">
<noscript>
<p style="padding:2rem;font-family:sans-serif">
JavaScript is required to run the nx9-auth UI.
</p>
</noscript>
</div>
<!-- External module: CSP script-src 'self' (no unsafe-inline) -->
<script type="module" src="/assets/boot.js"></script>
</body>
</html>
+1723
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
@@ -0,0 +1,9 @@
export function getMetaContents(meta_name) {
const selector = document.querySelector(`meta[name="${meta_name}"]`);
if (!selector) {
return null;
}
return selector.content;
}
@@ -0,0 +1,222 @@
function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;default:if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}}var truthy=function(val){return val==="true"||val===!0},isBoolAttr=function(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}};class BaseInterpreter{global;local;root;handler;resizeObserver;intersectionObserver;nodes;stack;templates;m;constructor(){}initialize(root,handler=null){this.global={},this.local={},this.root=root,this.nodes=[root],this.stack=[root],this.templates={},this.handler=handler,root.setAttribute("data-dioxus-id","0")}handleResizeEvent(entry){const target=entry.target;let event=new CustomEvent("resize",{bubbles:!1,detail:entry});target.dispatchEvent(event)}createResizeObserver(element){if(!this.resizeObserver)this.resizeObserver=new ResizeObserver((entries)=>{for(let entry of entries)this.handleResizeEvent(entry)});this.resizeObserver.observe(element)}removeResizeObserver(element){if(this.resizeObserver)this.resizeObserver.unobserve(element)}handleIntersectionEvent(entry){const target=entry.target;let event=new CustomEvent("visible",{bubbles:!1,detail:entry});target.dispatchEvent(event)}createIntersectionObserver(element){if(!this.intersectionObserver)this.intersectionObserver=new IntersectionObserver((entries)=>{for(let entry of entries)this.handleIntersectionEvent(entry)});this.intersectionObserver.observe(element)}removeIntersectionObserver(element){if(this.intersectionObserver)this.intersectionObserver.unobserve(element)}createListener(event_name,element,bubbles){if(event_name=="resize")this.createResizeObserver(element);else if(event_name=="visible")this.createIntersectionObserver(element);if(bubbles)if(this.global[event_name]===void 0)this.global[event_name]={active:1,callback:this.handler},this.root.addEventListener(event_name,this.handler);else this.global[event_name].active++;else{const id=element.getAttribute("data-dioxus-id");if(!this.local[id])this.local[id]={};element.addEventListener(event_name,this.handler)}}removeListener(element,event_name,bubbles){if(event_name=="resize")this.removeResizeObserver(element);else if(event_name=="visible")this.removeIntersectionObserver(element);else if(bubbles)this.removeBubblingListener(event_name);else this.removeNonBubblingListener(element,event_name)}removeBubblingListener(event_name){if(this.global[event_name].active--,this.global[event_name].active===0)this.root.removeEventListener(event_name,this.global[event_name].callback),delete this.global[event_name]}removeNonBubblingListener(element,event_name){const id=element.getAttribute("data-dioxus-id");if(delete this.local[id][event_name],Object.keys(this.local[id]).length===0)delete this.local[id];element.removeEventListener(event_name,this.handler)}removeAllNonBubblingListeners(element){const id=element.getAttribute("data-dioxus-id");delete this.local[id]}getNode(id){return this.nodes[id]}pushRoot(node){this.stack.push(node)}appendChildren(id,many){const root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k<many;k++)root.appendChild(els[k])}loadChild(ptr,len){let node=this.stack[this.stack.length-1],ptr_end=ptr+len;for(;ptr<ptr_end;ptr++){let end=this.m.getUint8(ptr);for(node=node.firstChild;end>0;end--)node=node.nextSibling}return node}saveTemplate(nodes,tmpl_id){this.templates[tmpl_id]=nodes}hydrate_node(hydrateNode,ids){const split=hydrateNode.getAttribute("data-node-hydration").split(","),id=ids[parseInt(split[0])];if(this.nodes[id]=hydrateNode,split.length>1){hydrateNode.listening=split.length-1,hydrateNode.setAttribute("data-dioxus-id",id.toString());for(let j=1;j<split.length;j++){const split2=split[j].split(":"),event_name=split2[0],bubbles=split2[1]==="1";this.createListener(event_name,hydrateNode,bubbles)}}}hydrate(ids,underNodes){for(let i=0;i<underNodes.length;i++){const under=underNodes[i];if(under instanceof HTMLElement){if(under.getAttribute("data-node-hydration"))this.hydrate_node(under,ids);const hydrateNodes=under.querySelectorAll("[data-node-hydration]");for(let i2=0;i2Line truncated
let bubbles,field,id,many,ns;
export class RawInterpreter extends BaseInterpreter {
constructor(r) {
super();
this.d=r;
this.m = null;
this.p = null;
this.ls = null;
this.t = null;
this.op = null;
this.e = null;
this.z = null;
this.metaflags = null;
this.u16buf=null;this.u16bufp=null;this.u32buf=null;this.u32bufp=null;this.u8buf=null;this.u8bufp=null;this.attr = [];
this.attr_cache_hit = null;
this.attr_cache_idx;
this.get_attr = function() {
this.attr_cache_idx = this.u8buf[this.u8bufp++];
if(this.attr_cache_idx & 128){
this.attr_cache_hit=this.s.substring(this.sp,this.sp+=this.u8buf[this.u8bufp++]);
this.attr[this.attr_cache_idx&4294967167]=this.attr_cache_hit;
return this.attr_cache_hit;
}
else{
return this.attr[this.attr_cache_idx&4294967167];
}
};this.el = [];
this.el_cache_hit = null;
this.el_cache_idx;
this.get_el = function() {
this.el_cache_idx = this.u8buf[this.u8bufp++];
if(this.el_cache_idx & 128){
this.el_cache_hit=this.s.substring(this.sp,this.sp+=this.u8buf[this.u8bufp++]);
this.el[this.el_cache_idx&4294967167]=this.el_cache_hit;
return this.el_cache_hit;
}
else{
return this.el[this.el_cache_idx&4294967167];
}
};this.evt = [];
this.evt_cache_hit = null;
this.evt_cache_idx;
this.get_evt = function() {
this.evt_cache_idx = this.u8buf[this.u8bufp++];
if(this.evt_cache_idx & 128){
this.evt_cache_hit=this.s.substring(this.sp,this.sp+=this.u8buf[this.u8bufp++]);
this.evt[this.evt_cache_idx&4294967167]=this.evt_cache_hit;
return this.evt_cache_hit;
}
else{
return this.evt[this.evt_cache_idx&4294967167];
}
};this.namespace = [];
this.namespace_cache_hit = null;
this.namespace_cache_idx;
this.get_namespace = function() {
this.namespace_cache_idx = this.u8buf[this.u8bufp++];
if(this.namespace_cache_idx & 128){
this.namespace_cache_hit=this.s.substring(this.sp,this.sp+=this.u8buf[this.u8bufp++]);
this.namespace[this.namespace_cache_idx&4294967167]=this.namespace_cache_hit;
return this.namespace_cache_hit;
}
else{
return this.namespace[this.namespace_cache_idx&4294967167];
}
};this.ns_cache = [];
this.ns_cache_cache_hit = null;
this.ns_cache_cache_idx;
this.get_ns_cache = function() {
this.ns_cache_cache_idx = this.u8buf[this.u8bufp++];
if(this.ns_cache_cache_idx & 128){
this.ns_cache_cache_hit=this.s.substring(this.sp,this.sp+=this.u8buf[this.u8bufp++]);
this.ns_cache[this.ns_cache_cache_idx&4294967167]=this.ns_cache_cache_hit;
return this.ns_cache_cache_hit;
}
else{
return this.ns_cache[this.ns_cache_cache_idx&4294967167];
}
};this.s = "";this.lsp = null;this.sp = null;this.sl = null;this.c = new TextDecoder();
}
update_memory(b){
this.m=new DataView(b.buffer)
}
run(){
this.metaflags=this.m.getUint32(this.d,true);
if((this.metaflags>>>6)&1){
this.ls=this.m.getUint32(this.d+6*4,true);
}
this.p=this.ls;
if ((this.metaflags>>>4)&1){
this.t = this.m.getUint32(this.d+4*4,true);
this.u16buf=new Uint16Array(this.m.buffer,this.t,((this.m.buffer.byteLength-this.t)-(this.m.buffer.byteLength-this.t)%2)/2);
}
this.u16bufp=0;if ((this.metaflags>>>3)&1){
this.t = this.m.getUint32(this.d+3*4,true);
this.u32buf=new Uint32Array(this.m.buffer,this.t,((this.m.buffer.byteLength-this.t)-(this.m.buffer.byteLength-this.t)%4)/4);
}
this.u32bufp=0;if ((this.metaflags>>>5)&1){
this.t = this.m.getUint32(this.d+5*4,true);
this.u8buf=new Uint8Array(this.m.buffer,this.t,((this.m.buffer.byteLength-this.t)-(this.m.buffer.byteLength-this.t)%1)/1);
}
this.u8bufp=0;if (this.metaflags&1){
this.lsp = this.m.getUint32(this.d+1*4,true);
}
if ((this.metaflags>>>2)&1) {
this.sl = this.m.getUint32(this.d+2*4,true);
if ((this.metaflags>>>1)&1) {
this.sp = this.lsp;
this.s = "";
this.e = this.sp + ((this.sl / 4) | 0) * 4;
while (this.sp < this.e) {
this.t = this.m.getUint32(this.sp, true);
this.s += String.fromCharCode(
this.t & 255,
(this.t & 65280) >> 8,
(this.t & 16711680) >> 16,
this.t >> 24
);
this.sp += 4;
}
while (this.sp < this.lsp + this.sl) {
this.s += String.fromCharCode(this.m.getUint8(this.sp++));
}
} else {
let buffer = new Uint8Array(this.m.buffer, this.lsp, this.sl);
// If the wasm buffer is a shared array buffer, we need to copy the data out before decoding https://github.com/DioxusLabs/dioxus/issues/2589
// Note: We intentionally don't use instanceof here because SharedArrayBuffer can be created even when SharedArrayBuffer is not defined...
if (this.m.buffer.constructor.name === "SharedArrayBuffer") {
let arrayBuffer = new ArrayBuffer(this.sl);
new Uint8Array(arrayBuffer).set(buffer);
buffer = arrayBuffer;
}
this.s = this.c.decode(buffer);
}
}
this.sp=0;
for(;;){
this.op=this.m.getUint32(this.p,true);
this.p+=4;
this.z=0;
while(this.z++<4){
switch(this.op&255){
case 0:{this.pushRoot(this.nodes[this.u32buf[this.u32bufp++]]);}break;case 1:{this.appendChildren(this.u32buf[this.u32bufp++], this.u16buf[this.u16bufp++]);}break;case 2:{this.stack.pop();}break;case 3:{const root = this.nodes[this.u32buf[this.u32bufp++]]; let els = this.stack.splice(this.stack.length-this.u16buf[this.u16bufp++]); if (root.listening) { this.removeAllNonBubblingListeners(root); } root.replaceWith(...els);}break;case 4:{let node = this.nodes[this.u32buf[this.u32bufp++]];node.after(...this.stack.splice(this.stack.length-this.u16buf[this.u16bufp++]));}break;case 5:{let node = this.nodes[this.u32buf[this.u32bufp++]];node.before(...this.stack.splice(this.stack.length-this.u16buf[this.u16bufp++]));}break;case 6:{let node = this.nodes[this.u32buf[this.u32bufp++]]; if (node !== undefined) { if (node.listening) { this.removeAllNonBubblingListeners(node); } node.remove(); }}break;case 7:{this.stack.push(document.createTextNode(this.s.substring(this.sp,this.sp+=this.u32buf[this.u32bufp++])));}break;case 8:{let node = document.createTextNode(this.s.substring(this.sp,this.sp+=this.u32buf[this.u32bufp++])); this.nodes[this.u32buf[this.u32bufp++]] = node; this.stack.push(node);}break;case 9:{let node = document.createComment('placeholder'); this.stack.push(node); this.nodes[this.u32buf[this.u32bufp++]] = node;}break;case 10:id=this.u32buf[this.u32bufp++];
const node = this.nodes[id];
if(node.listening){node.listening += 1;}else{node.listening = 1;}
node.setAttribute('data-dioxus-id', `${id}`);
this.createListener(this.get_evt(), node, this.u8buf[this.u8bufp++]);
break;case 11:{let node = this.nodes[this.u32buf[this.u32bufp++]]; node.listening -= 1; node.removeAttribute('data-dioxus-id'); this.removeListener(node, this.get_evt(), this.u8buf[this.u8bufp++]);}break;case 12:{this.nodes[this.u32buf[this.u32bufp++]].textContent = this.s.substring(this.sp,this.sp+=this.u32buf[this.u32bufp++]);}break;case 13:{let node = this.nodes[this.u32buf[this.u32bufp++]]; this.setAttributeInner(node, this.get_attr(), this.s.substring(this.sp,this.sp+=this.u32buf[this.u32bufp++]), this.get_ns_cache());}break;case 14:field=this.get_attr();ns=this.get_ns_cache();{
let node = this.nodes[this.u32buf[this.u32bufp++]];
if (!ns) {
switch (field) {
case "value":
node.value = "";
node.removeAttribute("value");
break;
case "checked":
node.checked = false;
break;
case "selected":
node.selected = false;
break;
case "dangerous_inner_html":
node.innerHTML = "";
break;
default:
node.removeAttribute(field);
break;
}
} else if (ns == "style") {
node.style.removeProperty(field);
} else {
node.removeAttributeNS(ns, field);
}
}break;case 15:{this.nodes[this.u32buf[this.u32bufp++]] = this.loadChild(this.u32buf[this.u32bufp++], this.u8buf[this.u8bufp++]);}break;case 16:{let els = this.stack.splice(this.stack.length - this.u16buf[this.u16bufp++]); let node = this.loadChild(this.u32buf[this.u32bufp++], this.u8buf[this.u8bufp++]); node.replaceWith(...els);}break;case 17:{let node = this.templates[this.u16buf[this.u16bufp++]][this.u16buf[this.u16bufp++]].cloneNode(true); this.nodes[this.u32buf[this.u32bufp++]] = node; this.stack.push(node);}break;case 18:many=this.u16buf[this.u16bufp++];{
let root = this.stack[this.stack.length-many-1];
let els = this.stack.splice(this.stack.length-many);
for (let k = 0; k < many; k++) {
root.appendChild(els[k]);
}
}break;case 19:{this.setAttributeInner(this.stack[this.stack.length-1], this.get_attr(), this.s.substring(this.sp,this.sp+=this.u32buf[this.u32bufp++]), this.get_ns_cache());}break;case 20:{let node = document.createComment('placeholder'); this.stack.push(node);}break;case 21:{this.stack.push(document.createElement(this.get_el()))}break;case 22:{this.stack.push(document.createElementNS(this.get_namespace(), this.get_el()))}break;case 23:{this.templates[this.u16buf[this.u16bufp++]] = this.stack.splice(this.stack.length-this.u16buf[this.u16bufp++]);}break;case 24:id=this.u32buf[this.u32bufp++];bubbles=this.u8buf[this.u8bufp++];
bubbles = bubbles == 1;
let this_node = this.nodes[id];
if(this_node.listening){
this_node.listening += 1;
} else {
this_node.listening = 1;
}
this_node.setAttribute('data-dioxus-id', `${id}`);
const event_name = this.get_evt();
// if this is a mounted listener, we send the event immediately
if (event_name === "mounted") {
window.ipc.postMessage(
this.sendSerializedEvent({
name: event_name,
element: id,
data: null,
bubbles,
})
);
} else {
this.createListener(event_name, this_node, bubbles, (event) => {
this.handler(event, event_name, bubbles);
});
}break;case 25:{this.nodes[this.u32buf[this.u32bufp++]] = this.loadChild((()=>{this.e=this.u8bufp+this.u32buf[this.u32bufp++];const final_array = this.u8buf.slice(this.u8bufp,this.e);this.u8bufp=this.e;return final_array;})());}break;case 26:{let els = this.stack.splice(this.stack.length - this.u16buf[this.u16bufp++]); let node = this.loadChild((()=>{this.e=this.u8bufp+this.u32buf[this.u32bufp++];const final_array = this.u8buf.slice(this.u8bufp,this.e);this.u8bufp=this.e;return final_array;})()); node.replaceWith(...els);}break;case 27:return true;
}
this.op>>>=8;
}
}
}
run_from_bytes(bytes){
this.d = 0;
this.update_memory(new Uint8Array(bytes))
this.run()
}
}
@@ -0,0 +1 @@
function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;default:if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}}var truthy=function(val){return val==="true"||val===!0},isBoolAttr=function(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}};function retrieveFormValues(form){const formData=new FormData(form),contents={};return formData.forEach((value,key)=>{if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}export{setAttributeInner,retrieveFormValues};
@@ -0,0 +1 @@
function register_rehydrate_chunk_for_streaming(callback){return register_rehydrate_chunk_for_streaming_debug(callback)}function register_rehydrate_chunk_for_streaming_debug(callback){window.hydration_callback=callback;for(let i=0;i<window.hydrate_queue.length;i++){const[id,data,debug_types,debug_locations]=window.hydrate_queue[i];window.hydration_callback(id,data,debug_types,debug_locations)}}export{register_rehydrate_chunk_for_streaming_debug,register_rehydrate_chunk_for_streaming};
@@ -0,0 +1 @@
function monkeyPatchConsole(ws){const console=window.console,log=console.log,info=console.info,warn=console.warn,error=console.error,debug=console.debug;console.log=function(...args){if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"log",messages:args}}));log.apply(console,args)},console.info=function(...args){if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"info",messages:args}}));info.apply(console,args)},console.warn=function(...args){if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"warn",messages:args}}));warn.apply(console,args)},console.error=function(...args){if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"error",messages:args}}));error.apply(console,args)},console.debug=function(...args){if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"debug",messages:args}}));debug.apply(console,args)}}export{monkeyPatchConsole};
+11
View File
@@ -0,0 +1,11 @@
export function get_form_data(form) {
let values = new Map();
const formData = new FormData(form);
for (let name of formData.keys()) {
values.set(name, formData.getAll(name));
}
return values;
}
+12
View File
@@ -0,0 +1,12 @@
export function get_select_data(select) {
let values = [];
for (let i = 0; i < select.options.length; i++) {
let option = select.options[i];
if (option.selected) {
values.push(option.value.toString());
}
}
return values;
}
@@ -0,0 +1 @@
class Channel{pending;waiting;constructor(){this.pending=[],this.waiting=[]}send(data){if(this.waiting.length>0){this.waiting.shift()(data);return}this.pending.push(data)}async recv(){return new Promise((resolve,_reject)=>{if(this.pending.length>0){resolve(this.pending.shift());return}this.waiting.push(resolve)})}}class WeakDioxusChannel{inner;constructor(channel){this.inner=new WeakRef(channel)}rustSend(data){let channel=this.inner.deref();if(channel)channel.rustSend(data)}async rustRecv(){let channel=this.inner.deref();if(channel)return await channel.rustRecv()}}class DioxusChannel{weak(){return new WeakDioxusChannel(this)}}class WebDioxusChannel extends DioxusChannel{js_to_rust;rust_to_js;owner;constructor(owner){super();this.owner=owner,this.js_to_rust=new Channel,this.rust_to_js=new Channel}weak(){return new WeakDioxusChannel(this)}async recv(){return await this.rust_to_js.recv()}send(data){this.js_to_rust.send(data)}rustSend(data){this.rust_to_js.send(data)}async rustRecv(){return await this.js_to_rust.recv()}}export{WebDioxusChannel};
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="nx9-auth — Identity and Access Management" />
<title>nx9-auth</title>
<!-- Ensure relative module/snippet/wasm URLs resolve from site root on SPA routes -->
<base href="/" />
<link rel="stylesheet" href="/assets/style.css" />
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
</head>
<body>
<div id="boot-loader"></div>
<div id="main">
<noscript>
<p style="padding:2rem;font-family:sans-serif">
JavaScript is required to run the nx9-auth UI.
</p>
</noscript>
</div>
<!-- External module: CSP script-src 'self' (no unsafe-inline) -->
<script type="module" src="/assets/boot.js"></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
//! Root application component.
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, BootstrapState};
use dioxus::prelude::*;
#[component]
pub fn App() -> Element {
let state = AppState::provide();
let mut auth = state.auth;
use_future(move || async move {
// Only run initialization once.
if !matches!(auth(), BootstrapState::Initializing) {
return;
}
match api::me().await {
Ok(Some(me)) => {
auth.set(BootstrapState::Authenticated(me));
}
Ok(None) => {
auth.set(BootstrapState::Anonymous);
}
Err(e) => {
auth.set(BootstrapState::Failed(e.to_string()));
}
}
});
rsx! {
ToastStack {}
match auth() {
BootstrapState::Initializing => {
rsx! {
div { class: "loading-center", style: "min-height: 100vh;",
div { class: "spinner spinner-lg" }
span { "Starting nx9-auth…" }
}
}
}
BootstrapState::Failed(err) => {
rsx! {
div { class: "loading-center", style: "min-height: 100vh; color: #b91c1c;",
h1 { "Initialization Error" }
pre { "{err}" }
}
}
}
BootstrapState::Authenticated(_) | BootstrapState::Anonymous => {
rsx! { Router::<Route> {} }
}
}
}
}
#[component]
fn ToastStack() -> Element {
let state = use_context::<AppState>();
let toasts = state.toasts;
rsx! {
div { class: "toast-stack", role: "status", "aria-live": "polite",
for t in toasts() {
{
let id = t.id;
let kind = t.kind.css();
let msg = t.message.clone();
rsx! {
div { class: "toast {kind}", key: "{id}",
div { class: "msg", "{msg}" }
button {
class: "close",
r#type: "button",
"aria-label": "Dismiss",
onclick: move |_| state.dismiss_toast(id),
"×"
}
}
}
}
}
}
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Feedback components: loading, empty, error, modal, confirmation.
use dioxus::prelude::*;
#[component]
pub fn LoadingSpinner(#[props(default)] label: String) -> Element {
let text = if label.is_empty() {
"Loading…".to_string()
} else {
label
};
rsx! {
div { class: "loading-center", role: "status", "aria-live": "polite",
div { class: "spinner spinner-lg" }
span { "{text}" }
}
}
}
#[component]
pub fn SkeletonLoader(#[props(default = 4u32)] rows: u32) -> Element {
rsx! {
div { class: "stack", "aria-hidden": "true",
for i in 0..rows {
div {
class: "skeleton",
key: "{i}",
style: "height: 1.25rem; width: {60 + (i * 7) % 40}%;",
}
}
}
}
}
#[component]
pub fn EmptyState(
title: String,
#[props(default)] description: String,
#[props(default = "📭".to_string())] icon: String,
) -> Element {
rsx! {
div { class: "empty-state",
div { class: "icon", "{icon}" }
h3 { "{title}" }
if !description.is_empty() {
p { "{description}" }
}
}
}
}
#[component]
pub fn ErrorState(message: String, on_retry: EventHandler<()>) -> Element {
rsx! {
div { class: "error-state",
div { class: "icon", "⚠" }
h3 { "Something went wrong" }
p { "{message}" }
button {
class: "btn btn-outline mt-1",
r#type: "button",
onclick: move |_| on_retry.call(()),
"Retry"
}
}
}
}
#[component]
pub fn Modal(
title: String,
open: bool,
on_close: EventHandler<()>,
#[props(default)]
large: bool,
children: Element,
) -> Element {
if !open {
return rsx! {};
}
let size = if large { "modal modal-lg" } else { "modal" };
rsx! {
div {
class: "modal-backdrop",
role: "dialog",
"aria-modal": "true",
"aria-label": "{title}",
onclick: move |_| on_close.call(()),
div {
class: "{size}",
onclick: move |e| e.stop_propagation(),
div { class: "modal-header",
h2 { "{title}" }
button {
class: "btn btn-ghost btn-icon",
r#type: "button",
"aria-label": "Close",
onclick: move |_| on_close.call(()),
"×"
}
}
div { class: "modal-body",
{children}
}
}
}
}
}
#[component]
pub fn ConfirmDialog(
title: String,
message: String,
open: bool,
#[props(default = "Confirm".to_string())]
confirm_label: String,
#[props(default)]
danger: bool,
on_confirm: EventHandler<()>,
on_cancel: EventHandler<()>,
) -> Element {
let confirm_cls = if danger {
"btn btn-danger"
} else {
"btn btn-primary"
};
rsx! {
Modal {
title: title,
open: open,
on_close: move |_| on_cancel.call(()),
p { "{message}" }
div { class: "modal-footer", style: "padding: 0; border: none; margin-top: 1rem;",
button {
class: "btn btn-outline",
r#type: "button",
onclick: move |_| on_cancel.call(()),
"Cancel"
}
button {
class: "{confirm_cls}",
r#type: "button",
onclick: move |_| on_confirm.call(()),
"{confirm_label}"
}
}
}
}
}
#[component]
pub fn Alert(kind: String, message: String) -> Element {
rsx! {
div { class: "alert alert-{kind}", role: "alert", "{message}" }
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Form controls.
use dioxus::prelude::*;
#[component]
pub fn TextInput(
#[props(default)] label: String,
#[props(default)] name: String,
value: String,
oninput: EventHandler<String>,
#[props(default)] placeholder: String,
#[props(default = "text".to_string())] input_type: String,
#[props(default)] required: bool,
#[props(default)] disabled: bool,
#[props(default)] error: String,
#[props(default)] hint: String,
#[props(default)] autocomplete: String,
) -> Element {
let invalid = if error.is_empty() { "" } else { "is-invalid" };
let id = if name.is_empty() {
"field".to_string()
} else {
name.clone()
};
let type_attr = input_type.clone();
rsx! {
div { class: "form-group",
if !label.is_empty() {
label { class: "form-label", r#for: "{id}", "{label}" }
}
input {
class: "form-control {invalid}",
id: "{id}",
name: "{name}",
r#type: "{type_attr}",
value: "{value}",
placeholder: "{placeholder}",
required: required,
disabled: disabled,
autocomplete: "{autocomplete}",
oninput: move |e| oninput.call(e.value()),
}
if !error.is_empty() {
div { class: "form-error", role: "alert", "{error}" }
} else if !hint.is_empty() {
div { class: "form-hint", "{hint}" }
}
}
}
}
#[component]
pub fn PasswordInput(
#[props(default)] label: String,
#[props(default)] name: String,
value: String,
oninput: EventHandler<String>,
#[props(default)] placeholder: String,
#[props(default)] required: bool,
#[props(default)] error: String,
#[props(default = "current-password".to_string())] autocomplete: String,
) -> Element {
let mut visible = use_signal(|| false);
let id = if name.is_empty() {
"password".to_string()
} else {
name.clone()
};
let input_type = if visible() { "text" } else { "password" };
let invalid = if error.is_empty() { "" } else { "is-invalid" };
let toggle_label = if visible() {
"Hide password"
} else {
"Show password"
};
let toggle_icon = if visible() { "🙈" } else { "👁" };
rsx! {
div { class: "form-group",
if !label.is_empty() {
label { class: "form-label", r#for: "{id}", "{label}" }
}
div { class: "password-field",
input {
class: "form-control {invalid}",
id: "{id}",
name: "{name}",
r#type: "{input_type}",
value: "{value}",
placeholder: "{placeholder}",
required: required,
autocomplete: "{autocomplete}",
oninput: move |e| oninput.call(e.value()),
}
button {
class: "toggle",
r#type: "button",
"aria-label": "{toggle_label}",
title: "{toggle_label}",
onclick: move |_| visible.set(!visible()),
"{toggle_icon}"
}
}
if !error.is_empty() {
div { class: "form-error", role: "alert", "{error}" }
}
}
}
}
#[component]
pub fn SelectInput(
#[props(default)] label: String,
value: String,
options: Vec<(String, String)>,
onchange: EventHandler<String>,
) -> Element {
rsx! {
div { class: "form-group",
if !label.is_empty() {
label { class: "form-label", "{label}" }
}
select {
class: "form-control",
value: "{value}",
onchange: move |e| onchange.call(e.value()),
for (val, lab) in options {
option { value: "{val}", selected: value == val, "{lab}" }
}
}
}
}
}
#[component]
pub fn Checkbox(label: String, checked: bool, onchange: EventHandler<bool>) -> Element {
rsx! {
label { class: "checkbox-row",
input {
r#type: "checkbox",
checked: checked,
onchange: move |e| onchange.call(e.checked()),
}
span { "{label}" }
}
}
}
#[component]
pub fn Toggle(
checked: bool,
onchange: EventHandler<bool>,
#[props(default)] label: String,
) -> Element {
let class = if checked { "toggle on" } else { "toggle" };
rsx! {
label { class: "row gap-1", style: "cursor:pointer;",
button {
class: "{class}",
r#type: "button",
role: "switch",
"aria-checked": "{checked}",
onclick: move |_| onchange.call(!checked),
}
if !label.is_empty() {
span { "{label}" }
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Application shell layout.
use crate::components::navigation::{Header, Sidebar};
use crate::routes::Route;
use crate::state::{AppState, BootstrapState};
use dioxus::prelude::*;
/// Persistent shell: header + sidebar + main content outlet.
#[component]
pub fn AppLayout() -> Element {
let state = use_context::<AppState>();
let auth = state.auth;
let nav = use_navigator();
// Redirect unauthenticated users to login — but only after session restore
// has finished. Never bounce while Unknown/Loading.
use_effect(move || {
if matches!(auth(), BootstrapState::Anonymous) {
nav.replace(Route::LoginPage {});
}
});
match auth() {
BootstrapState::Initializing | BootstrapState::Failed(_) => {
// Layout is typically hidden/minimal during initial load or hard failure.
return rsx! {
div { class: "app-loading" }
};
}
BootstrapState::Anonymous => {
return rsx! {
div { class: "loading-center", style: "min-height: 100vh;",
div { class: "spinner spinner-lg" }
span { "Redirecting to login…" }
}
};
}
BootstrapState::Authenticated(_) => {}
}
let shell_class = if (state.mobile_nav_open)() {
"app-shell mobile-nav-open"
} else if (state.sidebar_collapsed)() {
"app-shell sidebar-collapsed"
} else {
"app-shell"
};
rsx! {
div { class: "{shell_class}",
Header {}
Sidebar {}
div { class: "app-main",
div { class: "main-inner",
Outlet::<Route> {}
}
footer { class: "app-footer",
span { "nx9-auth · Identity & Access Management" }
span { class: "text-muted", "Pure Rust · Self-hosted · FOSS" }
}
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Reusable UI component library.
pub mod feedback;
pub mod forms;
pub mod layout;
pub mod navigation;
pub mod tables;
pub mod widgets;
+228
View File
@@ -0,0 +1,228 @@
//! Navigation pieces: header, sidebar, breadcrumb, user menu.
pub mod registry;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, BootstrapState};
use crate::utils::initials;
use dioxus::prelude::*;
#[component]
pub fn Header() -> Element {
let state = use_context::<AppState>();
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 mobile = state.mobile_nav_open;
let username = auth().username().to_string();
let theme_icon = theme().icon();
let theme_label = theme().label();
rsx! {
header { class: "app-header",
button {
class: "btn btn-ghost btn-icon",
r#type: "button",
"aria-label": "Toggle navigation",
style: "display: none;",
onclick: move |_| mobile.set(!mobile()),
"☰"
}
Link {
class: "brand",
to: Route::DashboardPage {},
div { class: "brand-mark", "N9" }
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;", "▾" }
}
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…"
}
}
}
}
// 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 {
class: "btn btn-ghost btn-icon",
r#type: "button",
title: "Theme: {theme_label}",
"aria-label": "Switch theme (current: {theme_label})",
onclick: move |_| state.cycle_theme(),
"{theme_icon}"
}
// Profile Menu
div { class: "dropdown",
button {
class: "btn btn-ghost",
r#type: "button",
"aria-haspopup": "menu",
"aria-expanded": "{menu_open()}",
onclick: move |_| menu_open.set(!menu_open()),
div { class: "avatar avatar-sm", "{initials(&username)}" }
span { style: "margin-left: 0.4rem;", "{username}" }
span { style: "margin-left: 0.25rem; opacity: 0.6;", "▾" }
}
if menu_open() {
div { class: "dropdown-menu", role: "menu",
Link {
class: "dropdown-item",
to: Route::ProfilePage {},
onclick: move |_| menu_open.set(false),
"👤 Profile"
}
Link {
class: "dropdown-item",
to: Route::SettingsPage {},
onclick: move |_| menu_open.set(false),
"⚙ Settings"
}
Link {
class: "dropdown-item",
to: Route::TokensPage {},
onclick: move |_| menu_open.set(false),
"🔑 API Tokens"
}
div { class: "dropdown-divider" }
button {
class: "dropdown-item",
r#type: "button",
onclick: move |_| {
menu_open.set(false);
let mut auth = state.auth;
spawn(async move {
let _ = api::logout().await;
auth.set(BootstrapState::Anonymous);
});
},
"⎋ Sign out"
}
}
}
}
}
}
}
}
#[component]
pub fn Sidebar() -> Element {
let state = use_context::<AppState>();
let auth = state.auth;
let path = use_route::<Route>();
let registry = (state.nav_registry)();
let can_see = |perm: &Option<String>| -> bool {
match perm {
Some(p) => auth().has_permission(p.as_str()),
None => true,
}
};
let active = |r: &Route| -> bool {
format!("{path:?}").split_whitespace().next()
== format!("{r:?}").split_whitespace().next()
};
rsx! {
nav { class: "app-sidebar", "aria-label": "Main",
for (section_name, items) in registry.sections.iter() {
// Only render section if at least one item is visible
if items.iter().any(|item| can_see(&item.permission)) {
div { class: "nav-section", "{section_name}" }
for item in items {
if can_see(&item.permission) {
{
let cls = if active(&item.route) { "nav-link active" } else { "nav-link" };
rsx! {
Link {
class: "{cls}",
to: item.route.clone(),
span { class: "icon", "{item.icon}" }
span { "{item.title}" }
}
}
}
}
}
}
}
}
}
}
#[component]
pub fn Breadcrumb(items: Vec<(String, Option<Route>)>) -> Element {
rsx! {
nav { class: "breadcrumb", "aria-label": "Breadcrumb",
for (i, (label, route)) in items.into_iter().enumerate() {
if i > 0 {
span { class: "sep", " / " }
}
if let Some(r) = route {
Link { to: r, "{label}" }
} else {
span { class: "current", "{label}" }
}
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
use crate::routes::Route;
#[derive(Clone, Debug, PartialEq)]
pub struct NavigationItem {
pub id: String,
pub title: String,
pub icon: String, // Lucide SVG path or icon name
pub route: Route,
pub permission: Option<String>,
pub children: Vec<NavigationItem>,
}
#[derive(Clone)]
pub struct NavigationRegistry {
pub sections: std::collections::BTreeMap<String, Vec<NavigationItem>>,
}
impl NavigationRegistry {
pub fn new() -> Self {
Self {
sections: std::collections::BTreeMap::new(),
}
}
pub fn register_section(&mut self, name: &str, items: Vec<NavigationItem>) {
self.sections.insert(name.to_string(), items);
}
}
+119
View File
@@ -0,0 +1,119 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct ColumnDef {
pub key: String,
pub label: String,
pub sortable: bool,
pub visible: bool,
}
#[component]
pub fn DataTable(
columns: Vec<ColumnDef>,
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,
total: usize,
// 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,
) -> Element {
let mut show_columns = use_signal(|| false);
rsx! {
div { class: "data-table-container",
div { class: "toolbar",
crate::components::tables::SearchBox {
value: search_value,
oninput: move |v| on_search.call(v),
placeholder: "{search_placeholder}",
}
div { class: "spacer" }
{toolbar_actions}
// Column Visibility
div { class: "dropdown",
button {
class: "btn btn-outline",
r#type: "button",
onclick: move |_| show_columns.set(!show_columns()),
"Columns ▾"
}
if show_columns() {
div { class: "dropdown-menu", style: "right: 0; left: auto;",
for col in columns.iter() {
label { class: "dropdown-item checkbox-row",
input {
r#type: "checkbox",
checked: col.visible,
// TODO: emit event
}
span { "{col.label}" }
}
}
}
}
}
// CSV Export (future ready)
button {
class: "btn btn-outline",
r#type: "button",
title: "Export to CSV (Coming Soon)",
"⬇ Export"
}
}
div { class: "table-wrap",
table { class: "data-table",
thead {
tr {
for col in columns.iter().filter(|c| c.visible) {
th {
if col.sortable {
button {
class: "btn-ghost",
style: "padding: 0; font-weight: inherit; font-size: inherit;",
onclick: {
let k = col.key.clone();
move |_| on_sort.call(k.clone())
},
"{col.label}"
if sort_key == col.key { " ↓" }
}
} else {
"{col.label}"
}
}
}
}
}
tbody {
{children}
}
}
}
crate::components::tables::Pagination {
page: page,
page_size: page_size,
total: total,
on_page: move |p| on_page.call(p),
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Table helpers: search toolbar + pagination.
pub mod datatable;
pub use datatable::{DataTable, ColumnDef};
use dioxus::prelude::*;
#[component]
pub fn SearchBox(
value: String,
oninput: EventHandler<String>,
#[props(default = "Search…".to_string())] placeholder: String,
) -> Element {
rsx! {
input {
class: "form-control search-input",
r#type: "search",
value: "{value}",
placeholder: "{placeholder}",
"aria-label": "Search",
oninput: move |e| oninput.call(e.value()),
}
}
}
#[component]
pub fn Pagination(
page: usize,
page_size: usize,
total: usize,
on_page: EventHandler<usize>,
) -> Element {
if total == 0 {
return rsx! {};
}
let pages = total.div_ceil(page_size).max(1);
let from = page * page_size + 1;
let to = ((page + 1) * page_size).min(total);
rsx! {
div { class: "pagination",
span { "Showing {from}–{to} of {total}" }
div { class: "pages",
button {
class: "btn btn-sm btn-outline",
r#type: "button",
disabled: page == 0,
onclick: move |_| on_page.call(page.saturating_sub(1)),
"Prev"
}
span { class: "text-muted", style: "padding: 0 0.5rem;",
"Page {page + 1} / {pages}"
}
button {
class: "btn btn-sm btn-outline",
r#type: "button",
disabled: page + 1 >= pages,
onclick: move |_| on_page.call(page + 1),
"Next"
}
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Small presentational widgets.
use dioxus::prelude::*;
#[component]
pub fn Avatar(name: String, #[props(default)] size: String) -> Element {
let initials = crate::utils::initials(&name);
let size_cls = match size.as_str() {
"sm" => "avatar avatar-sm",
"lg" => "avatar avatar-lg",
_ => "avatar",
};
rsx! {
div {
class: "{size_cls}",
title: "{name}",
"aria-hidden": "true",
"{initials}"
}
}
}
#[component]
pub fn Badge(text: String, #[props(default)] kind: String) -> Element {
let cls = match kind.as_str() {
"success" => "badge badge-success",
"warning" => "badge badge-warning",
"danger" => "badge badge-danger",
"info" => "badge badge-info",
"accent" => "badge badge-accent",
_ => "badge",
};
rsx! { span { class: "{cls}", "{text}" } }
}
#[component]
pub fn StatusChip(status: String) -> Element {
let cls = crate::utils::status_badge_class(&status);
rsx! { span { class: "{cls}", "{status}" } }
}
#[component]
pub fn Card(
#[props(default)] title: String,
#[props(default)] actions: Option<Element>,
children: Element,
) -> Element {
rsx! {
div { class: "card",
if !title.is_empty() || actions.is_some() {
div { class: "card-header",
if !title.is_empty() {
h3 { "{title}" }
} else {
span {}
}
if let Some(a) = actions {
div { class: "row", {a} }
}
}
}
div { class: "card-body", {children} }
}
}
}
#[component]
pub fn StatCard(
label: String,
value: String,
#[props(default)] hint: String,
) -> Element {
rsx! {
div { class: "stat-card",
div { class: "label", "{label}" }
div { class: "value", "{value}" }
if !hint.is_empty() {
div { class: "hint", "{hint}" }
}
}
}
}
/// Renders children only when the current user holds the given permission.
#[component]
pub fn PermissionGate(permission: String, children: Element) -> Element {
let state = use_context::<crate::state::AppState>();
let auth = state.auth;
if auth().has_permission(&permission) {
rsx! { {children} }
} else {
rsx! {}
}
}
+28
View File
@@ -0,0 +1,28 @@
//! nx9-auth Dioxus UI entrypoint.
mod app;
mod components;
mod models;
mod pages;
mod routes;
mod services;
mod state;
mod theme;
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)
// before Dioxus takes over `#main` and appends its root elements.
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
if let Some(el) = doc.get_element_by_id("main") {
el.set_inner_html("");
}
}
}
dioxus::launch(app::App);
}
+292
View File
@@ -0,0 +1,292 @@
//! Shared API response / request types.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct TenantView {
pub id: String,
pub name: String,
pub slug: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct TenantsResponse {
#[serde(default)]
pub tenants: Vec<TenantView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct UserView {
pub id: String,
pub username: String,
pub status: String,
#[serde(default)]
pub last_login_at: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct MeResponse {
pub user: UserView,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct UsersResponse {
#[serde(default)]
pub users: Vec<UserView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct TokenView {
pub id: String,
pub name: String,
#[serde(default)]
pub last_used_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub revoked: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct TokensResponse {
#[serde(default)]
pub tokens: Vec<TokenView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CreateTokenResponse {
pub token: TokenView,
pub raw_token: String,
#[serde(default)]
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RoleView {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub user_count: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RolesResponse {
#[serde(default)]
pub roles: Vec<RoleView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PermissionView {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub group: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PermissionGroup {
pub group: String,
#[serde(default)]
pub permissions: Vec<PermissionView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PermissionsResponse {
#[serde(default)]
pub permissions: Vec<PermissionView>,
#[serde(default)]
pub groups: Vec<PermissionGroup>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ApplicationView {
pub id: String,
pub name: String,
pub slug: String,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub redirect_urls: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ApplicationsResponse {
#[serde(default)]
pub applications: Vec<ApplicationView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ServiceAccountView {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<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 ServiceAccountsResponse {
#[serde(default)]
pub service_accounts: Vec<ServiceAccountView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AuditEntry {
pub id: String,
#[serde(default)]
pub actor_user_id: Option<String>,
#[serde(default)]
pub target_user_id: Option<String>,
pub action: String,
pub resource_type: String,
#[serde(default)]
pub resource_id: Option<String>,
pub severity: String,
#[serde(default)]
pub ip_address: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub metadata_json: Option<String>,
pub created_at: String,
#[serde(default)]
pub success: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AuditResponse {
#[serde(default)]
pub entries: Vec<AuditEntry>,
#[serde(default)]
pub total: i64,
#[serde(default)]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct DashboardResponse {
#[serde(default)]
pub personal: serde_json::Value,
#[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub admin: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProfileResponse {
#[serde(default)]
pub user: UserView,
#[serde(default)]
pub profile: ProfileFields,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub sessions: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProfileFields {
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub full_name: Option<String>,
#[serde(default)]
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ApiErrorBody {
#[serde(default)]
pub error: String,
#[serde(default)]
pub code: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SessionView {
pub id: String,
pub user_id: String,
#[serde(default)]
pub ip_address: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
pub created_at: String,
pub last_seen_at: String,
pub expires_at: String,
#[serde(default)]
pub is_current: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SessionsResponse {
#[serde(default)]
pub sessions: Vec<SessionView>,
#[serde(default)]
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct GroupView {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub member_count: usize,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct GroupMemberView {
pub id: String,
pub username: String,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct GroupDetailResponse {
pub group: GroupView,
#[serde(default)]
pub members: Vec<GroupMemberView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct GroupsResponse {
#[serde(default)]
pub groups: Vec<GroupView>,
}
+55
View File
@@ -0,0 +1,55 @@
//! About page.
use crate::components::navigation::Breadcrumb;
use crate::routes::Route;
use dioxus::prelude::*;
#[component]
pub fn AboutPage() -> Element {
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("About".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "About nx9-auth" }
p { class: "desc", "Lightweight self-hosted IAM for the NX9 ecosystem" }
}
}
div { class: "card",
div { class: "card-body stack",
p {
strong { "nx9-auth" }
" provides authentication, authorization, RBAC, sessions, API tokens, "
"service accounts, applications, and audit logging in a single Rust binary."
}
div { class: "row", style: "flex-wrap:wrap;gap:0.5rem;",
span { class: "badge badge-accent", "Pure Rust" }
span { class: "badge badge-info", "Dioxus UI" }
span { class: "badge badge-success", "Self-hosted" }
span { class: "badge", "FOSS" }
span { class: "badge", "No Node.js" }
}
h3 { class: "mt-2", "Philosophy" }
ul {
li { "Single binary deployment" }
li { "Zero JavaScript frameworks" }
li { "Backend remains the authority for security decisions" }
li { "Extensible shell for future NX9 services" }
}
h3 { "Future integrations" }
div { class: "row", style: "flex-wrap:wrap;gap:0.4rem;",
for name in ["nx9-docflow", "nx9-dns", "nx9-shortener", "nx9-storage", "nx9-monitor", "nx9-mail"] {
span { class: "badge", "{name}" }
}
}
p { class: "text-muted mt-2", style: "font-size:12px;",
"License: Apache-2.0 OR MIT"
}
}
}
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Applications CRUD.
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::widgets::StatusChip;
use crate::models::ApplicationView;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::{format_datetime, matches_query, slugify};
use dioxus::prelude::*;
#[component]
pub fn ApplicationsPage() -> Element {
let state = use_context::<AppState>();
let auth = state.auth;
let can_manage = auth().has_permission("roles:manage");
let mut apps = use_signal(Vec::<ApplicationView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut page = use_signal(|| 0usize);
let page_size = 10usize;
let mut sort_key = use_signal(|| "name".to_string());
let mut show_create = use_signal(|| false);
let mut name = use_signal(String::new);
let mut slug = use_signal(String::new);
let mut delete_target = use_signal(|| Option::<ApplicationView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
spawn(async move {
match api::list_applications().await {
Ok(list) => {
apps.set(list);
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
let mut filtered: Vec<_> = apps()
.into_iter()
.filter(|a| matches_query(&a.name, &query()) || matches_query(&a.slug, &query()))
.collect();
let sk = sort_key();
filtered.sort_by(|a, b| match sk.as_str() {
"status" => b.enabled.cmp(&a.enabled),
"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();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Applications".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Applications" }
p { class: "desc", "Registered applications in the NX9 ecosystem" }
}
if can_manage {
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| show_create.set(true),
"+ Create application"
}
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if apps().is_empty() && query().is_empty() {
EmptyState {
title: "No applications".to_string(),
description: "Applications appear here once registered.",
icon: "▦",
}
} else {
DataTable {
columns: {
let mut cols = vec![
ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true },
ColumnDef { key: "client_id".into(), label: "Client ID".into(), sortable: false, visible: true },
ColumnDef { key: "redirect".into(), label: "Redirect URLs".into(), sortable: false, visible: true },
ColumnDef { key: "scopes".into(), label: "Scopes".into(), sortable: false, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true },
ColumnDef { key: "created".into(), label: "Created".into(), sortable: true, visible: true },
];
if can_manage {
cols.push(ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true });
}
cols
},
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search applications…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for a in page_items {
{
let app = a.clone();
let app2 = a.clone();
rsx! {
tr { key: "{a.id}",
td { strong { "{a.name}" } }
td { code { "{a.client_id}" } }
td { class: "text-muted",
if a.redirect_urls.is_empty() { "—" } else { "{a.redirect_urls.join(\", \")}" }
}
td { class: "text-muted",
if a.scopes.is_empty() { "—" } else { "{a.scopes.join(\" \")}" }
}
td {
StatusChip {
status: if a.enabled { "active".to_string() } else { "disabled".to_string() }
}
}
td { "{format_datetime(&a.created_at)}" }
if can_manage {
td { style: "text-align: right;",
div { class: "actions",
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let id = app.id.clone();
let name = app.name.clone();
let slug = app.slug.clone();
let enabled = !app.enabled;
spawn(async move {
match api::update_application(&id, &name, &slug, enabled).await {
Ok(_) => {
state.toast(ToastKind::Success, if enabled { "Enabled" } else { "Disabled" });
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
if a.enabled { "Disable" } else { "Enable" }
}
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| delete_target.set(Some(app2.clone())),
"Delete"
}
}
}
}
}
}
}
}
}
}
Modal {
title: "Create application".to_string(),
open: show_create(),
on_close: move |_| show_create.set(false),
TextInput {
label: "Name",
value: name(),
oninput: move |v: String| {
name.set(v.clone());
if slug().is_empty() || slug() == slugify(&name()) {
// keep in sync when empty-ish
}
slug.set(slugify(&v));
},
}
TextInput {
label: "Slug / Client ID",
value: slug(),
oninput: move |v| slug.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| show_create.set(false), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = name();
let s = slug();
spawn(async move {
match api::create_application(&n, &s).await {
Ok(_) => {
state.toast(ToastKind::Success, "Application created");
show_create.set(false);
name.set(String::new());
slug.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
ConfirmDialog {
title: "Delete application".to_string(),
message: format!(
"Delete application \"{}\"?",
delete_target().as_ref().map(|a| a.name.as_str()).unwrap_or("")
),
open: delete_target().is_some(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
if let Some(a) = delete_target() {
spawn(async move {
match api::delete_application(&a.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "Application deleted");
delete_target.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| delete_target.set(None),
}
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Enterprise audit log viewer.
use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb;
use crate::components::tables::{Pagination, SearchBox};
use crate::models::AuditResponse;
use crate::routes::Route;
use crate::services::api;
use crate::utils::{format_datetime, severity_badge_class};
use dioxus::prelude::*;
#[component]
pub fn AuditPage() -> Element {
let mut data = use_signal(|| Option::<AuditResponse>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut action = use_signal(String::new);
let mut resource = use_signal(String::new);
let mut severity = use_signal(|| "all".to_string());
let mut success = use_signal(|| "all".to_string());
let mut since = use_signal(String::new);
let mut until = use_signal(String::new);
let mut page = use_signal(|| 0usize);
let page_size = 25usize;
let load = use_callback(move |_: ()| {
loading.set(true);
let mut parts = vec![
format!("limit={page_size}"),
format!("offset={}", page() * page_size),
];
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("&");
spawn(async move {
match api::list_audit(&qs).await {
Ok(d) => {
data.set(Some(d));
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { load.call(()); });
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Audit Log".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Audit Log" }
p { class: "desc", "Security and administrative event history" }
}
div { class: "row",
button {
class: "btn btn-outline", r#type: "button",
title: "Export is a placeholder",
onclick: move |_| {},
"Export (soon)"
}
button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" }
}
}
div { class: "card mb-2",
div { class: "card-body",
div { class: "toolbar", style: "margin:0;",
SearchBox {
value: query(),
oninput: move |v| query.set(v),
placeholder: "Search action, resource, IP…",
}
input {
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;",
placeholder: "Resource",
value: "{resource()}",
oninput: move |e| resource.set(e.value()),
}
select {
class: "form-control", style: "width:auto;",
value: "{severity()}",
onchange: move |e| severity.set(e.value()),
option { value: "all", "All severities" }
option { value: "info", "Info" }
option { value: "warning", "Warning" }
option { value: "critical", "Critical" }
}
select {
class: "form-control", style: "width:auto;",
value: "{success()}",
onchange: move |e| success.set(e.value()),
option { value: "all", "Success/Fail" }
option { value: "true", "Success" }
option { value: "false", "Failure" }
}
input {
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;",
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(()); },
"Apply"
}
}
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| load.call(()) }
} else if let Some(d) = data() {
if d.entries.is_empty() {
EmptyState {
title: "No audit entries".to_string(),
description: "Try broadening your filters.",
icon: "📋",
}
} else {
div { class: "table-wrap",
table { class: "data-table",
thead {
tr {
th { "Time" }
th { "Action" }
th { "Resource" }
th { "Severity" }
th { "Result" }
th { "Actor" }
th { "IP" }
}
}
tbody {
for e in d.entries {
tr { key: "{e.id}",
td { class: "mono", "{format_datetime(&e.created_at)}" }
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;",
"{rid}"
}
}
}
td {
span { class: "{severity_badge_class(&e.severity)}", "{e.severity}" }
}
td {
if e.success {
span { class: "badge badge-success", "ok" }
} else {
span { class: "badge badge-danger", "fail" }
}
}
td { class: "mono",
"{e.actor_user_id.as_deref().unwrap_or(\"—\")}"
}
td { class: "mono",
"{e.ip_address.as_deref().unwrap_or(\"—\")}"
}
}
}
}
}
}
Pagination {
page: page(),
page_size: page_size,
total: d.total as usize,
on_page: move |p| { page.set(p); load.call(()); },
}
}
}
}
}
fn urlencoding_lite(s: &str) -> String {
s.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
_ => format!("%{:02X}", c as u8),
})
.collect()
}
+241
View File
@@ -0,0 +1,241 @@
//! Authentication pages.
//!
//! Login submits credentials via **POST JSON** only. The HTML form uses
//! `method="post"` so a native fallback never puts passwords in the URL.
use crate::components::forms::{PasswordInput, TextInput};
use crate::models::MeResponse;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, BootstrapState, ToastKind};
use dioxus::prelude::*;
#[component]
pub fn LoginPage() -> Element {
let state = use_context::<AppState>();
let auth = state.auth;
let nav = use_navigator();
let mut username = use_signal(String::new);
let mut password = use_signal(String::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| false);
// If already authenticated, leave the login screen.
use_effect(move || {
if auth().is_authenticated() {
nav.replace(Route::DashboardPage {});
}
});
let on_submit = move |evt: Event<FormData>| {
// Critical: prevent native form submission (which defaults to GET
// and would put credentials in the query string / browser history).
evt.prevent_default();
if loading() {
return;
}
let u = username().trim().to_string();
let p = password();
if u.is_empty() || p.is_empty() {
error.set(Some("Please enter username and password.".into()));
return;
}
loading.set(true);
error.set(None);
let mut auth = state.auth;
let mut loading = loading;
let mut error = error;
let mut password = password;
let nav = nav.clone();
spawn(async move {
match api::login(&u, &p).await {
Ok(login) => {
// Clear password from UI memory after successful submit.
password.set(String::new());
// Prefer /auth/me; fall back to login payload user info.
let me = match api::me().await {
Ok(Some(m)) => m,
_ => {
// Build MeResponse from login.user if present
if let Some(user_val) = login.user {
MeResponse {
user: crate::models::UserView {
id: user_val
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
username: user_val
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
status: user_val
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("active")
.to_string(),
last_login_at: user_val
.get("last_login_at")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
created_at: user_val
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
updated_at: None,
},
roles: user_val
.get("roles")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
permissions: user_val
.get("permissions")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
}
} else {
error.set(Some(
"Signed in but session could not be verified. Try again."
.into(),
));
loading.set(false);
return;
}
}
};
auth.set(BootstrapState::Authenticated(me));
state.toast(ToastKind::Success, "Signed in successfully");
nav.replace(Route::DashboardPage {});
}
Err(e) => {
// Map API errors to a safe, non-enumerating message for creds.
let msg = match e {
api::ApiError::Unauthorized
| api::ApiError::InvalidInput(_)
| api::ApiError::Server(_) => {
// Prefer server body when it's the standard message
let s = e.to_string();
if s.to_lowercase().contains("invalid username")
|| s.to_lowercase().contains("unauthorized")
|| s.to_lowercase().contains("invalid credentials")
{
"Invalid username or password.".into()
} else {
s
}
}
other => other.to_string(),
};
error.set(Some(msg));
if !auth().is_authenticated() {
auth.set(BootstrapState::Anonymous);
}
}
}
loading.set(false);
});
};
rsx! {
div { class: "auth-page",
div { class: "auth-card",
div { class: "logo-row",
div { class: "brand-mark", style: "width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--accent),#7c3aed);display:grid;place-items:center;color:#fff;font-weight:800;",
"N9"
}
div {
h1 { "Sign in to nx9-auth" }
p { class: "subtitle", style: "margin:0;", "Identity & Access Management" }
}
}
if let Some(err) = error() {
div { class: "alert alert-error", role: "alert", "{err}" }
}
// method="post" is mandatory: HTML default is GET, which would
// put credentials in the URL if preventDefault failed.
form {
method: "post",
action: "#",
autocomplete: "on",
onsubmit: on_submit,
TextInput {
label: "Username",
name: "username",
value: username(),
oninput: move |v| username.set(v),
required: true,
autocomplete: "username",
placeholder: "admin",
}
PasswordInput {
label: "Password",
name: "password",
value: password(),
oninput: move |v| password.set(v),
required: true,
autocomplete: "current-password",
}
button {
class: "btn btn-primary",
r#type: "submit",
style: "width: 100%; margin-top: 0.5rem;",
disabled: loading() || username().trim().is_empty() || password().is_empty(),
if loading() {
span { class: "spinner", style: "width:14px;height:14px;border-width:2px;" }
}
if loading() { "Signing in…" } else { "Sign in" }
}
}
p { class: "text-muted", style: "margin-top: 1.25rem; font-size: 12px; text-align: center;",
"POST · Argon2id · Session tokens · No credentials in URLs"
}
}
}
}
}
#[component]
pub fn UnauthorizedPage() -> Element {
rsx! {
div { class: "auth-page",
div { class: "auth-card", style: "text-align:center;",
h1 { "401 — Unauthorized" }
p { class: "subtitle", "Your session is missing or has expired." }
Link { class: "btn btn-primary", to: Route::LoginPage {}, "Sign in" }
}
}
}
}
#[component]
pub fn ForbiddenPage() -> Element {
rsx! {
div { class: "auth-page",
div { class: "auth-card", style: "text-align:center;",
h1 { "403 — Forbidden" }
p { class: "subtitle", "You do not have permission to view this page." }
Link { class: "btn btn-primary", to: Route::DashboardPage {}, "Back to dashboard" }
}
}
}
}
+318
View File
@@ -0,0 +1,318 @@
//! User and admin dashboards.
use crate::components::feedback::{ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb;
use crate::components::widgets::StatCard;
use crate::routes::Route;
use crate::services::api;
use crate::utils::format_datetime;
use dioxus::prelude::*;
use serde_json::Value;
#[component]
pub fn DashboardPage() -> Element {
let mut data = use_signal(|| Option::<crate::models::DashboardResponse>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let load = use_callback(move |_: ()| {
loading.set(true);
error.set(None);
spawn(async move {
match api::dashboard().await {
Ok(d) => {
data.set(Some(d));
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { load.call(()); });
rsx! {
Breadcrumb { items: vec![("Dashboard".to_string(), None)] }
div { class: "page-header",
div {
h1 { "Dashboard" }
p { class: "desc", "Overview of your identity workspace" }
}
button {
class: "btn btn-outline",
r#type: "button",
onclick: move |_| load.call(()),
"Refresh"
}
}
if loading() {
LoadingSpinner { label: "Loading dashboard…" }
} else if let Some(err) = error() {
ErrorState {
message: err,
on_retry: move |_| load.call(())
}
} else if let Some(d) = data() {
{
let personal = &d.personal;
let username = personal
.pointer("/user/username")
.and_then(|v| v.as_str())
.unwrap_or("user");
let roles = personal
.get("roles")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let sessions = personal
.get("sessions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let tokens = personal
.get("tokens")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let apps = personal
.get("applications")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let recent = personal
.get("recent_audit")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
rsx! {
// Personal welcome
div { class: "card mb-2",
div { class: "card-body",
h2 { "Welcome, {username}" }
p { class: "text-secondary",
"Roles: "
for (i, r) in roles.iter().enumerate() {
if i > 0 { span { ", " } }
span { class: "badge badge-accent", "{r.as_str().unwrap_or(\"\")}" }
}
if roles.is_empty() {
span { class: "text-muted", "none" }
}
}
}
}
if d.is_admin {
if let Some(admin) = &d.admin {
AdminSummary { admin: admin.clone() }
}
}
div { class: "grid-2",
div { class: "card",
div { class: "card-header", h3 { "Active sessions" } }
div { class: "card-body",
if sessions.is_empty() {
p { class: "text-muted", "No active sessions." }
} else {
div { class: "table-wrap",
table { class: "data-table",
thead {
tr {
th { "IP" }
th { "Last seen" }
th { "Expires" }
}
}
tbody {
for s in sessions.iter().take(5) {
tr {
td { class: "mono",
"{s.get(\"ip_address\").and_then(|v| v.as_str()).unwrap_or(\"—\")}"
}
td {
"{format_datetime(s.get(\"last_seen_at\").and_then(|v| v.as_str()).unwrap_or(\"\"))}"
}
td {
"{format_datetime(s.get(\"expires_at\").and_then(|v| v.as_str()).unwrap_or(\"\"))}"
}
}
}
}
}
}
}
}
}
div { class: "card",
div { class: "card-header",
h3 { "API tokens" }
Link { class: "btn btn-sm btn-outline", to: Route::TokensPage {}, "Manage" }
}
div { class: "card-body",
if tokens.is_empty() {
p { class: "text-muted", "No personal tokens yet." }
} else {
ul { style: "margin:0;padding-left:1.1rem;",
for t in tokens.iter().take(5) {
li {
strong { "{t.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"token\")}" }
span { class: "text-muted",
" · created {format_datetime(t.get(\"created_at\").and_then(|v| v.as_str()).unwrap_or(\"\"))}"
}
}
}
}
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Applications" } }
div { class: "card-body",
if apps.is_empty() {
p { class: "text-muted", "No applications registered." }
} else {
div { class: "row", style: "flex-wrap:wrap;gap:0.5rem;",
for a in apps {
span { class: "badge badge-info",
"{a.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"app\")}"
}
}
}
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Recent activity" } }
div { class: "card-body",
if recent.is_empty() {
p { class: "text-muted", "No recent events." }
} else {
div { class: "stack",
for e in recent.iter().take(8) {
div { style: "display:flex;justify-content:space-between;gap:0.5rem;font-size:13px;",
span {
code { "{e.get(\"action\").and_then(|v| v.as_str()).unwrap_or(\"?\")}" }
span { class: "text-muted",
" on {e.get(\"resource_type\").and_then(|v| v.as_str()).unwrap_or(\"?\")}"
}
}
span { class: "text-muted",
"{format_datetime(e.get(\"created_at\").and_then(|v| v.as_str()).unwrap_or(\"\"))}"
}
}
}
}
}
}
}
}
}
}
}
}
}
#[component]
fn AdminSummary(admin: Value) -> Element {
let summary = admin.get("summary").cloned().unwrap_or(Value::Null);
let num = |k: &str| -> String {
summary
.get(k)
.and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|u| u as i64)))
.map(|n| n.to_string())
.unwrap_or_else(|| "—".to_string())
};
let recent_logins = admin
.get("recent_logins")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let recent_users = admin
.get("recent_users")
.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" }
div { class: "stat-grid",
StatCard { label: "Tenants", value: num("tenants"), hint: "".to_string() }
StatCard { label: "Users", value: num("total_users"), hint: "".to_string() }
StatCard { label: "Sessions", value: num("active_sessions"), hint: "".to_string() }
StatCard { label: "Roles", value: num("roles"), hint: "".to_string() }
StatCard { label: "Permissions", value: num("permissions"), hint: "".to_string() }
StatCard { label: "Applications", value: num("applications"), hint: "".to_string() }
StatCard { label: "OAuth Clients", value: num("oauth_clients"), hint: "".to_string() }
StatCard { label: "Service accounts", value: num("service_accounts"), hint: "".to_string() }
StatCard { label: "Audit events", value: num("audit_events"), hint: "".to_string() }
StatCard { label: "Security Alerts", value: num("security_alerts"), hint: "".to_string() }
}
div { class: "grid-2",
div { class: "card",
div { class: "card-header", h3 { "Recent logins" } }
div { class: "card-body",
if recent_logins.is_empty() {
p { class: "text-muted", "No recent logins." }
} else {
for e in recent_logins.iter().take(6) {
div { style: "display:flex;justify-content:space-between;font-size:13px;margin-bottom:0.4rem;",
span { class: "mono",
"{e.get(\"actor_user_id\").and_then(|v| v.as_str()).unwrap_or(\"?\")[..8.min(e.get(\"actor_user_id\").and_then(|v| v.as_str()).unwrap_or(\"\").len())].to_string()}"
}
span { class: "text-muted",
"{format_datetime(e.get(\"created_at\").and_then(|v| v.as_str()).unwrap_or(\"\"))}"
}
}
}
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Recent users" } }
div { class: "card-body",
if recent_users.is_empty() {
p { class: "text-muted", "No users." }
} else {
for u in recent_users.iter().take(6) {
div { style: "display:flex;justify-content:space-between;font-size:13px;margin-bottom:0.4rem;",
span { "{u.get(\"username\").and_then(|v| v.as_str()).unwrap_or(\"?\")}" }
span { class: "badge badge-success",
"{u.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"\")}"
}
}
}
}
}
}
}
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" }
}
}
}
}
}
+445
View File
@@ -0,0 +1,445 @@
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::models::{GroupView, UserView};
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::{format_datetime, matches_query};
use dioxus::prelude::*;
#[component]
pub fn GroupsPage() -> Element {
let state = use_context::<AppState>();
let mut groups = use_signal(Vec::<GroupView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut page = use_signal(|| 0usize);
let page_size = 10usize;
let mut sort_key = use_signal(|| "name".to_string());
let mut show_create = use_signal(|| false);
let mut name = use_signal(String::new);
let mut description = use_signal(String::new);
let mut delete_group = use_signal(|| Option::<GroupView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
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); }
}
});
});
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))
.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();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Groups".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Groups" }
p { class: "desc", "Manage user groups" }
}
button {
class: "btn btn-primary",
r#type: "button",
onclick: move |_| show_create.set(true),
"+ Create group"
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if groups().is_empty() && query().is_empty() {
EmptyState { title: "No groups".to_string(), description: "Create your first group.", icon: "👥" }
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true },
ColumnDef { key: "description".into(), label: "Description".into(), sortable: false, visible: true },
ColumnDef { key: "members".into(), label: "Members".into(), sortable: true, visible: true },
ColumnDef { key: "created".into(), label: "Created".into(), sortable: true, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search groups…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for g in page_items {
{
let id = g.id.clone();
let g2 = g.clone();
rsx! {
tr { key: "{g.id}",
td {
Link {
to: Route::GroupDetailPage { id: g.id.clone() },
strong { "{g.name}" }
}
}
td { class: "text-secondary", "{g.description.as_deref().unwrap_or(\"—\")}" }
td { span { class: "badge", "{g.member_count}" } }
td { "{format_datetime(&g.created_at)}" }
td { style: "text-align: right;",
div { class: "actions",
Link {
class: "btn btn-sm btn-outline",
to: Route::GroupDetailPage { id: id },
"View"
}
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| delete_group.set(Some(g2.clone())),
"Delete"
}
}
}
}
}
}
}
}
}
Modal {
title: "Create group".to_string(),
open: show_create(),
on_close: move |_| show_create.set(false),
TextInput {
label: "Name",
value: name(),
oninput: move |v| name.set(v),
}
TextInput {
label: "Description",
value: description(),
oninput: move |v| description.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| show_create.set(false), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = name();
let d = description();
spawn(async move {
match api::create_group(&n, Some(d.as_str()).filter(|s| !s.is_empty())).await {
Ok(_) => {
state.toast(ToastKind::Success, "Group created");
show_create.set(false);
name.set(String::new());
description.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
ConfirmDialog {
title: "Delete group".to_string(),
message: format!("Delete group \"{}\"? This cannot be undone.", delete_group().as_ref().map(|g| g.name.as_str()).unwrap_or("")),
open: delete_group().is_some(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
if let Some(g) = delete_group() {
spawn(async move {
match api::delete_group(&g.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "Group deleted");
delete_group.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| delete_group.set(None),
}
}
}
#[component]
pub fn GroupDetailPage(id: String) -> Element {
let state = use_context::<AppState>();
let mut group = use_signal(|| Option::<crate::models::GroupDetailResponse>::None);
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);
let mut edit_name = use_signal(String::new);
let mut edit_desc = use_signal(String::new);
let mut confirm_delete = use_signal(|| false);
let group_id = id.clone();
let reload = use_callback(move |_: ()| {
let id = group_id.clone();
loading.set(true);
spawn(async move {
match api::get_group(&id).await {
Ok(detail) => {
group.set(Some(detail));
if let Ok(users) = api::list_users().await {
all_users.set(users);
}
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Groups".to_string(), Some(Route::GroupsPage {})),
(group().map(|g| g.group.name.clone()).unwrap_or(id.clone()), None),
]}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if let Some(detail) = group() {
{
let gid = detail.group.id.clone();
let gid2 = detail.group.id.clone();
let gid3 = detail.group.id.clone();
let gname = detail.group.name.clone();
rsx! {
div { class: "page-header",
div {
h1 { "{detail.group.name}" }
p { class: "desc", "{detail.group.description.clone().unwrap_or_else(|| \"No description\".to_string())}" }
}
div { class: "row",
button {
class: "btn btn-outline",
r#type: "button",
onclick: move |_| {
edit_name.set(detail.group.name.clone());
edit_desc.set(detail.group.description.clone().unwrap_or_default());
edit_mode.set(true);
},
"Edit"
}
button {
class: "btn btn-danger",
r#type: "button",
onclick: move |_| confirm_delete.set(true),
"Delete"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Members" } }
div { class: "card-body",
div { class: "row mb-2",
select {
class: "form-control",
value: "{add_user_id()}",
onchange: move |e| add_user_id.set(e.value()),
option { value: "", "Select user to add…" }
for u in all_users() {
if !detail.members.iter().any(|m| m.id == u.id) {
option { value: "{u.id}", "{u.username}" }
}
}
}
button {
class: "btn btn-primary",
r#type: "button",
disabled: add_user_id().is_empty(),
onclick: move |_| {
let uid = add_user_id();
let gid = gid.clone();
spawn(async move {
match api::add_group_member(&gid, &uid).await {
Ok(_) => {
state.toast(ToastKind::Success, "Member added");
add_user_id.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Add member"
}
}
if detail.members.is_empty() {
p { class: "text-muted", "No members in this group." }
} else {
DataTable {
columns: vec![
ColumnDef { key: "username".into(), label: "Username".into(), sortable: false, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: false, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".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: detail.members.len().max(1),
total: detail.members.len(),
for m in detail.members {
{
let uid = m.id.clone();
let gid = gid2.clone();
rsx! {
tr { key: "{m.id}",
td {
Link {
to: Route::UserDetailPage { id: m.id.clone() },
"{m.username}"
}
}
td { "{m.status}" }
td { style: "text-align: right;",
button {
class: "btn btn-sm btn-outline btn-danger",
r#type: "button",
onclick: move |_| {
let uid = uid.clone();
let gid = gid.clone();
spawn(async move {
match api::remove_group_member(&gid, &uid).await {
Ok(_) => {
state.toast(ToastKind::Success, "Member removed");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Remove"
}
}
}
}
}
}
}
}
}
}
Modal {
title: "Edit group".to_string(),
open: edit_mode(),
on_close: move |_| edit_mode.set(false),
TextInput {
label: "Name",
value: edit_name(),
oninput: move |v| edit_name.set(v),
}
TextInput {
label: "Description",
value: edit_desc(),
oninput: move |v| edit_desc.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| edit_mode.set(false), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = edit_name();
let d = edit_desc();
let gid = gid3.clone();
spawn(async move {
match api::update_group(&gid, &n, Some(d.as_str()).filter(|s| !s.is_empty())).await {
Ok(_) => {
state.toast(ToastKind::Success, "Group updated");
edit_mode.set(false);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Save"
}
}
}
ConfirmDialog {
title: "Delete group".to_string(),
message: format!("Delete group \"{}\"? This cannot be undone.", gname),
open: confirm_delete(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
let gid = detail.group.id.clone();
spawn(async move {
match api::delete_group(&gid).await {
Ok(_) => {
state.toast(ToastKind::Success, "Group deleted");
let _ = dioxus_router::hooks::use_navigator().replace(Route::GroupsPage {});
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
on_cancel: move |_| confirm_delete.set(false),
}
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Application pages.
pub mod about;
pub mod applications;
pub mod audit;
pub mod auth;
pub mod dashboard;
pub mod groups;
pub mod not_found;
pub mod permissions;
pub mod profile;
pub mod roles;
pub mod service_accounts;
pub mod sessions;
pub mod settings;
pub mod tenants;
pub mod tokens;
pub mod users;
+15
View File
@@ -0,0 +1,15 @@
use crate::routes::Route;
use dioxus::prelude::*;
#[component]
pub fn NotFoundPage(route: Vec<String>) -> Element {
let path = route.join("/");
rsx! {
div { class: "empty-state", style: "padding-top: 4rem;",
div { class: "icon", "🔍" }
h1 { "404 — Page not found" }
p { "No page matches /{path}" }
Link { class: "btn btn-primary", to: Route::DashboardPage {}, "Go to dashboard" }
}
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Permission browser / matrix.
use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef};
use crate::models::PermissionsResponse;
use crate::routes::Route;
use crate::services::api;
use crate::utils::matches_query;
use dioxus::prelude::*;
#[component]
pub fn PermissionsPage() -> Element {
let mut data = use_signal(|| Option::<PermissionsResponse>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut group_filter = use_signal(|| "all".to_string());
let mut page = use_signal(|| 0usize);
let page_size = 15usize;
let mut sort_key = use_signal(|| "name".to_string());
let reload = use_callback(move |_: ()| {
loading.set(true);
spawn(async move {
match api::list_permissions().await {
Ok(d) => {
data.set(Some(d));
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Permissions".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Permissions" }
p { class: "desc", "System permission catalog (assignment via Roles)" }
}
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if let Some(d) = data() {
{
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 {
if gf == "all" || g.group == gf {
for p in &g.permissions {
if matches_query(&p.name, &q) || p.description.as_deref().map(|d| matches_query(d, &q)).unwrap_or(false) {
all_perms.push((g.group.clone(), p.clone()));
}
}
}
}
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();
rsx! {
if total == 0 && q.is_empty() {
EmptyState {
title: "No permissions match".to_string(),
description: "",
icon: "✓",
}
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true },
ColumnDef { key: "group".into(), label: "Group".into(), sortable: true, visible: true },
ColumnDef { key: "description".into(), label: "Description".into(), sortable: true, visible: true },
],
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search permissions…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
select {
class: "form-control",
style: "width: auto;",
value: "{group_filter()}",
onchange: move |e| { group_filter.set(e.value()); page.set(0); },
option { value: "all", "All groups" }
for g in groups {
option { value: "{g}", "{g}" }
}
}
},
for (group, p) in page_items {
{
rsx! {
tr { key: "{p.id}",
td { code { "{p.name}" } }
td { span { class: "badge badge-accent", "{group}" } }
td { class: "text-secondary", "{p.description.as_deref().unwrap_or(\"—\")}" }
}
}
}
}
}
}
p { class: "text-muted mt-2", style: "font-size:12px;",
"Permission assignment is managed on the Roles page. Drag-and-drop matrix is planned."
}
}
}
}
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Profile management.
use crate::components::feedback::{ErrorState, LoadingSpinner};
use crate::components::forms::{PasswordInput, TextInput};
use crate::components::navigation::Breadcrumb;
use crate::components::widgets::{Avatar, StatusChip};
use crate::models::ProfileResponse;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::format_datetime;
use dioxus::prelude::*;
#[component]
pub fn ProfilePage() -> Element {
let state = use_context::<AppState>();
let mut data = use_signal(|| Option::<ProfileResponse>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut email = use_signal(String::new);
let mut full_name = use_signal(String::new);
let mut current_pw = use_signal(String::new);
let mut new_pw = use_signal(String::new);
let reload = use_callback(move |_: ()| {
loading.set(true);
spawn(async move {
match api::get_profile().await {
Ok(p) => {
email.set(p.profile.email.clone().unwrap_or_default());
full_name.set(p.profile.full_name.clone().unwrap_or_default());
data.set(Some(p));
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Profile".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Profile" }
p { class: "desc", "Your account details and security settings" }
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if let Some(p) = data() {
div { class: "grid-2",
div { class: "card",
div { class: "card-body",
div { class: "row mb-2", style: "gap:1rem;",
Avatar { name: p.user.username.clone(), size: "lg" }
div {
h2 { "{p.user.username}" }
StatusChip { status: p.user.status.clone() }
p { class: "text-muted mono", style: "font-size:12px;margin:0.25rem 0 0;",
"{p.user.id}"
}
}
}
p { class: "text-secondary",
"Member since {format_datetime(&p.user.created_at)}"
}
p { class: "text-secondary",
"Roles: "
for (i, r) in p.roles.iter().enumerate() {
if i > 0 { span { ", " } }
span { class: "badge badge-accent", "{r}" }
}
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Profile details" } }
div { class: "card-body",
TextInput {
label: "Full name",
name: "full_name",
value: full_name(),
oninput: move |v| full_name.set(v),
placeholder: "Ada Lovelace",
autocomplete: "name",
}
TextInput {
label: "Email",
name: "email",
value: email(),
oninput: move |v| email.set(v),
input_type: "email",
placeholder: "you@example.com",
autocomplete: "email",
}
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let e = email();
let n = full_name();
spawn(async move {
match api::update_profile(
Some(e.as_str()).filter(|s| !s.is_empty()),
Some(n.as_str()).filter(|s| !s.is_empty()),
).await {
Ok(_) => state.toast(ToastKind::Success, "Profile updated"),
Err(err) => state.toast(ToastKind::Error, err.to_string()),
}
});
},
"Save profile"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Change password" } }
div { class: "card-body",
PasswordInput {
label: "Current password",
name: "current_password",
value: current_pw(),
oninput: move |v| current_pw.set(v),
autocomplete: "current-password",
}
PasswordInput {
label: "New password",
name: "new_password",
value: new_pw(),
oninput: move |v| new_pw.set(v),
autocomplete: "new-password",
}
p { class: "form-hint",
"Minimum 8 characters (12 for admins). Avoid common sequences like \"password\" or \"admin123\"."
}
button {
class: "btn btn-primary", r#type: "button",
disabled: current_pw().is_empty() || new_pw().len() < 8,
onclick: move |_| {
let cur = current_pw();
let neu = new_pw();
if cur.is_empty() || neu.is_empty() {
state.toast(ToastKind::Error, "Both password fields are required");
return;
}
if neu.len() < 8 {
state.toast(ToastKind::Error, "New password must be at least 8 characters");
return;
}
spawn(async move {
match api::change_password(&cur, &neu).await {
Ok(()) => {
state.toast(ToastKind::Success, "Password changed");
current_pw.set(String::new());
new_pw.set(String::new());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Update password"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Coming soon" } }
div { class: "card-body stack",
div { class: "row", style: "justify-content:space-between;",
span { "Avatar upload" }
span { class: "badge", "planned" }
}
div { class: "row", style: "justify-content:space-between;",
span { "Multi-factor authentication" }
span { class: "badge", "planned" }
}
div { class: "row", style: "justify-content:space-between;",
span { "Recovery codes" }
span { class: "badge", "planned" }
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Active sessions" } }
div { class: "card-body",
if p.sessions.is_empty() {
p { class: "text-muted", "No active sessions." }
} else {
for s in p.sessions.iter().take(8) {
div { style: "display:flex;justify-content:space-between;font-size:13px;margin-bottom:0.4rem;",
span { class: "mono",
"{s.get(\"ip_address\").and_then(|v| v.as_str()).unwrap_or(\"—\")}"
}
span { class: "text-muted",
"{format_datetime(s.get(\"last_seen_at\").and_then(|v| v.as_str()).unwrap_or(\"\"))}"
}
}
}
}
}
}
}
}
}
}
+309
View File
@@ -0,0 +1,309 @@
//! Role management.
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::models::{PermissionView, RoleView};
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::matches_query;
use dioxus::prelude::*;
#[component]
pub fn RolesPage() -> Element {
let state = use_context::<AppState>();
let mut roles = use_signal(Vec::<RoleView>::new);
let mut perms = use_signal(Vec::<PermissionView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut page = use_signal(|| 0usize);
let page_size = 10usize;
let mut sort_key = use_signal(|| "name".to_string());
let mut show_create = use_signal(|| false);
let mut name = use_signal(String::new);
let mut description = use_signal(String::new);
let mut edit_role = use_signal(|| Option::<RoleView>::None);
let mut edit_name = use_signal(String::new);
let mut edit_desc = use_signal(String::new);
let mut selected_perms = use_signal(Vec::<String>::new);
let mut delete_role = use_signal(|| Option::<RoleView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
spawn(async move {
match api::list_roles().await {
Ok(list) => {
roles.set(list);
if let Ok(p) = api::list_permissions().await {
perms.set(p.permissions);
}
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
let mut filtered: Vec<RoleView> = roles()
.into_iter()
.filter(|r| {
matches_query(&r.name, &query())
|| r.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() {
"permissions" => b.permissions.len().cmp(&a.permissions.len()),
"users" => b.user_count.cmp(&a.user_count),
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
let total = filtered.len();
let page_items: Vec<RoleView> = filtered
.into_iter()
.skip(page() * page_size)
.take(page_size)
.collect();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Roles".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Roles" }
p { class: "desc", "Manage roles and assigned permissions" }
}
button {
class: "btn btn-primary",
r#type: "button",
onclick: move |_| show_create.set(true),
"+ Create role"
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if roles().is_empty() && query().is_empty() {
EmptyState { title: "No roles".to_string(), description: "Create your first role to get started.", icon: "🛡" }
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true },
ColumnDef { key: "description".into(), label: "Description".into(), sortable: false, visible: true },
ColumnDef { key: "permissions".into(), label: "Permissions".into(), sortable: true, visible: true },
ColumnDef { key: "users".into(), label: "Users".into(), sortable: true, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search roles…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for r in page_items {
{
let role = r.clone();
let role2 = r.clone();
rsx! {
tr { key: "{r.id}",
td { strong { "{r.name}" } }
td { class: "text-secondary",
"{r.description.as_deref().unwrap_or(\"—\")}"
}
td {
span { class: "badge", "{r.permissions.len()}" }
}
td { "{r.user_count}" }
td { style: "text-align: right;",
div { class: "actions",
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
edit_name.set(role.name.clone());
edit_desc.set(role.description.clone().unwrap_or_default());
selected_perms.set(role.permissions.clone());
edit_role.set(Some(role.clone()));
},
"Edit"
}
if r.name != "admin" {
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| delete_role.set(Some(role2.clone())),
"Delete"
}
}
}
}
}
}
}
}
}
}
Modal {
title: "Create role".to_string(),
open: show_create(),
on_close: move |_| show_create.set(false),
TextInput {
label: "Name",
value: name(),
oninput: move |v| name.set(v),
}
TextInput {
label: "Description",
value: description(),
oninput: move |v| description.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| show_create.set(false), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = name();
let d = description();
spawn(async move {
match api::create_role(&n, Some(d.as_str()).filter(|s| !s.is_empty())).await {
Ok(_) => {
state.toast(ToastKind::Success, "Role created");
show_create.set(false);
name.set(String::new());
description.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
Modal {
title: "Edit role".to_string(),
open: edit_role().is_some(),
on_close: move |_| edit_role.set(None),
large: true,
TextInput {
label: "Name",
value: edit_name(),
oninput: move |v| edit_name.set(v),
}
TextInput {
label: "Description",
value: edit_desc(),
oninput: move |v| edit_desc.set(v),
}
h3 { class: "mt-2", "Permissions" }
div { class: "perm-list", style: "max-height: 240px; overflow: auto;",
for p in perms() {
{
let pname = p.name.clone();
let checked = selected_perms().contains(&pname);
rsx! {
Checkbox {
label: format!("{} — {}", p.name, p.description.as_deref().unwrap_or("")),
checked: checked,
onchange: move |v| {
let mut list = selected_perms();
if v {
if !list.contains(&pname) { list.push(pname.clone()); }
} else {
list.retain(|x| x != &pname);
}
selected_perms.set(list);
}
}
}
}
}
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| edit_role.set(None), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
if let Some(role) = edit_role() {
let id = role.id.clone();
let n = edit_name();
let d = edit_desc();
let perms = selected_perms();
spawn(async move {
if let Err(e) = api::update_role(
&id, &n, Some(d.as_str()).filter(|s| !s.is_empty())
).await {
state.toast(ToastKind::Error, e.to_string());
return;
}
match api::set_role_permissions(&id, &perms).await {
Ok(()) => {
state.toast(ToastKind::Success, "Role updated");
edit_role.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
"Save"
}
}
}
ConfirmDialog {
title: "Delete role".to_string(),
message: format!(
"Delete role \"{}\"? This cannot be undone.",
delete_role().as_ref().map(|r| r.name.as_str()).unwrap_or("")
),
open: delete_role().is_some(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
if let Some(r) = delete_role() {
spawn(async move {
match api::delete_role(&r.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "Role deleted");
delete_role.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| delete_role.set(None),
}
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Service account management.
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::widgets::StatusChip;
use crate::models::ServiceAccountView;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::{format_datetime, matches_query};
use dioxus::prelude::*;
#[component]
pub fn ServiceAccountsPage() -> Element {
let state = use_context::<AppState>();
let mut items = use_signal(Vec::<ServiceAccountView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut page = use_signal(|| 0usize);
let page_size = 10usize;
let mut sort_key = use_signal(|| "name".to_string());
let mut show_create = use_signal(|| false);
let mut name = use_signal(String::new);
let mut description = use_signal(String::new);
let mut secret = use_signal(|| Option::<String>::None);
let mut delete_target = use_signal(|| Option::<ServiceAccountView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
spawn(async move {
match api::list_service_accounts().await {
Ok(list) => {
items.set(list);
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
let mut filtered: Vec<_> = items()
.into_iter()
.filter(|s| {
matches_query(&s.name, &query())
|| s.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() {
"status" => b.enabled.cmp(&a.enabled),
"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();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Service Accounts".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Service Accounts" }
p { class: "desc", "Non-human identities for automation and integrations" }
}
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| show_create.set(true),
"+ Create"
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if items().is_empty() && query().is_empty() {
EmptyState {
title: "No service accounts".to_string(),
description: "",
icon: "⚙",
}
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true },
ColumnDef { key: "description".into(), label: "Description".into(), sortable: false, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true },
ColumnDef { key: "created".into(), label: "Created".into(), sortable: true, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for sa in page_items {
{
let item = sa.clone();
let item2 = sa.clone();
let item3 = sa.clone();
rsx! {
tr { key: "{sa.id}",
td { strong { "{sa.name}" } }
td { class: "text-secondary",
"{sa.description.as_deref().unwrap_or(\"—\")}"
}
td {
StatusChip {
status: if sa.enabled { "active".to_string() } else { "disabled".to_string() }
}
}
td { "{format_datetime(&sa.created_at)}" }
td { style: "text-align: right;",
div { class: "actions",
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let id = item.id.clone();
let enabled = !item.enabled;
spawn(async move {
match api::set_service_account_enabled(&id, enabled).await {
Ok(()) => {
state.toast(ToastKind::Success, if enabled { "Enabled" } else { "Disabled" });
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
if sa.enabled { "Disable" } else { "Enable" }
}
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let id = item2.id.clone();
spawn(async move {
match api::rotate_service_account_secret(&id).await {
Ok(raw) => {
secret.set(Some(raw));
state.toast(ToastKind::Success, "Secret rotated");
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Rotate secret"
}
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| delete_target.set(Some(item3.clone())),
"Delete"
}
}
}
}
}
}
}
}
}
Modal {
title: "Create service account".to_string(),
open: show_create(),
on_close: move |_| show_create.set(false),
TextInput {
label: "Name",
value: name(),
oninput: move |v| name.set(v),
}
TextInput {
label: "Description",
value: description(),
oninput: move |v| description.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| show_create.set(false), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = name();
let d = description();
spawn(async move {
match api::create_service_account(
&n, Some(d.as_str()).filter(|s| !s.is_empty())
).await {
Ok(_) => {
state.toast(ToastKind::Success, "Service account created");
show_create.set(false);
name.set(String::new());
description.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
Modal {
title: "Service account secret".to_string(),
open: secret().is_some(),
on_close: move |_| secret.set(None),
div { class: "alert alert-warning",
"Store this secret securely — it will not be shown again."
}
if let Some(raw) = secret() {
div { class: "secret-box",
code { "{raw}" }
button {
class: "btn btn-sm btn-outline", r#type: "button",
onclick: move |_| {
let token = raw.clone();
spawn(async move {
if let Some(window) = web_sys::window() {
let _ = wasm_bindgen_futures::JsFuture::from(
window.navigator().clipboard().write_text(&token)
).await;
}
state.toast(ToastKind::Success, "Copied");
});
},
"Copy"
}
}
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-primary", r#type: "button",
onclick: move |_| secret.set(None), "Done" }
}
}
ConfirmDialog {
title: "Delete service account".to_string(),
message: format!(
"Delete \"{}\"? This cannot be undone.",
delete_target().as_ref().map(|s| s.name.as_str()).unwrap_or("")
),
open: delete_target().is_some(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
if let Some(sa) = delete_target() {
spawn(async move {
match api::delete_service_account(&sa.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "Deleted");
delete_target.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| delete_target.set(None),
}
}
}
+178
View File
@@ -0,0 +1,178 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef};
use crate::models::SessionView;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
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>() + "..." }
}
#[component]
pub fn SessionsPage() -> Element {
let state = use_context::<AppState>();
let mut sessions = use_signal(Vec::<SessionView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut terminate_target = use_signal(|| Option::<SessionView>::None);
let mut show_terminate_others = use_signal(|| false);
let reload = use_callback(move |_: ()| {
loading.set(true);
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); }
}
});
});
use_effect(move || { reload.call(()); });
let filtered: Vec<SessionView> = sessions()
.into_iter()
.filter(|s| {
matches_query(s.ip_address.as_deref().unwrap_or(""), &query())
|| matches_query(s.user_agent.as_deref().unwrap_or(""), &query())
})
.collect();
let total = filtered.len();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Sessions".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Sessions" }
p { class: "desc", "Manage active sessions and connected devices" }
}
button {
class: "btn btn-outline btn-danger",
r#type: "button",
onclick: move |_| show_terminate_others.set(true),
"Terminate other sessions"
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if sessions().is_empty() && query().is_empty() {
EmptyState { title: "No active sessions".to_string(), description: "You have no active sessions.", icon: "🔌" }
} else {
DataTable {
columns: vec![
ColumnDef { key: "current".into(), label: "Current".into(), sortable: false, visible: true },
ColumnDef { key: "ip".into(), label: "IP Address".into(), sortable: false, visible: true },
ColumnDef { key: "browser".into(), label: "Browser/Device".into(), sortable: false, visible: true },
ColumnDef { key: "created".into(), label: "Created".into(), sortable: false, visible: true },
ColumnDef { key: "last_seen".into(), label: "Last Seen".into(), sortable: false, visible: true },
ColumnDef { key: "expires".into(), label: "Expires".into(), sortable: false, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| { query.set(v); },
search_value: query(),
search_placeholder: "Search IP or User Agent…".to_string(),
on_sort: move |_| {},
sort_key: "".to_string(),
on_page: move |_| {},
page: 0,
page_size: total.max(1),
total: total,
toolbar_actions: rsx! {
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for s in filtered {
{
let s2 = s.clone();
rsx! {
tr { key: "{s.id}",
td {
if s.is_current {
span { class: "badge badge-success", "Current" }
}
}
td { "{s.ip_address.as_deref().unwrap_or(\"Unknown\")}" }
td { "{parse_browser(s.user_agent.as_deref().unwrap_or(\"\"))}" }
td { "{format_datetime(&s.created_at)}" }
td { "{format_datetime(&s.last_seen_at)}" }
td { "{format_datetime(&s.expires_at)}" }
td { style: "text-align: right;",
div { class: "actions",
if !s.is_current {
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| terminate_target.set(Some(s2.clone())),
"Terminate"
}
}
}
}
}
}
}
}
}
}
ConfirmDialog {
title: "Terminate Session".to_string(),
message: "Are you sure you want to terminate this session?".to_string(),
open: terminate_target().is_some(),
confirm_label: "Terminate",
danger: true,
on_confirm: move |_| {
if let Some(s) = terminate_target() {
spawn(async move {
match api::terminate_session(&s.id).await {
Ok(_) => {
state.toast(ToastKind::Success, "Session terminated");
terminate_target.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| terminate_target.set(None),
}
ConfirmDialog {
title: "Terminate Other Sessions".to_string(),
message: "This will sign you out on all other devices. Are you sure?".to_string(),
open: show_terminate_others(),
confirm_label: "Terminate all others",
danger: true,
on_confirm: move |_| {
spawn(async move {
match api::terminate_other_sessions().await {
Ok(_) => {
state.toast(ToastKind::Success, "Other sessions terminated");
show_terminate_others.set(false);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
on_cancel: move |_| show_terminate_others.set(false),
}
}
}
+104
View File
@@ -0,0 +1,104 @@
use crate::components::navigation::Breadcrumb;
use crate::routes::Route;
use crate::state::{AppState, ToastKind};
use crate::theme::ThemeMode;
use crate::services::api;
use dioxus::prelude::*;
#[component]
pub fn SettingsPage() -> Element {
let state = use_context::<AppState>();
let theme = state.theme;
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Settings".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Settings" }
p { class: "desc", "Preferences for your account and workspace" }
}
}
div { class: "grid-2",
div { class: "card",
div { class: "card-header", h3 { "Appearance" } }
div { class: "card-body stack",
p { class: "text-secondary", "Theme preference is stored in this browser." }
div { class: "row", style: "gap:0.5rem;flex-wrap:wrap;",
for mode in [ThemeMode::Light, ThemeMode::Dark, ThemeMode::System] {
{
let active = theme() == mode;
let cls = if active { "btn btn-primary" } else { "btn btn-outline" };
rsx! {
button {
class: "{cls}",
r#type: "button",
onclick: move |_| state.set_theme(mode),
"{mode.icon()} {mode.label()}"
}
}
}
}
}
}
}
div { class: "card",
div { class: "card-header", h3 { "System Information" } }
div { class: "card-body stack",
p { class: "text-secondary",
"System configuration and state."
}
div { class: "row", style: "justify-content:space-between;",
span { "Session TTL" }
span { class: "badge", "config.toml" }
}
div { class: "row", style: "justify-content:space-between;",
span { "Password policy" }
span { class: "badge", "config.toml" }
}
div { class: "row", style: "justify-content:space-between;",
span { "Rate limiting" }
span { class: "badge", "enabled" }
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Sessions" } }
div { class: "card-body stack",
p { "Current session is active." }
button {
class: "btn btn-danger",
r#type: "button",
onclick: move |_| {
spawn(async move {
match api::terminate_other_sessions().await {
Ok(_) => state.toast(ToastKind::Success, "Other sessions terminated"),
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Sign out from all other devices"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Security" } }
div { class: "card-body stack",
p { class: "text-secondary", "Passwords must be at least 8 characters long." }
Link {
class: "btn btn-outline",
to: Route::ProfilePage {},
"Change password"
}
}
}
}
}
}
+356
View File
@@ -0,0 +1,356 @@
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::models::TenantView;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::matches_query;
use dioxus::prelude::*;
#[component]
pub fn TenantsPage() -> Element {
let mut state = use_context::<AppState>();
let mut tenants = use_signal(Vec::<TenantView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut sort_key = use_signal(|| "name".to_string());
let mut page = use_signal(|| 0usize);
let page_size = 10usize;
let mut show_create = use_signal(|| false);
let mut new_name = use_signal(String::new);
let mut new_slug = use_signal(String::new);
let mut delete_tenant = use_signal(|| Option::<TenantView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
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); }
}
});
});
use_effect(move || { reload.call(()); });
let filtered = {
let q = query();
let sk = sort_key();
let mut list: Vec<TenantView> = tenants()
.into_iter()
.filter(|t| matches_query(&t.name, &q) || matches_query(&t.slug, &q))
.collect();
list.sort_by(|a, b| match sk.as_str() {
"slug" => a.slug.cmp(&b.slug),
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
list
};
let total = filtered.len();
let page_items: Vec<TenantView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Tenants".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Tenants" }
p { class: "desc", "Manage organizational tenants and directories" }
}
button {
class: "btn btn-primary",
r#type: "button",
onclick: move |_| show_create.set(true),
"+ Create tenant"
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if tenants().is_empty() && query().is_empty() {
EmptyState { title: "No tenants found".to_string(), description: "Create a tenant to get started.", icon: "🏢" }
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Name".into(), sortable: true, visible: true },
ColumnDef { key: "slug".into(), label: "Slug".into(), sortable: true, visible: true },
ColumnDef { key: "description".into(), label: "Description".into(), sortable: false, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search tenants…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for t in page_items {
{
let id = t.id.clone();
let slug = t.slug.clone();
let t_clone = t.clone();
let t2 = t.clone();
rsx! {
tr { key: "{t.id}",
td {
Link {
to: Route::TenantDetailPage { id: t.id.clone() },
strong { "{t.name}" }
}
div { class: "mono text-muted", style: "font-size:11px;", "{t.id}" }
}
td { span { class: "mono", "{slug}" } }
td { "{t.description.clone().unwrap_or_else(|| \"—\".to_string())}" }
td { style: "text-align: right;",
div { class: "actions",
Link {
class: "btn btn-sm btn-outline",
to: Route::TenantDetailPage { id: id.clone() },
"View"
}
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
state.tenant.set(Some(t_clone.clone()));
state.toast(ToastKind::Success, "Switched tenant context");
},
"Switch Context"
}
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| delete_tenant.set(Some(t2.clone())),
"Delete"
}
}
}
}
}
}
}
}
}
Modal {
title: "Create tenant".to_string(),
open: show_create(),
on_close: move |_| show_create.set(false),
TextInput {
label: "Name",
value: new_name(),
oninput: move |v: String| {
new_name.set(v.clone());
new_slug.set(v.to_lowercase().replace(" ", "-").chars().filter(|c| c.is_alphanumeric() || *c == '-').collect());
},
}
TextInput {
label: "Slug",
value: new_slug(),
oninput: move |v| new_slug.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button", onclick: move |_| show_create.set(false), "Cancel" }
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = new_name();
let s = new_slug();
spawn(async move {
match api::create_tenant(&n, Some(s.as_str()).filter(|s| !s.is_empty())).await {
Ok(_) => {
state.toast(ToastKind::Success, "Tenant created");
show_create.set(false);
new_name.set(String::new());
new_slug.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
ConfirmDialog {
title: "Delete tenant".to_string(),
message: format!("Delete tenant \"{}\"? This cannot be undone.", delete_tenant().as_ref().map(|t| t.name.as_str()).unwrap_or("")),
open: delete_tenant().is_some(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
if let Some(t) = delete_tenant() {
let total_tenants = tenants().len();
if total_tenants <= 1 {
state.toast(ToastKind::Error, "Cannot delete the last tenant");
delete_tenant.set(None);
return;
}
spawn(async move {
match api::delete_tenant(&t.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "Tenant deleted");
delete_tenant.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| delete_tenant.set(None),
}
}
}
#[component]
pub fn TenantDetailPage(id: String) -> Element {
let state = use_context::<AppState>();
let mut tenant = use_signal(|| Option::<TenantView>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut edit_name = use_signal(String::new);
let mut edit_slug = use_signal(String::new);
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);
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));
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Tenants".to_string(), Some(Route::TenantsPage {})),
(tenant().map(|t| t.name.clone()).unwrap_or(id.clone()), None),
]}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
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();
rsx! {
div { class: "page-header",
div {
h1 { "{t.name}" }
p { class: "desc", "Tenant configuration and 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),
}
button {
class: "btn btn-primary mt-2",
r#type: "button",
onclick: move |_| {
let n = edit_name();
let s = edit_slug();
let tid = tid.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: tid2 == "00000000-0000-0000-0000-000000000001",
onclick: move |_| confirm_delete.set(true),
"Delete Tenant"
}
}
}
}
ConfirmDialog {
title: "Delete tenant".to_string(),
message: format!("Delete tenant \"{}\"? This cannot be undone.", t.name),
open: confirm_delete(),
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
let tid = tid3.clone();
spawn(async move {
match api::delete_tenant(&tid).await {
Ok(_) => {
state.toast(ToastKind::Success, "Tenant deleted");
let _ = dioxus_router::hooks::use_navigator().replace(Route::TenantsPage {});
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
on_cancel: move |_| confirm_delete.set(false),
}
}
}
}
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Personal API token management.
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::TextInput;
use crate::components::navigation::Breadcrumb;
use crate::components::tables::SearchBox;
use crate::components::widgets::StatusChip;
use crate::models::{CreateTokenResponse, TokenView};
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::{format_datetime, matches_query};
use dioxus::prelude::*;
#[component]
pub fn TokensPage() -> Element {
let state = use_context::<AppState>();
let mut tokens = use_signal(Vec::<TokenView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut show_revoked = use_signal(|| false);
let mut show_create = use_signal(|| false);
let mut name = use_signal(String::new);
let mut created = use_signal(|| Option::<CreateTokenResponse>::None);
let mut revoke_target = use_signal(|| Option::<TokenView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
spawn(async move {
match api::list_tokens().await {
Ok(list) => {
tokens.set(list);
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
let filtered: Vec<TokenView> = tokens()
.into_iter()
.filter(|t| show_revoked() || !t.revoked)
.filter(|t| matches_query(&t.name, &query()))
.collect();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("API Tokens".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "API Tokens" }
p { class: "desc", "Personal access tokens for programmatic API access" }
}
button {
class: "btn btn-primary",
r#type: "button",
onclick: move |_| { created.set(None); show_create.set(true); },
"+ Create token"
}
}
div { class: "toolbar",
SearchBox {
value: query(),
oninput: move |v| query.set(v),
placeholder: "Search tokens…",
}
label { class: "checkbox-row", style: "margin:0;",
input {
r#type: "checkbox",
checked: show_revoked(),
onchange: move |e| show_revoked.set(e.checked()),
}
span { "Show revoked" }
}
div { class: "spacer" }
button { class: "btn btn-outline", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if filtered.is_empty() {
EmptyState {
title: "No tokens".to_string(),
description: "Create a personal access token to use the API.",
icon: "🔑",
}
} else {
div { class: "table-wrap",
table { class: "data-table",
thead {
tr {
th { "Name" }
th { "Status" }
th { "Last used" }
th { "Expires" }
th { "Created" }
th { style: "text-align:right;", "Actions" }
}
}
tbody {
for t in filtered {
{
let tok = t.clone();
rsx! {
tr { key: "{t.id}",
td { strong { "{t.name}" } }
td {
if t.revoked {
StatusChip { status: "revoked" }
} else {
StatusChip { status: "active" }
}
}
td {
"{t.last_used_at.as_deref().map(format_datetime).unwrap_or_else(|| \"—\".to_string())}"
}
td {
"{t.expires_at.as_deref().map(format_datetime).unwrap_or_else(|| \"never\".to_string())}"
}
td { "{format_datetime(&t.created_at)}" }
td {
div { class: "actions",
if !t.revoked {
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| revoke_target.set(Some(tok.clone())),
"Revoke"
}
}
}
}
}
}
}
}
}
}
}
}
Modal {
title: if created().is_some() { "Token created".to_string() } else { "Create token".to_string() },
open: show_create(),
on_close: move |_| show_create.set(false),
if let Some(c) = created() {
div { class: "alert alert-warning",
"{c.warning.as_deref().unwrap_or(\"Store this token securely — it will not be shown again.\")}"
}
div { class: "secret-box",
code { "{c.raw_token}" }
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let token = c.raw_token.clone();
spawn(async move {
if let Some(window) = web_sys::window() {
let nav = window.navigator();
let clipboard = nav.clipboard();
let _ = wasm_bindgen_futures::JsFuture::from(
clipboard.write_text(&token)
).await;
}
state.toast(ToastKind::Success, "Copied to clipboard");
});
},
"Copy"
}
}
} else {
TextInput {
label: "Token name",
value: name(),
oninput: move |v| name.set(v),
placeholder: "ci-deploy",
hint: "A friendly label to identify this token.",
}
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button {
class: "btn btn-outline", r#type: "button",
onclick: move |_| show_create.set(false),
if created().is_some() { "Done" } else { "Cancel" }
}
if created().is_none() {
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| {
let n = name();
spawn(async move {
match api::create_token(&n).await {
Ok(c) => {
created.set(Some(c));
name.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
}
ConfirmDialog {
title: "Revoke token".to_string(),
message: format!(
"Revoke token \"{}\"? Applications using it will lose access immediately.",
revoke_target().as_ref().map(|t| t.name.as_str()).unwrap_or("")
),
open: revoke_target().is_some(),
confirm_label: "Revoke",
danger: true,
on_confirm: move |_| {
if let Some(t) = revoke_target() {
spawn(async move {
match api::revoke_token(&t.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "Token revoked");
revoke_target.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| revoke_target.set(None),
}
}
}
+493
View File
@@ -0,0 +1,493 @@
//! User management UI.
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::widgets::StatusChip;
use crate::models::UserView;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::utils::{format_datetime, matches_query};
use dioxus::prelude::*;
#[component]
pub fn UsersPage() -> Element {
let state = use_context::<AppState>();
let mut users = use_signal(Vec::<UserView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut query = use_signal(String::new);
let mut status_filter = use_signal(|| "all".to_string());
let mut sort_key = use_signal(|| "username".to_string());
let mut page = use_signal(|| 0usize);
let page_size = 10usize;
let mut show_create = use_signal(|| false);
let mut new_user = use_signal(String::new);
let mut new_pass = use_signal(String::new);
let mut confirm_delete = use_signal(|| Option::<UserView>::None);
let reload = use_callback(move |_: ()| {
loading.set(true);
error.set(None);
spawn(async move {
match api::list_users().await {
Ok(list) => {
users.set(list);
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
let filtered = {
let q = query();
let sf = status_filter();
let sk = sort_key();
let mut list: Vec<UserView> = users()
.into_iter()
.filter(|u| matches_query(&u.username, &q))
.filter(|u| sf == "all" || u.status == sf)
.collect();
list.sort_by(|a, b| match sk.as_str() {
"status" => a.status.cmp(&b.status),
"created" => b.created_at.cmp(&a.created_at),
_ => a.username.to_lowercase().cmp(&b.username.to_lowercase()),
});
list
};
let total = filtered.len();
let page_items: Vec<UserView> = filtered
.into_iter()
.skip(page() * page_size)
.take(page_size)
.collect();
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Users".to_string(), None),
]}
div { class: "page-header",
div {
h1 { "Users" }
p { class: "desc", "Create, disable, and manage user accounts" }
}
button {
class: "btn btn-primary",
r#type: "button",
onclick: move |_| show_create.set(true),
"+ Create user"
}
}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState {
message: err,
on_retry: move |_| reload.call(())
}
} else if users().is_empty() && query().is_empty() && status_filter() == "all" {
EmptyState {
title: "No users found".to_string(),
description: "Get started by creating your first user.",
icon: "👥",
}
} else {
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: "last_login".into(), label: "Last login".into(), sortable: false, visible: true },
ColumnDef { key: "created".into(), label: "Created".into(), sortable: true, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| { query.set(v); page.set(0); },
search_value: query(),
search_placeholder: "Search username…".to_string(),
on_sort: move |k| sort_key.set(k),
sort_key: sort_key(),
on_page: move |p| page.set(p),
page: page(),
page_size: page_size,
total: total,
toolbar_actions: rsx! {
select {
class: "form-control",
style: "width: auto;",
value: "{status_filter()}",
onchange: move |e| { status_filter.set(e.value()); page.set(0); },
option { value: "all", "All statuses" }
option { value: "active", "Active" }
option { value: "disabled", "Disabled" }
option { value: "locked", "Locked" }
}
button {
class: "btn btn-outline",
r#type: "button",
onclick: move |_| reload.call(()),
"Refresh"
}
},
for u in page_items {
{
let id = u.id.clone();
let id2 = u.id.clone();
let id3 = u.id.clone();
let status = u.status.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 { StatusChip { status: u.status.clone() } }
td {
"{u.last_login_at.as_deref().map(format_datetime).unwrap_or_else(|| \"—\".to_string())}"
}
td { "{format_datetime(&u.created_at)}" }
td { style: "text-align: right;",
div { class: "actions",
Link {
class: "btn btn-sm btn-outline",
to: Route::UserDetailPage { id: id.clone() },
"View"
}
if status == "active" {
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let id = id2.clone();
spawn(async move {
match api::update_user_status(&id, "disabled").await {
Ok(_) => {
state.toast(ToastKind::Success, "User disabled");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Disable"
}
} else {
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let id = id2.clone();
spawn(async move {
match api::update_user_status(&id, "active").await {
Ok(_) => {
state.toast(ToastKind::Success, "User enabled");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Enable"
}
}
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| {
if let Some(u) = users().into_iter().find(|x| x.id == id3) {
confirm_delete.set(Some(u));
}
},
"Delete"
}
}
}
}
}
}
}
}
}
// Create modal
Modal {
title: "Create user".to_string(),
open: show_create(),
on_close: move |_| show_create.set(false),
TextInput {
label: "Username",
value: new_user(),
oninput: move |v| new_user.set(v),
required: true,
}
PasswordInput {
label: "Password",
value: new_pass(),
oninput: move |v| new_pass.set(v),
required: true,
autocomplete: "new-password",
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button {
class: "btn btn-outline",
r#type: "button",
onclick: move |_| show_create.set(false),
"Cancel"
}
button {
class: "btn btn-primary",
r#type: "button",
onclick: move |_| {
let u = new_user();
let p = new_pass();
spawn(async move {
match api::create_user(&u, &p).await {
Ok(_) => {
state.toast(ToastKind::Success, "User created");
show_create.set(false);
new_user.set(String::new());
new_pass.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Create"
}
}
}
ConfirmDialog {
title: "Disable user".to_string(),
message: format!(
"Disable user \"{}\"? They will no longer be able to sign in.",
confirm_delete().as_ref().map(|u| u.username.as_str()).unwrap_or("")
),
open: confirm_delete().is_some(),
confirm_label: "Disable",
danger: true,
on_confirm: move |_| {
if let Some(u) = confirm_delete() {
spawn(async move {
match api::delete_user(&u.id).await {
Ok(()) => {
state.toast(ToastKind::Success, "User disabled");
confirm_delete.set(None);
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| confirm_delete.set(None),
}
}
}
#[component]
pub fn UserDetailPage(id: String) -> Element {
let state = use_context::<AppState>();
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 error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut new_pass = use_signal(String::new);
let mut assign_role = use_signal(String::new);
let user_id = id.clone();
let reload = use_callback(move |_: ()| {
let id = user_id.clone();
loading.set(true);
spawn(async move {
match api::get_user(&id).await {
Ok(u) => {
user.set(Some(u));
let r = api::list_user_roles(&id).await.unwrap_or_default();
roles.set(r);
if let Ok(ar) = api::list_roles().await {
all_roles.set(ar);
}
loading.set(false);
}
Err(e) => {
error.set(Some(e.to_string()));
loading.set(false);
}
}
});
});
use_effect(move || { reload.call(()); });
rsx! {
Breadcrumb { items: vec![
("Dashboard".to_string(), Some(Route::DashboardPage {})),
("Users".to_string(), Some(Route::UsersPage {})),
(id.clone(), None),
]}
if loading() {
LoadingSpinner {}
} else if let Some(err) = error() {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if let Some(u) = user() {
{
let user_id = u.id.clone();
let user_id_reset = user_id.clone();
let user_id_assign = user_id.clone();
rsx! {
div { class: "page-header",
div {
h1 { "{u.username}" }
p { class: "desc mono", "{u.id}" }
}
StatusChip { status: u.status.clone() }
}
div { class: "grid-2",
div { class: "card",
div { class: "card-header", h3 { "Account" } }
div { class: "card-body stack",
div { "Status: " StatusChip { status: u.status.clone() } }
div { class: "text-secondary",
"Created: {format_datetime(&u.created_at)}"
}
div { class: "text-secondary",
"Last login: {u.last_login_at.as_deref().map(format_datetime).unwrap_or_else(|| \"—\".to_string())}"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Reset password" } }
div { class: "card-body",
PasswordInput {
label: "New password",
value: new_pass(),
oninput: move |v| new_pass.set(v),
autocomplete: "new-password",
}
p { class: "form-hint",
"Minimum 8 characters (12 if the user is an admin). Avoid common sequences like \"password\"."
}
button {
class: "btn btn-primary",
r#type: "button",
disabled: new_pass().len() < 8,
onclick: move |_| {
let id = user_id_reset.clone();
let p = new_pass();
if p.len() < 8 {
state.toast(ToastKind::Error, "Password must be at least 8 characters");
return;
}
spawn(async move {
match api::reset_user_password(&id, &p).await {
Ok(()) => {
state.toast(ToastKind::Success, "Password reset");
new_pass.set(String::new());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Reset password"
}
}
}
div { class: "card",
div { class: "card-header", h3 { "Roles" } }
div { class: "card-body",
if roles().is_empty() {
p { class: "text-muted", "No roles assigned." }
} else {
div { class: "stack",
for r in roles() {
{
let role_name = r.name.clone();
let uid = user_id.clone();
rsx! {
div { class: "row", style: "justify-content:space-between;",
span { class: "badge badge-accent", "{r.name}" }
button {
class: "btn btn-sm btn-ghost",
r#type: "button",
onclick: move |_| {
let role_name = role_name.clone();
let uid = uid.clone();
spawn(async move {
match api::remove_user_role(&uid, &role_name).await {
Ok(()) => {
state.toast(ToastKind::Success, "Role removed");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Remove"
}
}
}
}
}
}
}
div { class: "row mt-2",
select {
class: "form-control",
value: "{assign_role()}",
onchange: move |e| assign_role.set(e.value()),
option { value: "", "Assign role…" }
for r in all_roles() {
option { value: "{r.name}", "{r.name}" }
}
}
button {
class: "btn btn-outline",
r#type: "button",
disabled: assign_role().is_empty(),
onclick: move |_| {
let role = assign_role();
let uid = user_id_assign.clone();
spawn(async move {
match api::assign_user_role(&uid, &role).await {
Ok(()) => {
state.toast(ToastKind::Success, "Role assigned");
assign_role.set(String::new());
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
"Assign"
}
}
}
}
}
}
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Application routes (permission-aware navigation is handled in the sidebar).
use crate::components::layout::AppLayout;
use crate::pages::{
about::AboutPage,
applications::ApplicationsPage,
audit::AuditPage,
auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
dashboard::DashboardPage,
groups::{GroupsPage, GroupDetailPage},
not_found::NotFoundPage,
permissions::PermissionsPage,
profile::ProfilePage,
roles::RolesPage,
service_accounts::ServiceAccountsPage,
sessions::SessionsPage,
settings::SettingsPage,
tenants::{TenantsPage, TenantDetailPage},
tokens::TokensPage,
users::{UserDetailPage, UsersPage},
};
use dioxus::prelude::*;
#[derive(Clone, Routable, Debug, PartialEq)]
#[rustfmt::skip]
pub enum Route {
#[route("/login")]
LoginPage {},
#[route("/unauthorized")]
UnauthorizedPage {},
#[route("/forbidden")]
ForbiddenPage {},
// Root: public landing that sends users to login or dashboard.
#[route("/")]
HomeRedirect {},
#[layout(AppLayout)]
#[route("/dashboard")]
DashboardPage {},
#[route("/profile")]
ProfilePage {},
#[route("/tenants")]
TenantsPage {},
#[route("/tenants/:id")]
TenantDetailPage { id: String },
#[route("/users")]
UsersPage {},
#[route("/users/:id")]
UserDetailPage { id: String },
#[route("/groups")]
GroupsPage {},
#[route("/groups/:id")]
GroupDetailPage { id: String },
#[route("/roles")]
RolesPage {},
#[route("/permissions")]
PermissionsPage {},
#[route("/tokens")]
TokensPage {},
#[route("/sessions")]
SessionsPage {},
#[route("/applications")]
ApplicationsPage {},
#[route("/service-accounts")]
ServiceAccountsPage {},
#[route("/audit")]
AuditPage {},
#[route("/settings")]
SettingsPage {},
#[route("/about")]
AboutPage {},
#[end_layout]
#[route("/:..route")]
NotFoundPage { route: Vec<String> },
}
/// `/` → login when signed out, dashboard when signed in.
#[component]
fn HomeRedirect() -> Element {
let state = use_context::<crate::state::AppState>();
let auth = state.auth;
let nav = use_navigator();
use_effect(move || match auth() {
crate::state::BootstrapState::Authenticated(_) => {
nav.replace(Route::DashboardPage {});
}
crate::state::BootstrapState::Anonymous => {
nav.replace(Route::LoginPage {});
}
crate::state::BootstrapState::Initializing | crate::state::BootstrapState::Failed(_) => {}
});
rsx! {
div { class: "loading-center", style: "min-height: 100vh;",
div { class: "spinner spinner-lg" }
span {
match auth() {
crate::state::BootstrapState::Authenticated(_) => "Opening dashboard…",
crate::state::BootstrapState::Anonymous => "Opening sign-in…",
_ => "Starting nx9-auth…",
}
}
}
}
}
+525
View File
@@ -0,0 +1,525 @@
//! HTTP client wrapping the `/api/v1` surface.
//!
//! Authentication:
//! 1. Browser cookies (`fetch_credentials_include`) for the HttpOnly session cookie
//! 2. `Authorization: Bearer <session_token>` from sessionStorage (login body fallback)
//!
//! Frontend permission checks are presentation-only — the backend is authoritative.
//!
//! Note: reqwest on WASM requires **absolute** URLs.
use crate::models::*;
use crate::services::session;
use reqwest::{Client, StatusCode};
use serde::Serialize;
use serde_json::Value;
/// API base path (same-origin).
const API_PREFIX: &str = "/api/v1";
/// Client-side API error.
#[derive(Debug, Clone, PartialEq)]
pub enum ApiError {
Unauthorized,
Forbidden,
NotFound,
InvalidInput(String),
Network(String),
Server(String),
Other(String),
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unauthorized => write!(f, "Invalid username or password."),
Self::Forbidden => write!(f, "You do not have permission to do that"),
Self::NotFound => write!(f, "Resource not found"),
Self::InvalidInput(m) => write!(f, "{m}"),
Self::Network(m) => write!(f, "Network error: {m}"),
Self::Server(m) => write!(f, "{m}"),
Self::Other(m) => write!(f, "{m}"),
}
}
}
/// Browser origin, e.g. `http://127.0.0.1:8655`.
fn origin() -> String {
web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default()
}
/// Build an absolute API URL (required by reqwest-wasm).
fn api_url(path: &str) -> String {
format!("{}{}{}", origin(), API_PREFIX, path)
}
fn client() -> Client {
Client::new()
}
/// Attach credentials + optional bearer session token.
fn authorize(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
// let builder = builder.fetch_credentials_include();
if let Some(token) = session::load_access_token() {
builder.header("Authorization", format!("Bearer {token}"))
} else {
builder
}
}
async fn handle<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T, ApiError> {
let status = resp.status();
if status == StatusCode::UNAUTHORIZED {
// Stale client token — drop it so the next login is clean.
session::clear();
return Err(ApiError::Unauthorized);
}
if status == StatusCode::FORBIDDEN {
return Err(ApiError::Forbidden);
}
if status == StatusCode::NOT_FOUND {
return Err(ApiError::NotFound);
}
let text = resp
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if !status.is_success() {
if let Ok(body) = serde_json::from_str::<ApiErrorBody>(&text) {
if status == StatusCode::UNPROCESSABLE_ENTITY {
return Err(ApiError::InvalidInput(body.error));
}
return Err(ApiError::Server(body.error));
}
return Err(ApiError::Server(format!("HTTP {status}: {text}")));
}
serde_json::from_str(&text).map_err(|e| ApiError::Other(format!("decode error: {e}: {text}")))
}
async fn get<T: serde::de::DeserializeOwned>(path: &str) -> Result<T, ApiError> {
let url = api_url(path);
let resp = authorize(client().get(&url))
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
handle(resp).await
}
async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = api_url(path);
let resp = authorize(client().post(&url).json(body))
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
handle(resp).await
}
async fn patch_json<B: Serialize, T: serde::de::DeserializeOwned>(
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = api_url(path);
let resp = authorize(client().patch(&url).json(body))
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
handle(resp).await
}
async fn put_json<B: Serialize, T: serde::de::DeserializeOwned>(
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = api_url(path);
let resp = authorize(client().put(&url).json(body))
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
handle(resp).await
}
async fn delete_json<T: serde::de::DeserializeOwned>(path: &str) -> Result<T, ApiError> {
let url = api_url(path);
let resp = authorize(client().delete(&url))
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
handle(resp).await
}
// ── Auth ──────────────────────────────────────────────────────────────────────
/// Secure login response (POST JSON only — never query parameters).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct LoginResponse {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub user: Option<serde_json::Value>,
}
/// POST /api/v1/auth/login with JSON body.
///
/// Credentials are never placed in the URL, query string, or fragment.
pub async fn login(username: &str, password: &str) -> Result<LoginResponse, ApiError> {
// Do not send a stale Authorization header on login.
session::clear();
let body = serde_json::json!({
"username": username,
"password": password,
});
let url = api_url("/auth/login");
let resp = client()
.post(&url)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
let parsed: LoginResponse = handle(resp).await?;
if !parsed.access_token.is_empty() {
session::save_access_token(&parsed.access_token);
}
if let Some(ref rt) = parsed.refresh_token {
if !rt.is_empty() {
session::save_refresh_token(rt);
}
}
Ok(parsed)
}
pub async fn logout() -> Result<(), ApiError> {
let result = post_json::<_, Value>("/auth/logout", &serde_json::json!({})).await;
session::clear();
result.map(|_| ())
}
pub async fn me() -> Result<Option<MeResponse>, ApiError> {
let url = api_url("/auth/me");
let resp = authorize(client().get(&url))
.send()
.await
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
if resp.status() == StatusCode::UNAUTHORIZED {
session::clear();
return Ok(None);
}
let body: MeResponse = handle(resp).await?;
Ok(Some(body))
}
// ── Dashboard / Profile ───────────────────────────────────────────────────────
pub async fn dashboard() -> Result<DashboardResponse, ApiError> {
get("/dashboard").await
}
pub async fn get_profile() -> Result<ProfileResponse, ApiError> {
get("/profile").await
}
pub async fn list_tenants() -> Result<Vec<TenantView>, ApiError> {
let r: TenantsResponse = get("/tenants").await?;
Ok(r.tenants)
}
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
}
pub async fn change_password(current: &str, new_password: &str) -> Result<(), ApiError> {
let body = serde_json::json!({
"current_password": current,
"new_password": new_password,
});
let _: Value = post_json("/profile/password", &body).await?;
Ok(())
}
// ── Users ─────────────────────────────────────────────────────────────────────
pub async fn list_users() -> Result<Vec<UserView>, ApiError> {
let r: UsersResponse = get("/users").await?;
Ok(r.users)
}
pub async fn get_user(id: &str) -> Result<UserView, ApiError> {
let r: Value = get(&format!("/users/{id}")).await?;
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn create_user(username: &str, password: &str) -> Result<UserView, ApiError> {
let body = serde_json::json!({ "username": username, "password": password });
let r: Value = post_json("/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 update_user_status(id: &str, status: &str) -> Result<UserView, ApiError> {
let body = serde_json::json!({ "status": status });
let r: Value = patch_json(&format!("/users/{id}"), &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 delete_user(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/users/{id}")).await?;
Ok(())
}
pub async fn reset_user_password(id: &str, password: &str) -> Result<(), ApiError> {
let body = serde_json::json!({ "password": password });
let _: Value = post_json(&format!("/users/{id}/reset-password"), &body).await?;
Ok(())
}
pub async fn list_user_roles(id: &str) -> Result<Vec<RoleView>, ApiError> {
let r: Value = get(&format!("/users/{id}/roles")).await?;
serde_json::from_value(r.get("roles").cloned().unwrap_or(Value::Array(vec![])))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn assign_user_role(user_id: &str, role: &str) -> Result<(), ApiError> {
let body = serde_json::json!({ "role": role });
let _: Value = post_json(&format!("/users/{user_id}/roles"), &body).await?;
Ok(())
}
pub async fn remove_user_role(user_id: &str, role: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/users/{user_id}/roles/{role}")).await?;
Ok(())
}
// ── Roles / Permissions ───────────────────────────────────────────────────────
pub async fn list_roles() -> Result<Vec<RoleView>, ApiError> {
let r: RolesResponse = get("/roles").await?;
Ok(r.roles)
}
pub async fn create_role(name: &str, description: Option<&str>) -> Result<RoleView, ApiError> {
let body = serde_json::json!({ "name": name, "description": description });
let r: Value = post_json("/roles", &body).await?;
serde_json::from_value(r.get("role").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn update_role(
id: &str,
name: &str,
description: Option<&str>,
) -> Result<RoleView, ApiError> {
let body = serde_json::json!({ "name": name, "description": description });
let r: Value = patch_json(&format!("/roles/{id}"), &body).await?;
serde_json::from_value(r.get("role").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn delete_role(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/roles/{id}")).await?;
Ok(())
}
pub async fn set_role_permissions(id: &str, permissions: &[String]) -> Result<(), ApiError> {
let body = serde_json::json!({ "permissions": permissions });
let _: Value = put_json(&format!("/roles/{id}/permissions"), &body).await?;
Ok(())
}
pub async fn list_permissions() -> Result<PermissionsResponse, ApiError> {
get("/permissions").await
}
// ── Tokens ────────────────────────────────────────────────────────────────────
pub async fn list_tokens() -> Result<Vec<TokenView>, ApiError> {
let r: TokensResponse = get("/tokens").await?;
Ok(r.tokens)
}
pub async fn create_token(name: &str) -> Result<CreateTokenResponse, ApiError> {
let body = serde_json::json!({ "name": name });
post_json("/tokens", &body).await
}
pub async fn revoke_token(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/tokens/{id}")).await?;
Ok(())
}
// ── Applications ──────────────────────────────────────────────────────────────
pub async fn list_applications() -> Result<Vec<ApplicationView>, ApiError> {
let r: ApplicationsResponse = get("/applications").await?;
Ok(r.applications)
}
pub async fn create_application(name: &str, slug: &str) -> Result<ApplicationView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug });
let r: Value = post_json("/applications", &body).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn update_application(
id: &str,
name: &str,
slug: &str,
enabled: bool,
) -> Result<ApplicationView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug, "enabled": enabled });
let r: Value = patch_json(&format!("/applications/{id}"), &body).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn delete_application(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/applications/{id}")).await?;
Ok(())
}
// ── Service accounts ──────────────────────────────────────────────────────────
pub async fn list_service_accounts() -> Result<Vec<ServiceAccountView>, ApiError> {
let r: ServiceAccountsResponse = get("/service-accounts").await?;
Ok(r.service_accounts)
}
pub async fn create_service_account(
name: &str,
description: Option<&str>,
) -> Result<ServiceAccountView, ApiError> {
let body = serde_json::json!({ "name": name, "description": description });
let r: Value = post_json("/service-accounts", &body).await?;
serde_json::from_value(r.get("service_account").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn set_service_account_enabled(id: &str, enabled: bool) -> Result<(), ApiError> {
let body = serde_json::json!({ "enabled": enabled });
let _: Value = patch_json(&format!("/service-accounts/{id}"), &body).await?;
Ok(())
}
pub async fn delete_service_account(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/service-accounts/{id}")).await?;
Ok(())
}
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?;
Ok(r.get("raw_secret")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
// ── Audit ─────────────────────────────────────────────────────────────────────
pub async fn list_audit(query: &str) -> Result<AuditResponse, ApiError> {
let path = if query.is_empty() {
"/audit".to_string()
} else {
format!("/audit?{query}")
};
get(&path).await
}
// ── Sessions ──────────────────────────────────────────────────────────────────
pub async fn list_sessions() -> Result<SessionsResponse, ApiError> {
get("/sessions").await
}
pub async fn terminate_session(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/sessions/{id}")).await?;
Ok(())
}
pub async fn terminate_other_sessions() -> Result<(), ApiError> {
let _: Value = delete_json("/sessions/others").await?;
Ok(())
}
// ── Groups ────────────────────────────────────────────────────────────────────
pub async fn list_groups() -> Result<Vec<GroupView>, ApiError> {
let r: GroupsResponse = get("/groups").await?;
Ok(r.groups)
}
pub async fn create_group(name: &str, description: Option<&str>) -> Result<GroupView, ApiError> {
let body = serde_json::json!({ "name": name, "description": description });
let r: Value = post_json("/groups", &body).await?;
serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
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> {
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))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn delete_group(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/groups/{id}")).await?;
Ok(())
}
pub async fn add_group_member(group_id: &str, user_id: &str) -> Result<(), ApiError> {
let body = serde_json::json!({ "user_id": user_id });
let _: Value = post_json(&format!("/groups/{group_id}/members"), &body).await?;
Ok(())
}
pub async fn remove_group_member(group_id: &str, user_id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/groups/{group_id}/members/{user_id}")).await?;
Ok(())
}
// ── Tenants (complete) ────────────────────────────────────────────────────────
pub async fn create_tenant(name: &str, slug: Option<&str>) -> Result<TenantView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug });
let r: Value = post_json("/tenants", &body).await?;
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
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))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn get_tenant(id: &str) -> Result<TenantView, ApiError> {
let r: Value = get(&format!("/tenants/{id}")).await?;
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn delete_tenant(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/tenants/{id}")).await?;
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
//! Typed API client for the nx9-auth REST API.
pub mod api;
pub mod session;
+37
View File
@@ -0,0 +1,37 @@
//! Client-side access-token storage (SPA auth).
//!
//! Login uses **POST JSON only** — never query parameters.
//! The server returns an opaque `access_token` (and sets an HttpOnly cookie).
//! We store the access token in `sessionStorage` and send
//! `Authorization: Bearer …` on subsequent requests.
//!
//! Passwords are never stored on the client.
use gloo_storage::{SessionStorage, Storage};
const ACCESS_KEY: &str = "nx9_access_token";
const REFRESH_KEY: &str = "nx9_refresh_token";
pub fn save_access_token(token: &str) {
let _ = SessionStorage::set(ACCESS_KEY, token);
}
pub fn save_refresh_token(token: &str) {
let _ = SessionStorage::set(REFRESH_KEY, token);
}
pub fn load_access_token() -> Option<String> {
SessionStorage::get::<String>(ACCESS_KEY).ok()
}
#[allow(dead_code)]
pub fn load_refresh_token() -> Option<String> {
SessionStorage::get::<String>(REFRESH_KEY).ok()
}
pub fn clear() {
SessionStorage::delete(ACCESS_KEY);
SessionStorage::delete(REFRESH_KEY);
}
+277
View File
@@ -0,0 +1,277 @@
//! 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 dioxus::prelude::*;
/// Toast notification.
#[derive(Debug, Clone, PartialEq)]
pub struct Toast {
pub id: u64,
pub kind: ToastKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ToastKind {
Success,
Error,
Info,
Warning,
}
impl ToastKind {
pub fn css(self) -> &'static str {
match self {
Self::Success => "success",
Self::Error => "error",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
/// Shared app state provided at the root.
#[derive(Clone, Copy)]
pub struct AppState {
pub auth: Signal<BootstrapState>,
pub theme: Signal<ThemeMode>,
pub toasts: Signal<Vec<Toast>>,
pub toast_seq: Signal<u64>,
pub sidebar_collapsed: Signal<bool>,
pub mobile_nav_open: Signal<bool>,
pub nav_registry: Signal<NavigationRegistry>,
pub tenant: Signal<Option<crate::models::TenantView>>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum BootstrapState {
#[default]
Initializing,
Anonymous,
Authenticated(MeResponse),
Failed(String),
}
impl BootstrapState {
pub fn is_authenticated(&self) -> bool {
matches!(self, Self::Authenticated(_))
}
pub fn me(&self) -> Option<&MeResponse> {
match self {
Self::Authenticated(m) => Some(m),
_ => None,
}
}
pub fn has_permission(&self, perm: &str) -> bool {
self.me()
.map(|m| m.permissions.iter().any(|p| p == perm))
.unwrap_or(false)
}
pub fn has_any_permission(&self, perms: &[&str]) -> bool {
self.me()
.map(|m| perms.iter().any(|p| m.permissions.iter().any(|x| x == p)))
.unwrap_or(false)
}
pub fn is_adminish(&self) -> bool {
self.has_any_permission(&[
"roles:manage",
"audit:view",
"users:create",
"users:update",
"users:delete",
]) || self
.me()
.map(|m| m.roles.iter().any(|r| r == "admin"))
.unwrap_or(false)
}
pub fn username(&self) -> &str {
self.me().map(|m| m.user.username.as_str()).unwrap_or("")
}
}
impl AppState {
/// Create and provide app state. Must be called from a component body.
pub fn provide() -> Self {
let theme_mode = theme::load_theme();
theme::apply_theme(theme_mode);
let mut registry = NavigationRegistry::new();
registry.register_section(
"Workspace",
vec![
NavigationItem {
id: "dashboard".into(),
title: "Dashboard".into(),
icon: "◫".into(),
route: Route::DashboardPage {},
permission: None,
children: vec![],
},
NavigationItem {
id: "tenants".into(),
title: "Tenants".into(),
icon: "🏢".into(),
route: Route::TenantsPage {},
permission: None,
children: vec![],
},
NavigationItem {
id: "applications".into(),
title: "Applications".into(),
icon: "▦".into(),
route: Route::ApplicationsPage {},
permission: None,
children: vec![],
},
]
);
registry.register_section(
"Security",
vec![
NavigationItem {
id: "users".into(),
title: "Users".into(),
icon: "👥".into(),
route: Route::UsersPage {},
permission: Some("users:read".into()),
children: vec![],
},
NavigationItem {
id: "groups".into(),
title: "Groups".into(),
icon: "👪".into(),
route: Route::GroupsPage {},
permission: Some("groups:read".into()),
children: vec![],
},
NavigationItem {
id: "roles".into(),
title: "Roles".into(),
icon: "🛡".into(),
route: Route::RolesPage {},
permission: Some("roles:manage".into()),
children: vec![],
},
NavigationItem {
id: "permissions".into(),
title: "Permissions".into(),
icon: "✓".into(),
route: Route::PermissionsPage {},
permission: Some("roles:manage".into()),
children: vec![],
},
NavigationItem {
id: "service_accounts".into(),
title: "Service Accounts".into(),
icon: "⚙".into(),
route: Route::ServiceAccountsPage {},
permission: Some("roles:manage".into()),
children: vec![],
},
]
);
registry.register_section(
"Audit & Logs",
vec![
NavigationItem {
id: "sessions".into(),
title: "Sessions".into(),
icon: "⏱".into(),
route: Route::SessionsPage {},
permission: Some("audit:view".into()),
children: vec![],
},
NavigationItem {
id: "audit_events".into(),
title: "Audit Log".into(),
icon: "📋".into(),
route: Route::AuditPage {},
permission: Some("audit:view".into()),
children: vec![],
},
]
);
registry.register_section(
"System",
vec![
NavigationItem {
id: "settings".into(),
title: "Settings".into(),
icon: "⚙".into(),
route: Route::SettingsPage {},
permission: None,
children: vec![],
},
NavigationItem {
id: "about".into(),
title: "About".into(),
icon: "ℹ".into(),
route: Route::AboutPage {},
permission: None,
children: vec![],
},
]
);
let state = Self {
auth: use_signal(|| BootstrapState::Initializing),
theme: use_signal(|| theme_mode),
toasts: use_signal(Vec::new),
toast_seq: use_signal(|| 0u64),
sidebar_collapsed: use_signal(|| false),
mobile_nav_open: use_signal(|| false),
nav_registry: use_signal(|| registry),
tenant: use_signal(|| None),
};
use_context_provider(|| state);
state
}
pub fn toast(&self, kind: ToastKind, message: impl Into<String>) {
let id = {
let mut seq = self.toast_seq;
let next = seq() + 1;
seq.set(next);
next
};
let mut toasts = self.toasts;
let mut list = toasts();
list.push(Toast {
id,
kind,
message: message.into(),
});
if list.len() > 5 {
list.remove(0);
}
toasts.set(list);
}
pub fn dismiss_toast(&self, id: u64) {
let mut toasts = self.toasts;
let list: Vec<_> = toasts().into_iter().filter(|t| t.id != id).collect();
toasts.set(list);
}
pub fn set_theme(&self, mode: ThemeMode) {
theme::save_theme(mode);
theme::apply_theme(mode);
let mut t = self.theme;
t.set(mode);
}
pub fn cycle_theme(&self) {
let next = (self.theme)().cycle();
self.set_theme(next);
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Theme management (light / dark / system) with localStorage persistence.
use gloo_storage::{LocalStorage, Storage};
use std::fmt;
const STORAGE_KEY: &str = "nx9-auth-theme";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemeMode {
Light,
Dark,
#[default]
System,
}
impl ThemeMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
Self::System => "system",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"light" => Self::Light,
"dark" => Self::Dark,
_ => Self::System,
}
}
pub fn cycle(self) -> Self {
match self {
Self::Light => Self::Dark,
Self::Dark => Self::System,
Self::System => Self::Light,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Light => "Light",
Self::Dark => "Dark",
Self::System => "System",
}
}
pub fn icon(self) -> &'static str {
match self {
Self::Light => "☀",
Self::Dark => "☾",
Self::System => "◐",
}
}
}
impl fmt::Display for ThemeMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub fn load_theme() -> ThemeMode {
LocalStorage::get::<String>(STORAGE_KEY)
.map(|s| ThemeMode::from_str(&s))
.unwrap_or_default()
}
pub fn save_theme(mode: ThemeMode) {
let _ = LocalStorage::set(STORAGE_KEY, mode.as_str());
}
/// Apply theme to `<html data-theme="...">`.
pub fn apply_theme(mode: ThemeMode) {
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Some(el) = document.document_element() {
match mode {
ThemeMode::Light => {
let _ = el.set_attribute("data-theme", "light");
}
ThemeMode::Dark => {
let _ = el.set_attribute("data-theme", "dark");
}
ThemeMode::System => {
let _ = el.remove_attribute("data-theme");
}
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Small UI helpers.
/// 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();
if parts.is_empty() {
return "?".to_string();
}
if parts.len() == 1 {
return parts[0].chars().take(2).collect::<String>().to_uppercase();
}
format!(
"{}{}",
parts[0].chars().next().unwrap_or('?'),
parts[1].chars().next().unwrap_or('?')
)
.to_uppercase()
}
/// Relative-ish short date display (pass through ISO for now).
pub fn format_datetime(s: &str) -> String {
if s.is_empty() {
return "—".to_string();
}
// Prefer "YYYY-MM-DD HH:MM" from ISO-ish strings
let s = s.replace('T', " ");
if s.len() >= 16 {
s[..16].to_string()
} else {
s
}
}
/// Status → CSS badge class.
pub fn status_badge_class(status: &str) -> &'static str {
match status {
"active" | "enabled" => "badge badge-success",
"disabled" => "badge badge-danger",
"locked" | "revoked" => "badge badge-warning",
_ => "badge",
}
}
/// Severity → CSS badge class.
pub fn severity_badge_class(sev: &str) -> &'static str {
match sev {
"info" => "badge badge-info",
"warning" => "badge badge-warning",
"critical" => "badge badge-danger",
_ => "badge",
}
}
/// Client-side filter helper.
pub fn matches_query(haystack: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
haystack.to_lowercase().contains(&query.to_lowercase())
}
/// Simple slugify for application slugs.
pub fn slugify(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join("-")
}