feat(admin): user-administration QoL — last-seen, profile page, OIDC awareness
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>
This commit is contained in:
+157
-13
@@ -90,6 +90,34 @@ async fn set_email_inline(
|
||||
|
||||
// ---------- per-row actions ----------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateInfoForm {
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn update_info(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateInfoForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
state
|
||||
.db
|
||||
.user_set_display_name(id, form.display_name.trim())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
state
|
||||
.db
|
||||
.raw_update_user_email(id, form.email.trim())
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
notice_then_table(&state, "ok", "Profile updated.").await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasswordResetForm {
|
||||
pub password: String,
|
||||
@@ -102,6 +130,24 @@ pub async fn reset_password(
|
||||
Form(form): Form<PasswordResetForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
// Server-side guard: even though the UI hides the form for OIDC
|
||||
// accounts, refuse to set a local password on them. Letting a local
|
||||
// password slip in would silently re-enable password sign-in and
|
||||
// bypass any MFA the IdP enforces.
|
||||
let target = state
|
||||
.db
|
||||
.user_find_by_id(id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
if target.is_oidc_linked() {
|
||||
return notice_then_table(
|
||||
&state,
|
||||
"error",
|
||||
"This account is linked to OIDC — set the password at the identity provider instead.",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if form.password.len() < 4 {
|
||||
return notice_then_table(
|
||||
&state,
|
||||
@@ -334,6 +380,13 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
totp.insert(u.id, b);
|
||||
}
|
||||
}
|
||||
// Single GROUP BY query for the whole "last seen" column, derived
|
||||
// from MAX(tokens.last_used_at) per user.
|
||||
let last_seen = state
|
||||
.db
|
||||
.users_last_seen_map()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut s = String::new();
|
||||
// No `overflow-hidden` on the table wrapper: the per-row action menu is
|
||||
// an absolutely-positioned `<details>` popover inside a <td>, and the
|
||||
@@ -349,19 +402,25 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
<th class="text-left font-medium px-3 py-2">Status</th>
|
||||
<th class="text-left font-medium px-3 py-2">Admin</th>
|
||||
<th class="text-left font-medium px-3 py-2">TOTP</th>
|
||||
<th class="text-left font-medium px-3 py-2">Last seen</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
);
|
||||
for u in &users {
|
||||
render_user_row(&mut s, u, *totp.get(&u.id).unwrap_or(&false));
|
||||
render_user_row(
|
||||
&mut s,
|
||||
u,
|
||||
*totp.get(&u.id).unwrap_or(&false),
|
||||
last_seen.get(&u.id).map(String::as_str),
|
||||
);
|
||||
}
|
||||
s.push_str(" </tbody>\n</table></div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
|
||||
fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool, last_seen: Option<&str>) {
|
||||
let status_badge = match u.status {
|
||||
1 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-emerald-900/50 border border-emerald-700/50 text-emerald-300 text-xs">active</span>"#,
|
||||
0 => r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-400 text-xs">disabled</span>"#,
|
||||
@@ -373,11 +432,56 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let totp_badge = if has_totp {
|
||||
// The TOTP column doubles as an "auth path" indicator: OIDC-linked
|
||||
// users get an "OIDC" badge (their MFA lives at the IdP — local
|
||||
// TOTP is moot), and OIDC takes precedence over local TOTP if both
|
||||
// somehow exist.
|
||||
let totp_badge = if u.is_oidc_linked() {
|
||||
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-cyan-900/50 border border-cyan-700/50 text-cyan-300 text-xs">OIDC</span>"#
|
||||
} else if has_totp {
|
||||
r#"<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">enrolled</span>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let oidc_linked = u.is_oidc_linked();
|
||||
// OIDC-linked users sign in via the IdP — adding a local password
|
||||
// would let them bypass the IdP (and any MFA enforced there). Show
|
||||
// a note instead of the password-reset form for these accounts.
|
||||
let password_form = if oidc_linked {
|
||||
r##"<div class="px-2 py-1.5 text-xs text-slate-500 italic border border-slate-800 rounded">
|
||||
Linked to OIDC — password sign-in is disabled.
|
||||
</div>"##
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
r##"<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML">
|
||||
<input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button>
|
||||
</form>"##,
|
||||
id = u.id,
|
||||
)
|
||||
};
|
||||
let (last_seen_rel, last_seen_abs) = match last_seen {
|
||||
Some(ts) => (relative_ts(ts), html_escape(ts)),
|
||||
None => ("never".to_string(), String::new()),
|
||||
};
|
||||
// TOTP enrollment is self-service (the user does it on their
|
||||
// profile page so they can scan the QR + verify a code before
|
||||
// we store the secret). Admin-side action is reset/disable only,
|
||||
// and only relevant when the user has it enrolled.
|
||||
let totp_button = if has_totp {
|
||||
format!(
|
||||
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/users/{id}/totp-unenroll" hx-target="#users-region" hx-swap="innerHTML"
|
||||
hx-confirm="Disable TOTP for {username}? They'll be able to sign in without a 6-digit code until they re-enroll.">
|
||||
Disable TOTP
|
||||
</button>"##,
|
||||
id = u.id,
|
||||
username = html_escape(&u.username),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr class="hover:bg-slate-800/40">
|
||||
@@ -387,14 +491,17 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
|
||||
<td class="px-3 py-2">{status}</td>
|
||||
<td class="px-3 py-2">{admin}</td>
|
||||
<td class="px-3 py-2">{totp}</td>
|
||||
<td class="px-3 py-2 text-slate-400 whitespace-nowrap" title="{last_seen_abs}">{last_seen_rel}</td>
|
||||
<td class="px-3 py-2">
|
||||
<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<form class="flex gap-1" hx-post="/admin/pages/users/{id}/password-reset" hx-target="#users-region" hx-swap="innerHTML">
|
||||
<input name="password" type="password" required minlength="4" placeholder="new password" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Set</button>
|
||||
<div class="absolute right-2 mt-1 z-10 w-64 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<form class="space-y-1" hx-post="/admin/pages/users/{id}/update-info" hx-target="#users-region" hx-swap="innerHTML">
|
||||
<input name="display_name" value="{display_name}" placeholder="display name" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<input name="email" type="email" value="{email}" placeholder="email" class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs"/>
|
||||
<button class="w-full bg-sky-700 hover:bg-sky-600 rounded px-2 py-1 text-xs">Save profile</button>
|
||||
</form>
|
||||
{password_form}
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/users/{id}/toggle-admin" hx-target="#users-region" hx-swap="innerHTML">
|
||||
{admin_label}
|
||||
@@ -403,10 +510,7 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
|
||||
hx-post="/admin/pages/users/{id}/toggle-status" hx-target="#users-region" hx-swap="innerHTML">
|
||||
{status_label}
|
||||
</button>
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-post="/admin/pages/users/{id}/totp-{totp_action}" hx-target="#users-region" hx-swap="innerHTML">
|
||||
{totp_label}
|
||||
</button>
|
||||
{totp_button}
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/30 rounded"
|
||||
hx-post="/admin/pages/users/{id}/delete"
|
||||
hx-confirm="Delete user {username}? This cascades into their tokens, group memberships and AB shares."
|
||||
@@ -426,11 +530,51 @@ fn render_user_row(s: &mut String, u: &UserRow, has_totp: bool) {
|
||||
totp = totp_badge,
|
||||
admin_label = if u.is_admin { "Revoke admin" } else { "Grant admin" },
|
||||
status_label = if u.status == 1 { "Disable user" } else { "Enable user" },
|
||||
totp_action = if has_totp { "unenroll" } else { "enroll" },
|
||||
totp_label = if has_totp { "Disable TOTP" } else { "Enroll TOTP" },
|
||||
totp_button = totp_button,
|
||||
last_seen_rel = last_seen_rel,
|
||||
last_seen_abs = last_seen_abs,
|
||||
password_form = password_form,
|
||||
);
|
||||
}
|
||||
|
||||
/// Format a SQLite `current_timestamp` string ("YYYY-MM-DD HH:MM:SS",
|
||||
/// always UTC) as a relative time-ago label. Renders short forms — "5m
|
||||
/// ago", "3h ago", "2d ago" — for the at-a-glance column; the absolute
|
||||
/// timestamp goes into the cell's `title=` for hover.
|
||||
fn relative_ts(ts: &str) -> String {
|
||||
let parsed = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|t| t.and_utc())
|
||||
.ok();
|
||||
let Some(t) = parsed else {
|
||||
return ts.to_string();
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
let secs = (now - t).num_seconds();
|
||||
if secs < 0 {
|
||||
return "just now".to_string();
|
||||
}
|
||||
if secs < 60 {
|
||||
return "just now".to_string();
|
||||
}
|
||||
let mins = secs / 60;
|
||||
if mins < 60 {
|
||||
return format!("{}m ago", mins);
|
||||
}
|
||||
let hours = mins / 60;
|
||||
if hours < 24 {
|
||||
return format!("{}h ago", hours);
|
||||
}
|
||||
let days = hours / 24;
|
||||
if days < 30 {
|
||||
return format!("{}d ago", days);
|
||||
}
|
||||
let months = days / 30;
|
||||
if months < 12 {
|
||||
return format!("{}mo ago", months);
|
||||
}
|
||||
format!("{}y ago", months / 12)
|
||||
}
|
||||
|
||||
fn notice_html(kind: &str, msg: &str) -> String {
|
||||
let (border, bg, text) = match kind {
|
||||
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
|
||||
|
||||
Reference in New Issue
Block a user