feat: complete Phase 0 Enterprise IAM
This commit is contained in:
1 parent
3d2d291006
commit
c2f5ba3f54
202 files changed
+21371
-1360
No files matched your search
Generated
+2312
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
});
|
||||
@@ -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 |
@@ -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); }
|
||||
Vendored
+40
@@ -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);
|
||||
Vendored
+4
@@ -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 |
Vendored
+857
@@ -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); }
|
||||
Vendored
+25
@@ -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>
|
||||
Vendored
+1723
File diff suppressed because it is too large.
Load diff
Vendored
BIN
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};
|
||||
+1
@@ -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};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
"×"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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! {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(\"\"))}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Typed API client for the nx9-auth REST API.
|
||||
|
||||
pub mod api;
|
||||
pub mod session;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("-")
|
||||
}
|
||||
Reference in new issue
Block a user