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())
|