4ccfe7a0e6
Bundles the dashboard improvements that landed since 782e4c5 into one
commit. None of these change wire protocols or DB schema; they're all
UI + handlers on top of existing tables.
Users page (/admin/#users)
- "Last seen" column derived from MAX(tokens.last_used_at) per user
(single GROUP BY query in users_last_seen_map). Shows relative
short-form ("5m ago", "3h ago", "2d ago") with the absolute UTC
timestamp in the cell title= for hover.
- Per-row dropdown gains an inline "Edit profile" form (display name,
email, Save) so admins can edit other users' info without going
through the self-service profile.
- "Enroll TOTP" button removed from the dropdown — TOTP enrollment is
now self-service only, so admin-side enroll (which generated a secret
out-of-band with no QR/confirm) is dead UX. "Disable TOTP" stays,
shown only when the user has it enrolled, with hx-confirm.
- Per-row action popover (the ··· menu) now closes on outside click,
via a global handler in index.html that targets details.relative.
Deploy page's collapsible help section is unaffected (no `relative`).
Self-service profile page (/admin/#profile)
- New page accessible to any signed-in user (no admin gate). Sections:
* Profile info — display name, email
* Change password — requires current password + new + confirm
* Two-factor authentication — enroll/disable
- TOTP enrollment is two-step with QR confirmation. POST .../totp/start
generates a fresh secret, renders a server-side SVG QR code (new
`qrcode` crate dependency, no_std SVG renderer) plus the manual-entry
base32 secret. The secret rides in a hidden form field; nothing is
written to user_totp_secrets until the user submits a valid 6-digit
code at .../totp/confirm. Wrong code re-renders the same QR with a
"code didn't match" notice so the user can retry without re-scanning.
- TOTP removal requires the current password.
- Sidebar now has a "My profile" link at the bottom.
OIDC linkage awareness
- UserRow exposes oidc_subject (was already in schema, just not surfaced
in the struct). UserRow::is_oidc_linked() returns true for non-empty.
- Admin Users page: for OIDC-linked rows the password-set form is
replaced by a small italic note ("Linked to OIDC — password sign-in
is disabled."). Server-side, reset_password also rejects with the
same message — UI hide is cosmetic; the handler check is the actual
guarantee.
- TOTP column doubles as an auth-path indicator: OIDC-linked users get
a cyan "OIDC" badge instead of (or in preference to) the violet
"enrolled" badge.
- Self-service profile page: change-password and TOTP sections become
short notes ("Your account signs in via the identity provider …" /
"MFA is managed by your identity provider") for OIDC users.
change_password handler also short-circuits with the same message.
Login page error fragment
- The auth handler returns 401 with an HTML body for bad credentials /
disabled / not-admin / bad-TOTP, but HTMX skips the swap on 4xx by
default — so login errors silently never appeared. Form now has
hx-on::before-swap that forces shouldSwap=true and clears isError on
4xx, but only for this form (page-level htmx:responseError handler
that bounces 401s to /admin/login.html still applies elsewhere — it
wouldn't loop here since this form sets isError=false).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
5.2 KiB
HTML
102 lines
5.2 KiB
HTML
<!doctype html>
|
|
<html lang="en" class="h-full">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>RustDesk Admin</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<script src="/admin/assets/tailwindcss.js"></script>
|
|
<script src="/admin/assets/htmx.min.js"></script>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
|
.nav-link.active { background: rgb(15 23 42); color: rgb(125 211 252); }
|
|
</style>
|
|
</head>
|
|
<body class="h-full bg-slate-950 text-slate-100">
|
|
<!--
|
|
Single-page shell. The sidebar drives navigation via HTMX:
|
|
each link does an `hx-get` of an HTML fragment URL that returns the
|
|
body of the page. The fragments live under /admin/pages/ and are
|
|
server-rendered Rust handlers that return Html<String>.
|
|
This keeps the UI a flat directory of static files plus a small
|
|
set of fragment endpoints — no SPA, no Node, no build step.
|
|
-->
|
|
<div class="min-h-full flex">
|
|
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
|
|
<div class="px-4 py-5 border-b border-slate-800">
|
|
<h1 class="text-base font-semibold">RustDesk Admin</h1>
|
|
<p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML">…</p>
|
|
</div>
|
|
<nav class="flex-1 px-2 py-3 space-y-1">
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">Users</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/devices" hx-target="#main" hx-push-url="#devices">Devices</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/groups" hx-target="#main" hx-push-url="#groups">Device groups</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">Strategies</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">Address books</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/oidc" hx-target="#main" hx-push-url="#oidc">OIDC providers</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
|
|
</nav>
|
|
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
|
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">My profile</a>
|
|
<button
|
|
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
|
hx-post="/admin/logout"
|
|
hx-on::after-request="window.location.href = '/admin/login.html'"
|
|
>Sign out</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main id="main" class="flex-1 p-6 overflow-x-hidden"
|
|
hx-get="/admin/pages/users" hx-trigger="load">
|
|
<div class="text-slate-500 text-sm">Loading…</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Toast container used by all admin handlers via hx-trigger="load delay:1s" -->
|
|
<div id="toast"
|
|
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
|
|
|
|
<!-- Highlight active link based on hash -->
|
|
<script>
|
|
function refreshActive() {
|
|
const hash = location.hash || '#users';
|
|
document.querySelectorAll('.nav-link').forEach(a => {
|
|
const ahash = a.getAttribute('hx-push-url');
|
|
a.classList.toggle('active', ahash === hash);
|
|
});
|
|
}
|
|
window.addEventListener('hashchange', refreshActive);
|
|
document.body.addEventListener('htmx:afterSwap', refreshActive);
|
|
refreshActive();
|
|
|
|
// Bounce to login if any HTMX request comes back 401.
|
|
document.body.addEventListener('htmx:responseError', (evt) => {
|
|
if (evt.detail.xhr.status === 401) {
|
|
window.location.href = '/admin/login.html';
|
|
}
|
|
});
|
|
|
|
// Close any open per-row action popover when a click happens outside it.
|
|
// The action dropdowns are <details class="... relative"> with an
|
|
// absolutely-positioned panel; the deploy page uses <details> too but
|
|
// without `relative`, so the selector is specific to the popover style.
|
|
document.addEventListener('click', (e) => {
|
|
document.querySelectorAll('details.relative[open]').forEach(d => {
|
|
if (!d.contains(e.target)) d.removeAttribute('open');
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|