diff --git a/bin/hyperion-web/static/app.css b/bin/hyperion-web/static/app.css index 5e061fe4..15bc3b8f 100644 --- a/bin/hyperion-web/static/app.css +++ b/bin/hyperion-web/static/app.css @@ -738,6 +738,84 @@ details.item > summary::-webkit-details-marker { display: none; } Zero it when groups are direct grid children; the grid `gap` spaces them. */ .dashboard-grid > .group + .group { margin-top: 0; } +/* /admin/users — one grouped panel; each user is a details row whose summary + is the scan line and whose body holds role / sign-in / password side by + side, with delete split off at the bottom. */ +.users-list .user-cols, +.users-list .user-row > summary { + display: grid; + grid-template-columns: minmax(14rem, 2.2fr) 1.3fr 1fr 0.8fr 1fr 5.5rem; + gap: 1rem; + align-items: center; +} +.users-list .user-cols { + padding: 0.55rem 1.25rem; + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + color: var(--text-dim); +} +.users-list .user-row { padding: 0; } +.users-list .user-row > summary { padding: 0.85rem 1.25rem; } +.users-list .user-row > summary:hover { background: var(--surface-2); } +.users-list .user-row[open] > summary { background: var(--surface-2); border-bottom: 1px solid var(--border); } +.users-list .user-row[open] .user-toggle .btn { color: var(--text); border-color: var(--text-dim); } +.users-list .user-id { display: flex; align-items: center; gap: 0.7rem; min-width: 0; } +.users-list .user-name { display: flex; flex-direction: column; min-width: 0; } +.users-list .user-name > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.users-list .user-ava { + width: 30px; height: 30px; border-radius: 50%; flex: none; + display: grid; place-items: center; + background: var(--surface-2); border: 1px solid var(--border); + font-weight: 600; font-size: 0.8rem; color: var(--text-dim); +} +.users-list .user-role { font-size: 0.86rem; } +.users-list .user-toggle { justify-self: end; } +.users-list .user-panel { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.users-list .user-sec { + padding: 1.1rem 1.25rem; + display: flex; flex-direction: column; gap: 0.5rem; +} +.users-list .user-sec + .user-sec { border-left: 1px solid var(--border); } +.users-list .user-sec h3 { margin: 0; } +.users-list .user-sec .help { margin: 0 0 0.2rem; font-size: 0.8rem; } +.users-list .user-sec input:not([type=hidden]), +.users-list .user-sec select { width: 100%; margin: 0; } +.users-list .user-sec .item-actions { margin-top: auto; padding-top: 0.4rem; } +.users-list .user-danger { + display: flex; justify-content: space-between; align-items: center; gap: 1rem; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border); + background: color-mix(in oklab, var(--danger) 5%, var(--surface)); +} +.users-add > summary.ghead { cursor: pointer; list-style: none; border-bottom: none; } +.users-add > summary::-webkit-details-marker { display: none; } +.users-add[open] > summary.ghead { border-bottom: 1px solid var(--border); } +.users-add-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem; } +.users-add .role-legend { + display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; + margin: 1rem 0 0; font-size: 0.8rem; color: var(--text-soft); +} +.users-add .role-legend dt { font-weight: 600; color: var(--text-dim); } +.users-add .role-legend dd { margin: 0; } +@media (max-width: 900px) { + .users-list .user-cols { display: none; } + .users-list .user-row > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem 0.75rem; } + .users-list .user-row > summary > .user-id { flex: 1 1 0; order: 0; } + .users-list .user-row > summary > .user-toggle { order: 1; } + .users-list .user-row > summary > :not(.user-id):not(.user-toggle) { order: 2; } + .users-list .user-row > summary > .user-role { flex-basis: 100%; padding-left: calc(30px + 0.7rem); margin-bottom: -0.2rem; } + .users-list .user-row > summary > :nth-child(3) { margin-left: calc(30px + 0.7rem); } + .users-list .user-panel { grid-template-columns: 1fr; } + .users-list .user-sec + .user-sec { border-left: none; border-top: 1px solid var(--border); } + .users-add-grid { grid-template-columns: 1fr 1fr; } +} + /* Grid of cards (e.g. dashboard stats) */ .grid { display: grid; diff --git a/bin/hyperion-web/templates/users.html b/bin/hyperion-web/templates/users.html index eda7184b..c05d4327 100644 --- a/bin/hyperion-web/templates/users.html +++ b/bin/hyperion-web/templates/users.html @@ -24,162 +24,170 @@

Admin users

{% endif %} -
-

Add user

-
- -
- - -
-
- - -
-
- - -
-
- - +
+
+ Users + {{ users.len() }} +
+ + {% for u in users %} +
+ + + {{ crate::handlers::user_initial(u.username) }} + + {{ u.username }} + {{ u.email }} + + + + {% if u.custom_role_id.is_some() %} + {% for cr in custom_roles %}{% if u.custom_role_id.as_ref() == Some(cr.id) %}{{ cr.name }} custom{% endif %}{% endfor %} + {% else %} + {{ crate::handlers::role_label(u.role) }} + {% endif %} + + + {% if u.totp_enrolled %} + enrolled + {% else if u.totp_required %} + required, not set + {% else %} + 2FA off + {% endif %} + + + {% if u.locked %} + locked + {% else %} + active + {% endif %} + + + {% if let Some(t) = u.last_login_at %}{{ crate::handlers::stats::fmt_ago(t) }}{% else %}never{% endif %} + + + Manage + + + +
+ + + +

Role

+

What this user can see and change.

+ +
+ + +
+ + +

Sign-in

+ {% if u.locked %} +

+ Locked{% if let Some(r) = u.locked_reason %}: {{ r }}{% endif %}. + The user cannot sign in until unlocked. +

+ +
+ {% else %} +

Block sign-in without deleting the account.

+ + +
+ {% endif %} +
+ +
+ + +

Password

+

Set a new password and send it to the user yourself.

+ +
+
- - -

- Owner — full control, including user management · - Administrator — full control except user management · - Operator — internal staff who can manage their assigned hostings · - Customer — end user with a slim nav, sees only their own hostings · - Read-only — view-only on assigned hostings. -

-
-
-
- - - - - - - - - - - - - {% for u in users %} - - - - - - - - - {% endfor %} - -
UserRole2FAStatusLast sign-inActions
- {{ u.username }} -
{{ u.email }}
-
-
- - - - -
-
- {% if u.totp_enrolled %} - enrolled - {% else if u.totp_required %} - required, not set - {% else %} - — - {% endif %} - - {% if u.locked %} - locked - {% if let Some(r) = u.locked_reason %} -
{{ r }}
- {% endif %} - {% else %} - active - {% endif %} -
- {% if let Some(t) = u.last_login_at %} - {{ crate::handlers::stats::fmt_ago(t) }} - {% else %} - never - {% endif %} - -
- Actions -
-
- - - {% if u.locked %} - - - {% else %} - - - - {% endif %} -
-
- - - - -
-
- - - -
-
-
-
-
+
+ + + Removes the account and revokes every session. Hostings are not touched. + +
+ + {% endfor %}
+
+ + + Add user + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
Owner
Full control, including user management.
+
Administrator
Full control except user management.
+
Operator
Staff who manage their assigned hostings.
+
Customer
End user with a slim nav, only their own hostings.
+
Read-only
View-only on assigned hostings.
+
+
+
+
+

Each user can enable TOTP two-factor authentication from their profile. Operator, Customer, and Read-only users diff --git a/crates/hyperion-core/src/service.rs b/crates/hyperion-core/src/service.rs index ac70a200..66fb2403 100644 --- a/crates/hyperion-core/src/service.rs +++ b/crates/hyperion-core/src/service.rs @@ -23868,7 +23868,19 @@ impl HostingService { let rows = hyperion_state::web_users::list(&self.pool) .await .map_err(|e| RpcError::Internal_with(format!("list: {e}")))?; - Ok(rows.into_iter().map(row_to_summary).collect()) + let custom = hyperion_state::web_users::custom_role_assignments(&self.pool) + .await + .map_err(|e| RpcError::Internal_with(format!("list custom roles: {e}")))?; + Ok(rows + .into_iter() + .map(|u| { + let custom_role_id = custom.get(&u.id).copied(); + hyperion_types::WebUserSummary { + custom_role_id, + ..row_to_summary(u) + } + }) + .collect()) } pub async fn web_user_get( @@ -35899,6 +35911,7 @@ fn row_to_summary(u: hyperion_state::web_users::WebUserRow) -> hyperion_types::W locked_reason: u.locked_reason, last_login_at: u.last_login_at, created_at: u.created_at, + custom_role_id: None, } } diff --git a/crates/hyperion-state/src/web_users.rs b/crates/hyperion-state/src/web_users.rs index eb1d151d..4a0299d9 100644 --- a/crates/hyperion-state/src/web_users.rs +++ b/crates/hyperion-state/src/web_users.rs @@ -134,6 +134,21 @@ pub async fn list(pool: &SqlitePool) -> Result, StateError> { Ok(rows) } +/// `user_id -> custom_role_id` for every user linked to a custom role that +/// still exists (a dangling id resolves to the built-in role, same as +/// `effective_role`). +pub async fn custom_role_assignments( + pool: &SqlitePool, +) -> Result, StateError> { + let rows: Vec<(i64, i64)> = sqlx::query_as( + "SELECT u.id, u.custom_role_id FROM web_users u \ + JOIN custom_roles c ON c.id = u.custom_role_id", + ) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().collect()) +} + pub async fn count(pool: &SqlitePool) -> Result { let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM web_users") .fetch_one(pool) @@ -962,6 +977,28 @@ mod tests { .expect("insert") } + #[tokio::test] + async fn custom_role_assignments_lists_linked_users_only() { + let pool = open_memory().await.expect("open"); + let plain = fresh_user(&pool, "plain").await; + let linked = fresh_user(&pool, "linked").await; + let role = crate::custom_roles::create(&pool, "Support desk", 0, false, 10) + .await + .expect("role"); + set_custom_role(&pool, linked, role, 11).await.expect("set"); + let map = custom_role_assignments(&pool).await.expect("list"); + assert_eq!(map.get(&linked), Some(&role)); + assert_eq!(map.get(&plain), None); + // Back to a built-in role clears the link. + set_role(&pool, linked, WebRole::Viewer, 12) + .await + .expect("reset"); + assert!(custom_role_assignments(&pool) + .await + .expect("list") + .is_empty()); + } + #[tokio::test] async fn consume_totp_step_rejects_replay_and_older_steps() { let pool = open_memory().await.expect("open"); diff --git a/crates/hyperion-types/src/stats.rs b/crates/hyperion-types/src/stats.rs index 4f3145cf..86df39da 100644 --- a/crates/hyperion-types/src/stats.rs +++ b/crates/hyperion-types/src/stats.rs @@ -1027,6 +1027,12 @@ pub struct WebUserSummary { pub locked_reason: Option, pub last_login_at: Option, pub created_at: i64, + /// Linked custom role, if any. A custom-role user's `role` column holds + /// the `operator` sentinel, so without this the users page showed them as + /// Operator and one "save" silently dropped the custom role. Filled by + /// `WebUserList` only; `serde(default)` keeps older agents decodable. + #[serde(default)] + pub custom_role_id: Option, } /// Wire shape of one custom role. `capabilities` is a plain bitmask (the