From c95d108bf8eef402ba474b78eb5bba5c35f48237 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 4 Aug 2026 23:58:52 +0200 Subject: [PATCH] feat(opaque): show users migrated in admin panel --- frontend/src/lib/api/types.ts | 21 +++++- .../src/routes/admin/[[tab]]/+page.svelte | 65 +++++++++++++++++++ src/application/dtos/user_dto.rs | 20 ++++++ src/domain/repositories/user_repository.rs | 12 ++++ .../repositories/pg/user_pg_repository.rs | 23 +++++-- 5 files changed, 134 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 3b2c03c6..1e9c738a 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -231,7 +231,14 @@ export interface User { /** Fields rendered by the paginated admin table. Full account details remain * available from the detail endpoint; this shape keeps avatars and preference - * documents off every listing page. */ + * documents off every listing page. + * + * The two OPAQUE flags below are ADMIN-ONLY signals: they surface per-user + * OPAQUE rollout progress in the admin table. The backend deliberately keeps + * them off `UserDto` (`/api/auth/me`, share-recipient DTOs, group members) + * so a non-admin can't enumerate the adoption set through third-party + * endpoints. Both optional on the wire — older backend builds omit them and + * `#[serde(default)]` maps missing → `false`. */ export type AdminUserSummary = Pick< User, | 'id' @@ -244,7 +251,17 @@ export type AdminUserSummary = Pick< | 'active' | 'auth_provider' | 'is_external' ->; +> & { + /** TRUE = user has an OPAQUE envelope on file (Phase 2 silent migration + * succeeded, or the user completed a manual re-registration). */ + opaque_registered?: boolean; + /** TRUE = user has completed at least one successful OPAQUE login. + * Distinct from `opaque_registered` — the envelope may have been + * cleared by an admin reset while a stale migrated=true remains as + * historical signal (backend clears both atomically today, but the + * two-flag shape keeps the option open for a future policy split). */ + opaque_migrated?: boolean; +}; export interface AdminUsersPage { total: number; diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 017efcc3..6805dfd8 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -2671,6 +2671,44 @@ {:else} {t('admin.local', 'local')} {/if} + + {#if u.opaque_migrated} + + + OPAQUE + + {:else if u.opaque_registered} + + + {t('admin.opaque_envelope', 'envelope')} + + {/if} @@ -4164,6 +4202,33 @@ text-transform: uppercase; } + /* + * OPAQUE adoption chips — sit next to the auth-provider badge in + * the admin user table. Green = user has actually logged in via + * OPAQUE at least once (`opaque_migrated`); softer info shade = + * envelope on file but the OPAQUE handshake hasn't landed yet + * (`opaque_registered && !opaque_migrated`). The two-shade pattern + * matches the way `.badge--active` vs `.badge--inactive` split + * "success" from "neutral". + */ + .badge--opaque { + background: var(--color-success-bg); + color: var(--color-success-text); + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + + .badge--opaque-registered { + background: var(--color-info-bg); + color: var(--color-info-text); + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + .badge--active { background: var(--color-success-bg); color: var(--color-success-text); diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index d811fd5a..47e61e9e 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -115,6 +115,24 @@ pub struct AdminUserSummaryDto { pub active: bool, pub auth_provider: String, pub is_external: bool, + /// Mirrors `UserListEntry::opaque_registered` — TRUE when the user + /// has an OPAQUE envelope on file. Surfaced on the admin table so + /// operators can see per-user rollout progress during the + /// migration window. **Admin-only exposure**: this field is NOT + /// on `UserDto` — putting it there would leak adoption status + /// through every user-directory-adjacent endpoint (share targets, + /// group members, invite listings). `#[serde(default)]` keeps + /// older SPA builds tolerant of the added field. + #[serde(default)] + pub opaque_registered: bool, + /// Mirrors `UserListEntry::opaque_migrated` — TRUE when the user + /// has completed at least one successful OPAQUE login. Distinct + /// from `opaque_registered`: an admin can invalidate the envelope + /// (`clear_registration`) leaving the user registered=false but + /// with a historical migrated=true; the SPA's admin table shows + /// both so this operational nuance is visible. + #[serde(default)] + pub opaque_migrated: bool, } impl From for AdminUserSummaryDto { @@ -130,6 +148,8 @@ impl From for AdminUserSummaryDto { active: entry.active, auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()), is_external: entry.is_external, + opaque_registered: entry.opaque_registered, + opaque_migrated: entry.opaque_migrated, } } } diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs index 38bdb700..84786133 100644 --- a/src/domain/repositories/user_repository.rs +++ b/src/domain/repositories/user_repository.rs @@ -47,6 +47,18 @@ pub struct UserListEntry { pub active: bool, pub oidc_provider: Option, pub is_external: bool, + /// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user + /// has completed OPAQUE registration (typically via the Phase 2 + /// silent-migration hook after a successful legacy login). Surfaced + /// on the admin user table so operators can see rollout progress + /// per-user. Admin-only exposure — see `AdminUserSummaryDto`. + pub opaque_registered: bool, + /// TRUE when `auth.users.opaque_migrated_at IS NOT NULL` — the + /// user has completed at least one successful OPAQUE login. Distinct + /// from `opaque_registered` because a user can have an envelope on + /// file without having actually logged in via OPAQUE yet (e.g. + /// admin cleared the envelope, silent-migration hasn't re-run). + pub opaque_migrated: bool, } // Conversion from UserRepositoryError to DomainError diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 7aef057c..d8b4fa79 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -767,17 +767,26 @@ impl UserRepository for UserPgRepository { bool, Option, bool, + bool, + bool, ), >( + // OPAQUE columns are projected as booleans via `IS NOT NULL` + // rather than as timestamps so the row-mapping tuple stays + // small and the wire shape is exactly what the admin table + // needs. Both are per-row scalar tests — no cost beyond the + // full-table sequential scan the LIMIT/OFFSET already pays. r#" SELECT id, username, email, role::text, storage_quota_bytes, storage_used_bytes, - last_login_at, active, oidc_provider, is_external - FROM auth.users - WHERE ($3 OR is_external = FALSE) - ORDER BY created_at DESC, id DESC - LIMIT $1 OFFSET $2 + last_login_at, active, oidc_provider, is_external, + (opaque_envelope IS NOT NULL) AS opaque_registered, + (opaque_migrated_at IS NOT NULL) AS opaque_migrated + FROM auth.users + WHERE ($3 OR is_external = FALSE) + ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2 "#, ) .bind(limit) @@ -801,6 +810,8 @@ impl UserRepository for UserPgRepository { active, oidc_provider, is_external, + opaque_registered, + opaque_migrated, )| UserListEntry { id, username, @@ -816,6 +827,8 @@ impl UserRepository for UserPgRepository { active, oidc_provider, is_external, + opaque_registered, + opaque_migrated, }, ) .collect())