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 {
520520 Ok(updated > 0)
521521 }
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+
523542 /// Set (or clear) a user's password hash.
524543 ///
525544 /// Passing `Some(hash)` sets the credential and clears the
crates/web/src/admin.rs +18 −2
@@ -162,6 +162,19 @@ pub async fn users(
162162 let (page, offset) = pager.resolve();
163163 let total = state.store.admin_counts().await?.users;
164164 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+ }
165178 // The password picker offers every account, not just the current page.
166179 let everyone = state.store.list_users().await?;
167180 let (_, chrome) = build_chrome(
@@ -182,12 +195,15 @@ pub async fn users(
182195 span class="entry-row-title" {
183196 a href={ "/" (u.username) } { (u.username) }
184197 @if u.is_admin { " " span class="badge" { "admin" } }
198+ @if verified.contains(&u.id) { " " span class="badge badge-verified" { "verified" } }
185199 @if u.disabled_at.is_some() { " " span class="badge badge-private" { "disabled" } }
186200 }
187201 div class="entry-row-meta muted" { (u.email) }
188202 }
189203 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+ }
191207 (toggle_button(&format!("/admin/users/{}/admin", u.id), &csrf, "admin", !u.is_admin,
192208 if u.is_admin { "Revoke admin" } else { "Make admin" }))
193209 (toggle_button(&format!("/admin/users/{}/disable", u.id), &csrf, "disabled", u.disabled_at.is_none(),
@@ -214,8 +230,8 @@ pub async fn users(
214230 }
215231 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)." }
216232 div class="admin-form-actions" {
217- label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
218233 button class="btn btn-primary inline-btn" type="submit" { "Create user" }
234+ label class="checkbox" { input type="checkbox" name="admin" value="1"; " Administrator" }
219235 }
220236 }
221237 }
crates/web/src/pages.rs +3 −0
@@ -1271,6 +1271,9 @@ pub async fn invite_submit(
12711271 .set_password(&invite.user_id, Some(&hash))
12721272 .await?;
12731273 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;
12741277 let user = state
12751278 .store
12761279 .user_by_id(&invite.user_id)