fabrica

hanna/fabrica

fix(web): reflect email-verified state in the admin user list

9167701 · hanna committed on 2026-07-26

The admin user list showed the "Verify" button unconditionally, so a fully
verified account looked unverified. Badge the primary-email-verified state and
only offer "Verify" when it is not verified. Also mark the primary email verified
when a user activates via an emailed invite — completing the invite proves they
control that address.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +40 −2UnifiedSplit
crates/store/src/users.rs +19 −0
@@ -520,6 +520,25 @@ impl Store {
520 Ok(updated > 0)520 Ok(updated > 0)
521 }521 }
522522
523 /// Whether a user's primary email address is verified.
524 ///
525 /// # Errors
526 ///
527 /// Returns [`StoreError::Query`] on a database failure.
528 pub async fn primary_email_verified(&self, user_id: &str) -> Result<bool, StoreError> {
529 let row = sqlx::query(
530 "SELECT verified_at FROM user_emails WHERE user_id = $1 AND is_primary = $2",
531 )
532 .bind(user_id)
533 .bind(true)
534 .fetch_optional(&self.pool)
535 .await?;
536 let verified = row
537 .and_then(|r| r.try_get::<Option<i64>, _>("verified_at").ok().flatten())
538 .is_some();
539 Ok(verified)
540 }
541
523 /// Set (or clear) a user's password hash.542 /// Set (or clear) a user's password hash.
524 ///543 ///
525 /// Passing `Some(hash)` sets the credential and clears the544 /// Passing `Some(hash)` sets the credential and clears the
crates/web/src/admin.rs +18 −2
@@ -162,6 +162,19 @@ pub async fn users(
162 let (page, offset) = pager.resolve();162 let (page, offset) = pager.resolve();
163 let total = state.store.admin_counts().await?.users;163 let total = state.store.admin_counts().await?.users;
164 let all = state.store.list_users_paged(PAGE_SIZE, offset).await?;164 let all = state.store.list_users_paged(PAGE_SIZE, offset).await?;
165 // Verified state of each shown user's primary email, so the row can badge it
166 // and only offer "Verify" when it is not yet verified.
167 let mut verified: std::collections::HashSet<String> = std::collections::HashSet::new();
168 for u in &all {
169 if state
170 .store
171 .primary_email_verified(&u.id)
172 .await
173 .unwrap_or(false)
174 {
175 verified.insert(u.id.clone());
176 }
177 }
165 // The password picker offers every account, not just the current page.178 // The password picker offers every account, not just the current page.
166 let everyone = state.store.list_users().await?;179 let everyone = state.store.list_users().await?;
167 let (_, chrome) = build_chrome(180 let (_, chrome) = build_chrome(
@@ -182,12 +195,15 @@ pub async fn users(
182 span class="entry-row-title" {195 span class="entry-row-title" {
183 a href={ "/" (u.username) } { (u.username) }196 a href={ "/" (u.username) } { (u.username) }
184 @if u.is_admin { " " span class="badge" { "admin" } }197 @if u.is_admin { " " span class="badge" { "admin" } }
198 @if verified.contains(&u.id) { " " span class="badge badge-verified" { "verified" } }
185 @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } }199 @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } }
186 }200 }
187 div class="entry-row-meta muted" { (u.email) }201 div class="entry-row-meta muted" { (u.email) }
188 }202 }
189 div class="entry-row-actions admin-actions" {203 div class="entry-row-actions admin-actions" {
190 (post_button(&format!("/admin/users/{}/verify", u.id), &csrf, "Verify", "btn"))204 @if !verified.contains(&u.id) {
205 (post_button(&format!("/admin/users/{}/verify", u.id), &csrf, "Verify", "btn"))
206 }
191 (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin,207 (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin,
192 if u.is_admin { "Revoke admin" } else { "Make admin" }))208 if u.is_admin { "Revoke admin" } else { "Make admin" }))
193 (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(),209 (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(),
@@ -214,8 +230,8 @@ pub async fn users(
214 }230 }
215 p class="muted field-hint" { "Leaving the password blank creates an account that must be activated by the user (they'll need an invite/reset)." }231 p class="muted field-hint" { "Leaving the password blank creates an account that must be activated by the user (they'll need an invite/reset)." }
216 div class="admin-form-actions" {232 div class="admin-form-actions" {
217 label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
218 button class="btn btn-primary inline-btn" type="submit" { "Create user" }233 button class="btn btn-primary inline-btn" type="submit" { "Create user" }
234 label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
219 }235 }
220 }236 }
221 }237 }
crates/web/src/pages.rs +3 −0
@@ -1271,6 +1271,9 @@ pub async fn invite_submit(
1271 .set_password(&invite.user_id, Some(&hash))1271 .set_password(&invite.user_id, Some(&hash))
1272 .await?;1272 .await?;
1273 state.store.mark_invite_used(&invite.id).await?;1273 state.store.mark_invite_used(&invite.id).await?;
1274 // The invite was delivered to this account's email, so completing it
1275 // proves control of that address — mark it verified.
1276 let _ = state.store.verify_primary_email(&invite.user_id).await;
1274 let user = state1277 let user = state
1275 .store1278 .store
1276 .user_by_id(&invite.user_id)1279 .user_by_id(&invite.user_id)