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>
111 lines
4.7 KiB
HTML
111 lines
4.7 KiB
HTML
<!doctype html>
|
|
<html lang="en" class="h-full">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Sign in — 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; }
|
|
</style>
|
|
</head>
|
|
<body class="h-full bg-slate-950 text-slate-100 flex items-center justify-center">
|
|
<main class="w-full max-w-sm px-6">
|
|
<div class="text-center mb-8">
|
|
<h1 class="text-2xl font-semibold">RustDesk Admin</h1>
|
|
<p class="text-slate-400 text-sm mt-1">Sign in to manage the server.</p>
|
|
</div>
|
|
|
|
<form
|
|
class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-6 shadow-xl"
|
|
hx-post="/admin/login"
|
|
hx-target="#err"
|
|
hx-swap="innerHTML"
|
|
hx-on::before-swap="
|
|
/* The auth handler returns 401 with an HTML error fragment for
|
|
bad credentials / disabled / not-admin / bad-TOTP. HTMX skips
|
|
the swap on 4xx by default, so force it back on. */
|
|
if (event.detail.xhr.status >= 400 && event.detail.xhr.status < 500) {
|
|
event.detail.shouldSwap = true;
|
|
event.detail.isError = false;
|
|
}
|
|
"
|
|
hx-on::after-request="
|
|
const xhr = event.detail.xhr;
|
|
if (event.detail.successful && (xhr.responseText || '').trim() === '') {
|
|
/* Empty 2xx body = real login. The TOTP-required path returns 2xx
|
|
with an HTML prompt fragment, which we MUST NOT redirect away
|
|
from. */
|
|
window.location.href = '/admin/';
|
|
}
|
|
"
|
|
>
|
|
<div>
|
|
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">Username</label>
|
|
<input
|
|
id="username" name="username" type="text" required autocomplete="username"
|
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">Password</label>
|
|
<input
|
|
id="password" name="password" type="password" required autocomplete="current-password"
|
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
|
|
/>
|
|
</div>
|
|
|
|
<div id="tfa-section" class="hidden">
|
|
<label class="block text-xs font-medium text-slate-400 mb-1" for="tfaCode">6-digit TOTP code</label>
|
|
<input
|
|
id="tfaCode" name="tfaCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
|
|
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm tracking-widest text-center focus:outline-none focus:border-sky-500"
|
|
/>
|
|
<input id="secret" name="secret" type="hidden" />
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
class="w-full bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"
|
|
>
|
|
Sign in
|
|
</button>
|
|
|
|
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
|
|
</form>
|
|
|
|
<!-- OIDC providers (rendered only when /admin/oidc/providers is non-empty) -->
|
|
<div id="oidc-block" class="mt-6 hidden">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<div class="flex-1 h-px bg-slate-800"></div>
|
|
<span class="text-xs text-slate-500">or</span>
|
|
<div class="flex-1 h-px bg-slate-800"></div>
|
|
</div>
|
|
<div id="oidc-buttons" class="space-y-2"></div>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
// Fetch enabled providers and render one button each. The button just
|
|
// navigates to /admin/login/oidc/<name>, which 302s the browser to the
|
|
// IdP. After the IdP redirects to /oidc/callback, the server sets our
|
|
// session cookie and redirects to /admin/.
|
|
fetch('/admin/oidc/providers').then(r => r.json()).then(list => {
|
|
if (!Array.isArray(list) || list.length === 0) return;
|
|
const block = document.getElementById('oidc-block');
|
|
const root = document.getElementById('oidc-buttons');
|
|
list.forEach(p => {
|
|
const a = document.createElement('a');
|
|
a.href = '/admin/login/oidc/' + encodeURIComponent(p.name);
|
|
a.className = 'block w-full text-center bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm rounded px-4 py-2 transition';
|
|
a.textContent = 'Sign in with ' + (p.display_name || p.name);
|
|
root.appendChild(a);
|
|
});
|
|
block.classList.remove('hidden');
|
|
}).catch(() => { /* silently hide block on any error */ });
|
|
</script>
|
|
</body>
|
|
</html>
|