refactor(User): clear separation PublicUserDto, FullUserDto, SelfUserDto

This commit is contained in:
Edouard Vanbelle
2026-08-21 14:06:54 +02:00
parent cc3be1ec38
commit ec70b21c6e
4 changed files with 747 additions and 1 deletions
+256
View File
@@ -288,6 +288,262 @@ impl From<User> for UserDto {
}
}
// ────────────────────────────────────────────────────────────────────────
// Three-layer user DTO family — see docs/plan/userdto-refactor.md.
//
// `PublicUserDto` — public identity. Every authenticated caller may see it.
// Returned by /api/users/{id}, share responses, group
// members, magic-link invitees, recipient enrichment.
// `FullUserDto` — `{ user: PublicUserDto, ...admin+self extras }`.
// Returned as `Vec<FullUserDto>` by /api/admin/users;
// embedded in `SelfUserDto`. Closest DTO to the
// `auth.users` row.
// `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned
// by /api/auth/me and by every AuthResponseDto path.
//
// The fat `UserDto` above is being phased out — the three types will replace
// it and its emitter sites migrate one at a time. Kept temporarily so this
// PR compiles at every checkpoint; deleted at the end of the refactor.
// ────────────────────────────────────────────────────────────────────────
/// Public identity — what any authenticated caller may see about ANOTHER
/// user. Returned by `/api/users/{id}` and everywhere a user is
/// referenced by another surface (share responses, group members,
/// magic-link invitees, recipient enrichment).
///
/// This is the audience-narrowest DTO: adding a field here means every
/// authenticated caller can see it about every visible user. Fields that
/// are meaningful only to the subject themselves (preferences, session
/// state) or only to an admin (auth adoption signals) belong on
/// [`SelfUserDto`] or [`FullUserDto`] respectively.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PublicUserDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub email: String,
/// Role string ("admin" | "user"). Kept public because the sharee /
/// group-member vignette renders an admin badge.
pub role: String,
/// Avatar payload (base64 data-URI up to 512 KiB). Public so a share
/// picker can render the recipient's face directly. Will move to a
/// dedicated avatar endpoint in a future refactor — this shape is
/// transitional.
pub image: Option<String>,
/// `true` for grant-only external recipients (magic-link, OIDC-only,
/// future OCM federated). Renders the "external" badge on the vignette.
pub is_external: bool,
/// Optional first/given name. Social identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
/// Optional last/family name. Social identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
/// Presence signal — TRUE when the server observed a request on any
/// of this user's non-revoked sessions within the last
/// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
/// (5 min). Sourced from an EXISTS subquery when the DTO is built
/// from a list-projection path; single-user endpoints that don't
/// enrich presence ship `false`.
#[serde(default)]
pub is_online: bool,
}
/// Full user record — public identity + all fields BOTH an admin
/// (viewing another user) AND the subject themselves may see. Returned
/// as `Vec<FullUserDto>` by `/api/admin/users`; embedded in
/// [`SelfUserDto`] for `/api/auth/me`.
///
/// This is the DTO closest to the underlying `auth.users` row. Adding a
/// field here means an admin looking at any user can see it, and the
/// subject themselves can see it in their `/me` response — but the field
/// stays off the public [`PublicUserDto`] surface.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FullUserDto {
/// Public identity — same set every authenticated caller sees.
pub user: PublicUserDto,
/// Which trust chain minted this user's federation identity —
/// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local users.
/// Kept off `PublicUserDto` because a peer's federation kind is a
/// soft org-affiliation leak; only self + admin need it.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_kind: Option<String>,
/// The authority that minted this user's `federation_subject` —
/// issuer URL for OIDC (id_token `iss` claim), peer domain for OCM,
/// `None` for local users. Same rationale as `federation_kind`.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_issuer: Option<String>,
/// Subject's own locale preference. Only THEY or an admin managing
/// them needs this — other callers use their own locale.
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_locale: Option<String>,
/// When the user first demonstrated control of their email. Trust
/// signal — meaningful to admin (auditing verification status) and
/// to self (own record), but not to a share picker rendering a
/// vignette.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified_at: Option<DateTime<Utc>>,
/// Row bookkeeping.
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Activity signal — private to the subject; admin sees it too.
pub last_login_at: Option<DateTime<Utc>>,
/// Account-active flag — a deactivated user couldn't reach `/me`
/// anyway, but admin needs to see it.
pub active: bool,
/// Storage quotas — personal financials. Admin manages others';
/// self sees own.
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
/// TRUE when the account has a server-verifiable password
/// (`password_hash IS NOT NULL`). Kept off `PublicUserDto` because
/// per-user auth adoption leaks through directory endpoints.
pub has_password: bool,
/// TRUE when the user has an OPAQUE envelope on file.
pub opaque_registered: bool,
/// TRUE when the user has completed ≥1 successful OPAQUE login.
/// Distinct from `opaque_registered`: an admin can invalidate the
/// envelope leaving the user registered=false but with historical
/// migrated=true.
pub opaque_migrated: bool,
}
/// Self view — everything the caller may see about themselves.
/// Returned by `/api/auth/me` and by every `AuthResponseDto` path
/// (login / refresh / OIDC callback / magic-link redemption).
///
/// Composed on top of [`FullUserDto`] so `/me` and `/admin/users` share
/// the SAME "full profile" contract for the fields both need — new
/// self+admin-visible fields go on `FullUserDto` and both endpoints get
/// them together. Fields here are pure self-scoped state: preferences,
/// session-scoped flags, and caller-scoped permissions.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SelfUserDto {
/// Full profile — same shape as one row of `/api/admin/users`.
pub full: FullUserDto,
/// Opaque UI preferences bag — my own UI state. Cross-device store
/// for pure UI toggles (view mode, sidebar collapse, hide dotfiles,
/// …). The server never inspects the contents. Always present on
/// the wire; empty bag is `{}`, never `null`.
pub ui_preferences: serde_json::Value,
/// Whether I want share-notification emails.
pub notify_on_share: bool,
/// Session-scoped: my current session carries a DPoP thumbprint.
/// SPA reads this on `session.load()` to skip a redundant
/// `POST /api/auth/dpop/bind` when the session is already bound.
pub is_dpop_bound: bool,
/// Admin-set temp-password gate — SPA nav guard blocks everything
/// but `/change-password` until this flips back. Cleared by a
/// successful `POST /api/auth/change-password`.
pub force_password_change: bool,
/// Caller-scoped permission: can I edit my own avatar? `false` for
/// OIDC users whose avatar comes from the IdP. Only meaningful when
/// caller == subject; nonsense on any other DTO.
pub can_edit_image: bool,
}
impl From<User> for PublicUserDto {
fn from(user: User) -> Self {
let role = format!("{}", user.role());
let p = user.into_parts();
Self {
id: p.id.to_string(),
username: p.username,
email: p.email,
role,
image: p.image,
is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
// Single-user paths that don't enrich presence ship `false`.
// List projections (admin users, sharees enriched with
// presence) build via FullUserDto::build below, which
// overrides this from UserDerivedFlags.
is_online: false,
}
}
}
impl FullUserDto {
/// Construct a `FullUserDto` from a `User` entity plus the DB-derived
/// flags the entity doesn't carry (`has_password`, OPAQUE flags,
/// `is_online`). Both are typically produced together by the users
/// list repo projection.
///
/// Not a `From` impl because it takes two arguments; not a `From
/// <(User, UserDerivedFlags)>` because that reads awkwardly at
/// callsites — `FullUserDto::build(user, flags)` is clearer.
pub fn build(
user: User,
flags: crate::domain::repositories::user_repository::UserDerivedFlags,
) -> Self {
let role = format!("{}", user.role());
let p = user.into_parts();
Self {
user: PublicUserDto {
id: p.id.to_string(),
username: p.username,
email: p.email,
role,
image: p.image,
is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
is_online: flags.is_online,
},
federation_kind: p.federation_kind.map(|k| k.as_str().to_string()),
federation_issuer: p.federation_issuer,
preferred_locale: p.preferred_locale,
email_verified_at: p.email_verified_at,
created_at: p.created_at,
updated_at: p.updated_at,
last_login_at: p.last_login_at,
active: p.active,
storage_quota_bytes: p.storage_quota_bytes,
storage_used_bytes: p.storage_used_bytes,
has_password: flags.has_password,
opaque_registered: flags.opaque_registered,
opaque_migrated: flags.opaque_migrated,
}
}
}
impl SelfUserDto {
/// Assemble the `/me` response from a `FullUserDto` plus the two
/// session-scoped booleans that can't be derived from `User` alone:
/// the caller's DPoP-binding state (from the JWT `cnf.jkt` claim)
/// and the admin-set force-password-change flag (from the auth
/// service's cache).
///
/// The other self-only fields (`ui_preferences`, `notify_on_share`,
/// `can_edit_image`) come from `User` and are read off the entity
/// before it's moved into the FullUserDto; this method takes those
/// as explicit parameters so the caller can decide when to read
/// them (typically at the same point they read the DPoP-binding
/// state).
pub fn build(
full: FullUserDto,
ui_preferences: serde_json::Value,
notify_on_share: bool,
is_dpop_bound: bool,
force_password_change: bool,
can_edit_image: bool,
) -> Self {
Self {
full,
ui_preferences,
notify_on_share,
is_dpop_bound,
force_password_change,
can_edit_image,
}
}
}
// ────────────────────────────────────────────────────────────────────────
// End of three-layer user DTO family.
// ────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct LoginDto {
/// Identifier the user typed. Accepts BOTH a username (no `@`) and
@@ -69,6 +69,51 @@ pub struct UserListEntry {
/// 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,
/// Optional avatar payload (base64, up to 512 KiB per row). Included
/// on the admin list projection so the SPA can seed its per-user
/// `resolveUser` cache from the list row and skip the follow-up
/// `/api/users/{id}` fetch UserVignette would otherwise trigger.
/// The narrow-projection concern that motivated omitting this
/// column originally is retired by that cache-seeding path — the
/// bytes now do useful work per page load instead of being
/// discarded. Deferred: moving avatar storage out of the row
/// entirely (planned refactor); this shape is transitional.
pub image: Option<String>,
/// Presence signal — TRUE when the server observed a request on
/// any of this user's non-revoked sessions within the last
/// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
/// (5 min). Populated via an `EXISTS(...)` subquery on
/// `auth.sessions` in the list projection — the partial index
/// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the
/// scan, so per-row cost is ~μs. Surfaces to the FE via
/// `UserDto::is_online` so both `/api/users/{id}` and the admin
/// listing carry it, and the admin table renders a green/grey
/// presence dot next to each vignette.
pub is_online: bool,
}
/// DB-computed booleans about a user that aren't fields on the
/// [`User`](crate::domain::entities::user::User) entity itself —
/// either derived from column presence (`password_hash IS NOT NULL`)
/// or from a cross-table lookup (`auth.sessions.last_seen_at` for
/// `is_online`). Companion to `User` on the list projection: the
/// repo computes both, the application layer packs them into
/// [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto).
///
/// Not "admin-only" — every field ends up on `FullUserDto`, which
/// both admin AND self read. The name reflects "derived from the DB
/// row, not intrinsic to the User entity".
///
/// See `docs/plan/userdto-refactor.md` for the phasing that
/// introduces this type; it will replace [`UserListEntry`] once the
/// list repo is switched from narrow projection to
/// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor).
#[derive(Debug, Clone, Copy)]
pub struct UserDerivedFlags {
pub has_password: bool,
pub opaque_registered: bool,
pub opaque_migrated: bool,
pub is_online: bool,
}
// Conversion from UserRepositoryError to DomainError
@@ -858,6 +858,8 @@ impl UserRepository for UserPgRepository {
bool,
bool,
bool,
Option<String>,
bool,
),
>(
// Auth-credential columns projected as booleans via `IS NOT
@@ -871,6 +873,21 @@ impl UserRepository for UserPgRepository {
// federation_kind / federation_issuer, the SPA derives the
// full "capability set" per user (password / OPAQUE / SSO /
// passwordless).
//
// `image` is projected too — the previous narrow projection
// (ROUND12 §Q1 / ROUND13 §Q1) discarded up to 512 KiB per
// row because the admin table never rendered it. That's now
// reversed: the SPA seeds its per-user `resolveUser` cache
// from these rows to kill the N+1 `/api/users/{id}` fetches
// UserVignette would otherwise trigger.
//
// `is_online` uses an EXISTS scalar subquery against
// `auth.sessions` — the partial index
// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers
// the lookup, so per-row cost is ~μs. The window comes from
// `application::dtos::session_dto::ONLINE_WINDOW` (bound as
// `$4` seconds), same single-source-of-truth pattern the
// `session_liveness_gauges` module uses.
r#"
SELECT
id, username, email, role::text,
@@ -879,7 +896,14 @@ impl UserRepository for UserPgRepository {
federation_kind, federation_issuer, is_external,
(password_hash IS NOT NULL) AS has_password,
(opaque_envelope IS NOT NULL) AS opaque_registered,
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
(opaque_migrated_at IS NOT NULL) AS opaque_migrated,
image,
EXISTS (
SELECT 1 FROM auth.sessions s
WHERE s.user_id = auth.users.id
AND s.revoked = FALSE
AND s.last_seen_at > NOW() - make_interval(secs => $4)
) AS is_online
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC, id DESC
@@ -889,6 +913,7 @@ impl UserRepository for UserPgRepository {
.bind(limit)
.bind(offset)
.bind(include_external)
.bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
.fetch_all(self.pool.as_ref())
.await
.map_err(Self::map_sqlx_error)?;
@@ -911,6 +936,8 @@ impl UserRepository for UserPgRepository {
has_password,
opaque_registered,
opaque_migrated,
image,
is_online,
)| UserListEntry {
id,
username,
@@ -930,6 +957,8 @@ impl UserRepository for UserPgRepository {
has_password,
opaque_registered,
opaque_migrated,
image,
is_online,
},
)
.collect())