refactor(User): move UserDto to PublicUserDto
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
use crate::domain::entities::user::User;
|
use crate::domain::entities::user::User;
|
||||||
use crate::domain::repositories::user_repository::UserListEntry;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smol_str::SmolStr;
|
use smol_str::SmolStr;
|
||||||
@@ -7,287 +6,6 @@ use std::sync::Arc;
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct UserDto {
|
|
||||||
pub id: String,
|
|
||||||
/// Optional handle. `None` for users who have not claimed one
|
|
||||||
/// (externals, fresh email-only signups). Frontend display callers
|
|
||||||
/// should walk `username → given/family → email` as their fallback
|
|
||||||
/// chain. Omitted from JSON when None (consistent with the existing
|
|
||||||
/// given_name / family_name fields).
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub email: String,
|
|
||||||
pub role: String,
|
|
||||||
pub storage_quota_bytes: i64,
|
|
||||||
pub storage_used_bytes: i64,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
|
||||||
pub active: bool,
|
|
||||||
/// Which trust chain minted this user's federation identity —
|
|
||||||
/// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local
|
|
||||||
/// users. Load-bearing for "is this user OIDC?"-shape predicates:
|
|
||||||
/// use `federation_kind == "oidc"` rather than string-scraping
|
|
||||||
/// `federation_issuer`. Serialized only when populated.
|
|
||||||
///
|
|
||||||
/// Mirrors `auth.users.federation_kind` verbatim — same name at
|
|
||||||
/// DB, entity, and wire layers so there's no translation to reason
|
|
||||||
/// about. See docs/plan/ocm.md § Identity & auth model.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub federation_kind: Option<String>,
|
|
||||||
/// The authority that mints this user's `federation_subject` —
|
|
||||||
/// issuer URL for OIDC (id_token `iss` claim), peer domain for
|
|
||||||
/// OCM, `null` for local users (password / OPAQUE only).
|
|
||||||
///
|
|
||||||
/// Renamed from `auth_provider` (which was a `String` with the
|
|
||||||
/// sentinel `"local"` for non-federated users, and a human-readable
|
|
||||||
/// label like `"MockSSO"` before Phase B). This shape mirrors the
|
|
||||||
/// `auth.users.federation_issuer` column directly: nullable when
|
|
||||||
/// there's no federation involved. FE predicates for "is this user
|
|
||||||
/// federated?" should read `federation_kind`, not
|
|
||||||
/// string-compare this value.
|
|
||||||
///
|
|
||||||
/// When populated, FE code that wants a friendly display label
|
|
||||||
/// looks this value up against `OidcProviderInfoDto.issuer →
|
|
||||||
/// provider_name` to render the deployment's configured display
|
|
||||||
/// name; falls back to the raw issuer for foreign IdPs / legacy
|
|
||||||
/// rows still holding a pre-Phase-B label.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub federation_issuer: Option<String>,
|
|
||||||
pub image: Option<String>,
|
|
||||||
pub can_edit_image: bool,
|
|
||||||
/// `true` for grant-only external recipients (magic-link, OIDC-only,
|
|
||||||
/// future OCM federated). External users have no home folder and
|
|
||||||
/// can't own storage; their quota is always 0. Internal users
|
|
||||||
/// default to `false`.
|
|
||||||
pub is_external: bool,
|
|
||||||
/// Optional first/given name. Populated from the OIDC `given_name`
|
|
||||||
/// claim at JIT provisioning, or via a profile-edit endpoint.
|
|
||||||
/// `None` until explicitly set — `skip_serializing_if = "Option::is_none"`
|
|
||||||
/// keeps the wire format compact for the common case.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub given_name: Option<String>,
|
|
||||||
/// Optional last/family name. Same provenance + serde rules as
|
|
||||||
/// `given_name`.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub family_name: Option<String>,
|
|
||||||
/// When the user first demonstrated control of their email (PR 23).
|
|
||||||
/// `None` = unverified (omitted from JSON). Stamped on the first
|
|
||||||
/// successful magic-link redemption or OIDC JIT with verified
|
|
||||||
/// claim. Idempotent — the original timestamp is preserved on
|
|
||||||
/// subsequent verifications.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub email_verified_at: Option<DateTime<Utc>>,
|
|
||||||
/// User-chosen locale for server-rendered surfaces (emails,
|
|
||||||
/// future authenticated HTML). `None` = no preference (the server
|
|
||||||
/// resolves to `OXICLOUD_DEFAULT_LOCALE` when rendering). Round-trips
|
|
||||||
/// through `/api/auth/me` and `PATCH /api/auth/me/profile`.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub preferred_locale: Option<String>,
|
|
||||||
/// Whether the user wants an email when someone shares a resource
|
|
||||||
/// with them. `true` (default) = receive share-notification mails;
|
|
||||||
/// `false` = grants are still created but no email is sent. Honored
|
|
||||||
/// only on the plain-notification path — magic-link first-invitations
|
|
||||||
/// to brand-new external users always send, otherwise the recipient
|
|
||||||
/// could never claim the share. Round-trips through `/api/auth/me`
|
|
||||||
/// and `PATCH /api/auth/me/profile`.
|
|
||||||
pub notify_on_share: bool,
|
|
||||||
/// Opaque UI preferences bag. Cross-device store for pure UI
|
|
||||||
/// toggles (hide dotfiles, view mode, sidebar collapse, …). The
|
|
||||||
/// server never inspects the contents — this DTO field just echoes
|
|
||||||
/// what was PATCHed via `PATCH /api/auth/me/profile`. Shape is a
|
|
||||||
/// JSON object; the frontend defines the keys it cares about (see
|
|
||||||
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
|
|
||||||
/// on the wire; empty bag is `{}`, never `null`.
|
|
||||||
pub ui_preferences: serde_json::Value,
|
|
||||||
/// Mirrors `auth.users.force_password_change_at_next_login`. Set
|
|
||||||
/// TRUE by the admin password-reset flow (see
|
|
||||||
/// `AuthApplicationService::admin_reset_password`) and cleared by
|
|
||||||
/// a successful self-service `POST /api/auth/change-password`.
|
|
||||||
///
|
|
||||||
/// Populated only by the `/api/auth/me` handler and the login
|
|
||||||
/// response minter (via a distinct code path). `From<User>` — used
|
|
||||||
/// by admin listings, share-recipient responses, group-member DTOs,
|
|
||||||
/// etc. — leaves it at `false`. The flag is a per-session-account
|
|
||||||
/// concern (does *this* user need to change their password before
|
|
||||||
/// they can proceed?), not a general user attribute worth
|
|
||||||
/// surfacing on every list row.
|
|
||||||
///
|
|
||||||
/// The load-bearing consumer is the SPA's session store: on
|
|
||||||
/// startup and after every refresh, `/me` returns the current
|
|
||||||
/// flag value and the SPA's nav-guard blocks navigation to
|
|
||||||
/// anything but the change-password surface until it flips
|
|
||||||
/// back to false. Backend enforcement is separate (see the
|
|
||||||
/// `require_no_password_change_pending` middleware) — this DTO
|
|
||||||
/// field is what the SPA reads to render the mandatory-mode UI.
|
|
||||||
#[serde(default)]
|
|
||||||
pub force_password_change: bool,
|
|
||||||
/// TRUE when the account has a local Argon2id `password_hash` on
|
|
||||||
/// file. Distinct from `federation_kind`: an OIDC-linked account
|
|
||||||
/// (`federation_kind == "oidc"`) can ALSO carry a local password if
|
|
||||||
/// it was set at signup or later — a hybrid posture. The SPA
|
|
||||||
/// gates the profile page's change-password card on this flag,
|
|
||||||
/// so hybrid users can rotate their local password even though
|
|
||||||
/// they normally sign in via SSO.
|
|
||||||
///
|
|
||||||
/// Populated only by the `/api/auth/me` handler. `From<User>` in
|
|
||||||
/// this file leaves it `false` — other UserDto emitters (admin
|
|
||||||
/// listings, share-recipient responses, group members) do not
|
|
||||||
/// need to surface per-user credential state.
|
|
||||||
#[serde(default)]
|
|
||||||
pub has_password: bool,
|
|
||||||
/// TRUE when the caller's current session carries a DPoP JWK
|
|
||||||
/// thumbprint (`session.dpop_jkt IS NOT NULL`). Sourced from the
|
|
||||||
/// caller's JWT `cnf.jkt` claim — `is_some()` means the session
|
|
||||||
/// was bound at token-mint time.
|
|
||||||
///
|
|
||||||
/// Populated only by the `/api/auth/me` handler; other UserDto
|
|
||||||
/// emitters leave it `false`. The SPA reads this on `session.load()`
|
|
||||||
/// to skip a redundant `POST /api/auth/dpop/bind` call when the
|
|
||||||
/// session is already bound (which would 409 and log noisily under
|
|
||||||
/// the audit stream — see the `already_bound` reject). Only the
|
|
||||||
/// OIDC / magic-link redirect flows land here as `false` on first
|
|
||||||
/// visit; password login binds at session-mint time so the very
|
|
||||||
/// first `/me` after login already reports `true`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_dpop_bound: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compact row returned by the paginated admin user table.
|
|
||||||
///
|
|
||||||
/// Account-detail fields deliberately do not appear here. In particular,
|
|
||||||
/// omitting `image` and `ui_preferences` prevents a 100-row page from turning
|
|
||||||
/// into tens of MiB when users have uploaded avatars. `GET /api/admin/users/:id`
|
|
||||||
/// remains the full-detail endpoint.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct AdminUserSummaryDto {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub email: String,
|
|
||||||
pub role: String,
|
|
||||||
pub storage_quota_bytes: i64,
|
|
||||||
pub storage_used_bytes: i64,
|
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
|
||||||
pub active: bool,
|
|
||||||
/// See `UserDto::federation_kind` — same semantics, same wire spelling.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub federation_kind: Option<String>,
|
|
||||||
/// See `UserDto::federation_issuer` — same semantics, same wire spelling.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub federation_issuer: Option<String>,
|
|
||||||
pub is_external: bool,
|
|
||||||
/// TRUE when the user has a server-verifiable password on file
|
|
||||||
/// (`password_hash IS NOT NULL`). The admin table uses this
|
|
||||||
/// alongside `federation_issuer` and `opaque_registered` to render
|
|
||||||
/// the user's full capability set: a `password` chip lights up
|
|
||||||
/// here, an OIDC provider name renders the SSO badge, an
|
|
||||||
/// envelope-on-file flips the OPAQUE chip. A user with none of
|
|
||||||
/// the three is passwordless (magic-link only — the SPA renders
|
|
||||||
/// a distinct `passwordless` chip in that case). Admin-only
|
|
||||||
/// exposure — see the DTO doc for why this isn't on `UserDto`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub has_password: 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<UserListEntry> for AdminUserSummaryDto {
|
|
||||||
fn from(entry: UserListEntry) -> Self {
|
|
||||||
Self {
|
|
||||||
id: entry.id.to_string(),
|
|
||||||
username: entry.username,
|
|
||||||
email: entry.email,
|
|
||||||
role: entry.role.to_string(),
|
|
||||||
storage_quota_bytes: entry.storage_quota_bytes,
|
|
||||||
storage_used_bytes: entry.storage_used_bytes,
|
|
||||||
last_login_at: entry.last_login_at,
|
|
||||||
active: entry.active,
|
|
||||||
federation_kind: entry.federation_kind,
|
|
||||||
federation_issuer: entry.federation_issuer,
|
|
||||||
is_external: entry.is_external,
|
|
||||||
has_password: entry.has_password,
|
|
||||||
opaque_registered: entry.opaque_registered,
|
|
||||||
opaque_migrated: entry.opaque_migrated,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<User> for UserDto {
|
|
||||||
fn from(user: User) -> Self {
|
|
||||||
// `user` is owned and dropped here, so every owned field is MOVED out
|
|
||||||
// via `into_parts` rather than cloned through the borrowing accessors —
|
|
||||||
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and
|
|
||||||
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin
|
|
||||||
// user listing (benches/ROUND20.md §A2). The two derived values read the
|
|
||||||
// entity before the move.
|
|
||||||
let role = format!("{}", user.role());
|
|
||||||
let can_edit_image = !user.is_oidc_user();
|
|
||||||
// has_password is derivable from the entity — read before the
|
|
||||||
// move. Cheap (bool from Option::is_some), no extra DB round-
|
|
||||||
// trip, so From<User> can populate it uniformly rather than
|
|
||||||
// leaving it false and requiring per-call-site backfill.
|
|
||||||
let has_password = user.has_password();
|
|
||||||
let p = user.into_parts();
|
|
||||||
Self {
|
|
||||||
id: p.id.to_string(),
|
|
||||||
username: p.username,
|
|
||||||
email: p.email,
|
|
||||||
role,
|
|
||||||
storage_quota_bytes: p.storage_quota_bytes,
|
|
||||||
storage_used_bytes: p.storage_used_bytes,
|
|
||||||
created_at: p.created_at,
|
|
||||||
updated_at: p.updated_at,
|
|
||||||
last_login_at: p.last_login_at,
|
|
||||||
active: p.active,
|
|
||||||
// NULL on both fields for local users (no federation wired).
|
|
||||||
// FE predicates use `!!federation_kind` for "is federated?" —
|
|
||||||
// no "local" sentinel string; the null tells the whole story.
|
|
||||||
federation_kind: p.federation_kind.map(|k| k.as_str().to_string()),
|
|
||||||
federation_issuer: p.federation_issuer,
|
|
||||||
image: p.image,
|
|
||||||
can_edit_image,
|
|
||||||
is_external: p.is_external,
|
|
||||||
given_name: p.given_name,
|
|
||||||
family_name: p.family_name,
|
|
||||||
email_verified_at: p.email_verified_at,
|
|
||||||
preferred_locale: p.preferred_locale,
|
|
||||||
notify_on_share: p.notify_on_share,
|
|
||||||
ui_preferences: p.ui_preferences,
|
|
||||||
// Defaults to false. The `/me` handler + the login-response
|
|
||||||
// minter populate this via a distinct code path (a
|
|
||||||
// repo read that goes through the auth service's cache);
|
|
||||||
// admin listings and other UserDto consumers deliberately
|
|
||||||
// leave it false — the flag is per-session-account state,
|
|
||||||
// not a general user attribute.
|
|
||||||
force_password_change: false,
|
|
||||||
has_password,
|
|
||||||
// Populated only by `/api/auth/me` — the handler overlays
|
|
||||||
// the caller's session's actual DPoP binding state after
|
|
||||||
// this `From<User>` runs. Other UserDto emitters leave
|
|
||||||
// this at `false` (they lack session context).
|
|
||||||
is_dpop_bound: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
// Three-layer user DTO family — see docs/plan/userdto-refactor.md.
|
// Three-layer user DTO family — see docs/plan/userdto-refactor.md.
|
||||||
//
|
//
|
||||||
@@ -301,9 +19,10 @@ impl From<User> for UserDto {
|
|||||||
// `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned
|
// `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned
|
||||||
// by /api/auth/me and by every AuthResponseDto path.
|
// by /api/auth/me and by every AuthResponseDto path.
|
||||||
//
|
//
|
||||||
// The fat `UserDto` above is being phased out — the three types will replace
|
// Adding a field? Decide by audience:
|
||||||
// it and its emitter sites migrate one at a time. Kept temporarily so this
|
// * Any authenticated caller may see it about another user → `PublicUserDto`.
|
||||||
// PR compiles at every checkpoint; deleted at the end of the refactor.
|
// * Only admin (about another user) AND self (about self) → `FullUserDto`.
|
||||||
|
// * Only self about themselves → `SelfUserDto`.
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Public identity — what any authenticated caller may see about ANOTHER
|
/// Public identity — what any authenticated caller may see about ANOTHER
|
||||||
@@ -823,7 +542,7 @@ pub struct OidcProviderInfoDto {
|
|||||||
/// users JIT-provisioned via this IdP.
|
/// users JIT-provisioned via this IdP.
|
||||||
///
|
///
|
||||||
/// Populated so the frontend can resolve display: when
|
/// Populated so the frontend can resolve display: when
|
||||||
/// `UserDto.federation_issuer` equals this `issuer`, render
|
/// `PublicUserDto.federation_issuer` equals this `issuer`, render
|
||||||
/// `provider_name` as the human-friendly label (avoids showing raw
|
/// `provider_name` as the human-friendly label (avoids showing raw
|
||||||
/// issuer URLs like `https://sso.example.com/realms/main` in the
|
/// issuer URLs like `https://sso.example.com/realms/main` in the
|
||||||
/// admin badge / profile view). Falls back to the raw issuer when
|
/// admin badge / profile view). Falls back to the raw issuer when
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use crate::domain::entities::app_password::AppPassword;
|
|||||||
use crate::domain::entities::device_code::DeviceCode;
|
use crate::domain::entities::device_code::DeviceCode;
|
||||||
use crate::domain::entities::session::Session;
|
use crate::domain::entities::session::Session;
|
||||||
use crate::domain::entities::user::User;
|
use crate::domain::entities::user::User;
|
||||||
use crate::domain::repositories::user_repository::UserListEntry;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -194,15 +193,6 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
|||||||
include_external: bool,
|
include_external: bool,
|
||||||
) -> Result<Vec<User>, DomainError>;
|
) -> Result<Vec<User>, DomainError>;
|
||||||
|
|
||||||
/// Narrow user-list projection for management tables. Keeps heavyweight
|
|
||||||
/// account-detail fields off the database and JSON hot path.
|
|
||||||
async fn list_user_summaries(
|
|
||||||
&self,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
include_external: bool,
|
|
||||||
) -> Result<Vec<UserListEntry>, DomainError>;
|
|
||||||
|
|
||||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||||
/// See [`list_users`] for the meaning of `include_external`.
|
/// See [`list_users`] for the meaning of `include_external`.
|
||||||
async fn search_users(
|
async fn search_users(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::application::dtos::user_dto::{
|
use crate::application::dtos::user_dto::{
|
||||||
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, RefreshTokenDto, RegisterDto,
|
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, PublicUserDto, RefreshTokenDto,
|
||||||
SelfUserDto, UpgradeToInternalDto, UserDto,
|
RegisterDto, SelfUserDto, UpgradeToInternalDto,
|
||||||
};
|
};
|
||||||
use crate::application::ports::auth_ports::{
|
use crate::application::ports::auth_ports::{
|
||||||
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
||||||
@@ -319,11 +319,11 @@ pub enum OidcCallbackResult {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum RegisterResult {
|
pub enum RegisterResult {
|
||||||
/// Boxed to avoid the `large_enum_variant` clippy warning —
|
/// Boxed to avoid the `large_enum_variant` clippy warning —
|
||||||
/// `UserDto` is ~250 bytes, the other variants are zero-sized,
|
/// `PublicUserDto` is ~250 bytes, the other variants are zero-sized,
|
||||||
/// so a heap-pointer indirection keeps the enum's stack size
|
/// so a heap-pointer indirection keeps the enum's stack size
|
||||||
/// small. `register` is called once per request; the
|
/// small. `register` is called once per request; the
|
||||||
/// allocation cost is negligible.
|
/// allocation cost is negligible.
|
||||||
Created(Box<UserDto>),
|
Created(Box<PublicUserDto>),
|
||||||
UsernameTaken,
|
UsernameTaken,
|
||||||
EmailTaken,
|
EmailTaken,
|
||||||
}
|
}
|
||||||
@@ -876,7 +876,7 @@ impl AuthApplicationService {
|
|||||||
is_external = false,
|
is_external = false,
|
||||||
"🛂 user registered",
|
"🛂 user registered",
|
||||||
);
|
);
|
||||||
Ok(RegisterResult::Created(Box::new(UserDto::from(
|
Ok(RegisterResult::Created(Box::new(PublicUserDto::from(
|
||||||
created_user,
|
created_user,
|
||||||
))))
|
))))
|
||||||
}
|
}
|
||||||
@@ -894,7 +894,7 @@ impl AuthApplicationService {
|
|||||||
username: String,
|
username: String,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
// Validate username
|
// Validate username
|
||||||
if username.len() < 3 || username.len() > 254 {
|
if username.len() < 3 || username.len() > 254 {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
@@ -981,7 +981,7 @@ impl AuthApplicationService {
|
|||||||
username,
|
username,
|
||||||
created_user.id()
|
created_user.id()
|
||||||
);
|
);
|
||||||
Ok(UserDto::from(created_user))
|
Ok(PublicUserDto::from(created_user))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
@@ -2081,7 +2081,7 @@ impl AuthApplicationService {
|
|||||||
&self,
|
&self,
|
||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
dto: UpgradeToInternalDto,
|
dto: UpgradeToInternalDto,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
||||||
|
|
||||||
// Precondition: caller is currently external. Fast-path 409 so
|
// Precondition: caller is currently external. Fast-path 409 so
|
||||||
@@ -2182,7 +2182,7 @@ impl AuthApplicationService {
|
|||||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UserDto::from(updated))
|
Ok(PublicUserDto::from(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin-driven external → internal promotion.
|
/// Admin-driven external → internal promotion.
|
||||||
@@ -2211,7 +2211,7 @@ impl AuthApplicationService {
|
|||||||
&self,
|
&self,
|
||||||
admin_id: Uuid,
|
admin_id: Uuid,
|
||||||
target_id: Uuid,
|
target_id: Uuid,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
let mut user = self.user_storage.get_user_by_id(target_id).await?;
|
let mut user = self.user_storage.get_user_by_id(target_id).await?;
|
||||||
|
|
||||||
if !user.is_external() {
|
if !user.is_external() {
|
||||||
@@ -2293,7 +2293,7 @@ impl AuthApplicationService {
|
|||||||
"👮🏻♂️ external user promoted to internal by admin",
|
"👮🏻♂️ external user promoted to internal by admin",
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(UserDto::from(updated))
|
Ok(PublicUserDto::from(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `keep_session_id` — when `Some`, revoke every OTHER session for
|
/// `keep_session_id` — when `Some`, revoke every OTHER session for
|
||||||
@@ -2548,9 +2548,9 @@ impl AuthApplicationService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
|
pub async fn get_user(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
|
||||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||||
Ok(UserDto::from(user))
|
Ok(PublicUserDto::from(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached, image-free lookup of the caller's authorization flags
|
/// Cached, image-free lookup of the caller's authorization flags
|
||||||
@@ -2699,7 +2699,7 @@ impl AuthApplicationService {
|
|||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
dto: crate::application::dtos::user_dto::UpdateProfileDto,
|
dto: crate::application::dtos::user_dto::UpdateProfileDto,
|
||||||
locale_registry: &crate::common::locale::LocaleRegistry,
|
locale_registry: &crate::common::locale::LocaleRegistry,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
||||||
|
|
||||||
// For OIDC-managed users, refuse the patch ONLY when it touches
|
// For OIDC-managed users, refuse the patch ONLY when it touches
|
||||||
@@ -2875,7 +2875,7 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
if changed.is_empty() && ui_prefs_patch.is_none() {
|
if changed.is_empty() && ui_prefs_patch.is_none() {
|
||||||
// No-op — return the current user without a DB write.
|
// No-op — return the current user without a DB write.
|
||||||
return Ok(UserDto::from(user));
|
return Ok(PublicUserDto::from(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the typed-field changes first (if any). Skip the
|
// Persist the typed-field changes first (if any). Skip the
|
||||||
@@ -2906,11 +2906,11 @@ impl AuthApplicationService {
|
|||||||
// Refetch so the returned DTO reflects the merged JSONB bag
|
// Refetch so the returned DTO reflects the merged JSONB bag
|
||||||
// (the in-memory `user` above holds the pre-merge value).
|
// (the in-memory `user` above holds the pre-merge value).
|
||||||
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
|
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
|
||||||
Ok(UserDto::from(refreshed))
|
Ok(PublicUserDto::from(refreshed))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias for consistency with handler method
|
// Alias for consistency with handler method
|
||||||
pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
|
pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
|
||||||
self.get_user(user_id).await
|
self.get_user(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3003,12 +3003,12 @@ impl AuthApplicationService {
|
|||||||
target_id: Uuid,
|
target_id: Uuid,
|
||||||
expose_system_users: bool,
|
expose_system_users: bool,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
// (1) Self — a single fetch suffices (the check compares the input
|
// (1) Self — a single fetch suffices (the check compares the input
|
||||||
// UUIDs, so the target read is never needed on this path).
|
// UUIDs, so the target read is never needed on this path).
|
||||||
if caller_id == target_id {
|
if caller_id == target_id {
|
||||||
let caller = self.user_storage.get_user_by_id(caller_id).await?;
|
let caller = self.user_storage.get_user_by_id(caller_id).await?;
|
||||||
return Ok(UserDto::from(caller));
|
return Ok(PublicUserDto::from(caller));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caller and target are independent point reads (the self-case already
|
// Caller and target are independent point reads (the self-case already
|
||||||
@@ -3069,7 +3069,7 @@ impl AuthApplicationService {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
if related.is_some() {
|
if related.is_some() {
|
||||||
return Ok(UserDto::from(target));
|
return Ok(PublicUserDto::from(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
// (3) External callers stop here — no directory enumeration.
|
// (3) External callers stop here — no directory enumeration.
|
||||||
@@ -3097,12 +3097,12 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
// (4) Internal target + system-address-book exposed: already public.
|
// (4) Internal target + system-address-book exposed: already public.
|
||||||
if !target.is_external() && expose_system_users {
|
if !target.is_external() && expose_system_users {
|
||||||
return Ok(UserDto::from(target));
|
return Ok(PublicUserDto::from(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
// (5) Admin caller: always visible.
|
// (5) Admin caller: always visible.
|
||||||
if caller.role() == UserRole::Admin {
|
if caller.role() == UserRole::Admin {
|
||||||
return Ok(UserDto::from(target));
|
return Ok(PublicUserDto::from(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
// (6) No relationship — anti-enumeration NotFound.
|
// (6) No relationship — anti-enumeration NotFound.
|
||||||
@@ -3155,7 +3155,7 @@ impl AuthApplicationService {
|
|||||||
username: &str,
|
username: &str,
|
||||||
expose_system_users: bool,
|
expose_system_users: bool,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
let target = match self.user_storage.get_user_by_username(username).await {
|
let target = match self.user_storage.get_user_by_username(username).await {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||||
@@ -3182,9 +3182,9 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New method to get user by username - needed for admin user handling
|
// New method to get user by username - needed for admin user handling
|
||||||
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
|
pub async fn get_user_by_username(&self, username: &str) -> Result<PublicUserDto, DomainError> {
|
||||||
let user = self.user_storage.get_user_by_username(username).await?;
|
let user = self.user_storage.get_user_by_username(username).await?;
|
||||||
Ok(UserDto::from(user))
|
Ok(PublicUserDto::from(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to count how many admin users exist in the system
|
// Method to count how many admin users exist in the system
|
||||||
@@ -3202,9 +3202,13 @@ impl AuthApplicationService {
|
|||||||
/// sharee search, etc. — never expose external identities. Admin
|
/// sharee search, etc. — never expose external identities. Admin
|
||||||
/// surfaces that need the full list should call
|
/// surfaces that need the full list should call
|
||||||
/// [`list_users_including_external_with_perms`] instead.
|
/// [`list_users_including_external_with_perms`] instead.
|
||||||
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
|
pub async fn list_users(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<PublicUserDto>, DomainError> {
|
||||||
let users = self.user_storage.list_users(limit, offset, false).await?;
|
let users = self.user_storage.list_users(limit, offset, false).await?;
|
||||||
Ok(users.into_iter().map(UserDto::from).collect())
|
Ok(users.into_iter().map(PublicUserDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin-only: lists users including external (grant-only) recipients.
|
/// Admin-only: lists users including external (grant-only) recipients.
|
||||||
@@ -3215,10 +3219,10 @@ impl AuthApplicationService {
|
|||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<UserDto>, DomainError> {
|
) -> Result<Vec<PublicUserDto>, DomainError> {
|
||||||
self.require_admin_caller(authorization, caller_id).await?;
|
self.require_admin_caller(authorization, caller_id).await?;
|
||||||
let users = self.user_storage.list_users(limit, offset, true).await?;
|
let users = self.user_storage.list_users(limit, offset, true).await?;
|
||||||
Ok(users.into_iter().map(UserDto::from).collect())
|
Ok(users.into_iter().map(PublicUserDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin-only user listing. Returns `Vec<FullUserDto>` — same
|
/// Admin-only user listing. Returns `Vec<FullUserDto>` — same
|
||||||
@@ -3267,9 +3271,13 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Searches internal users only. See [`list_users`] for the rationale.
|
/// Searches internal users only. See [`list_users`] for the rationale.
|
||||||
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
|
pub async fn search_users(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<PublicUserDto>, DomainError> {
|
||||||
let users = self.user_storage.search_users(query, limit, false).await?;
|
let users = self.user_storage.search_users(query, limit, false).await?;
|
||||||
Ok(users.into_iter().map(UserDto::from).collect())
|
Ok(users.into_iter().map(PublicUserDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Username-only search for the NC sharee autocomplete: identical
|
/// Username-only search for the NC sharee autocomplete: identical
|
||||||
@@ -3375,7 +3383,7 @@ impl AuthApplicationService {
|
|||||||
pub async fn admin_create_user(
|
pub async fn admin_create_user(
|
||||||
&self,
|
&self,
|
||||||
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
|
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
|
||||||
) -> Result<UserDto, DomainError> {
|
) -> Result<PublicUserDto, DomainError> {
|
||||||
// Validate username length
|
// Validate username length
|
||||||
if dto.username.len() < 3 || dto.username.len() > 254 {
|
if dto.username.len() < 3 || dto.username.len() > 254 {
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
@@ -3533,7 +3541,7 @@ impl AuthApplicationService {
|
|||||||
created.id(),
|
created.id(),
|
||||||
created.is_external()
|
created.is_external()
|
||||||
);
|
);
|
||||||
Ok(UserDto::from(created))
|
Ok(PublicUserDto::from(created))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin-only: reset a user's password.
|
/// Admin-only: reset a user's password.
|
||||||
@@ -3630,9 +3638,9 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a single user by ID (for admin panel)
|
/// Get a single user by ID (for admin panel)
|
||||||
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
|
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
|
||||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||||
Ok(UserDto::from(user))
|
Ok(PublicUserDto::from(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a user by ID (admin only).
|
/// Delete a user by ID (admin only).
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ pub struct User {
|
|||||||
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
|
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
|
||||||
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
|
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
|
||||||
/// them through the borrowing accessors — notably `image` (a data URI up to
|
/// them through the borrowing accessors — notably `image` (a data URI up to
|
||||||
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
|
/// 512 KiB) and `ui_preferences` (a JSON tree). See `PublicUserDto::from`
|
||||||
/// (benches/ROUND20.md §A2).
|
/// (benches/ROUND20.md §A2).
|
||||||
pub struct UserParts {
|
pub struct UserParts {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::user::{User, UserRole};
|
use crate::domain::entities::user::{User, UserRole};
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -26,72 +25,6 @@ pub enum UserRepositoryError {
|
|||||||
|
|
||||||
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
|
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
|
||||||
|
|
||||||
/// Narrow projection for user-directory tables that do not need secrets,
|
|
||||||
/// profile pictures, or the cross-device UI-preferences document.
|
|
||||||
///
|
|
||||||
/// The full [`User`] row intentionally carries all of those fields for account
|
|
||||||
/// detail and the system address book. Reusing it for the paginated admin
|
|
||||||
/// table made PostgreSQL detoast and transfer an avatar of up to 512 KiB per
|
|
||||||
/// row, only for the handler to serialize it back to the browser where the
|
|
||||||
/// table never reads it. Keeping the projection explicit prevents a future
|
|
||||||
/// full-row field from silently returning to that hot path.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UserListEntry {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub email: String,
|
|
||||||
pub role: UserRole,
|
|
||||||
pub storage_quota_bytes: i64,
|
|
||||||
pub storage_used_bytes: i64,
|
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
|
||||||
pub active: bool,
|
|
||||||
pub federation_kind: Option<String>,
|
|
||||||
pub federation_issuer: Option<String>,
|
|
||||||
pub is_external: bool,
|
|
||||||
/// TRUE when `auth.users.password_hash IS NOT NULL` — user has a
|
|
||||||
/// server-verifiable password on file (legacy or admin-set).
|
|
||||||
/// Distinct from `opaque_registered` (which is the zero-knowledge
|
|
||||||
/// envelope): a fully-migrated user carries BOTH — password for
|
|
||||||
/// the fallback / operator flows, envelope for the actual login.
|
|
||||||
/// A user with `has_password = false AND !opaque_registered AND
|
|
||||||
/// federation_issuer IS NULL` is passwordless — the only path in is
|
|
||||||
/// via magic-link (or, for externals, whatever grant they hold).
|
|
||||||
pub has_password: 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,
|
|
||||||
/// 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
|
/// DB-computed booleans about a user that aren't fields on the
|
||||||
/// [`User`](crate::domain::entities::user::User) entity itself —
|
/// [`User`](crate::domain::entities::user::User) entity itself —
|
||||||
/// either derived from column presence (`password_hash IS NOT NULL`)
|
/// either derived from column presence (`password_hash IS NOT NULL`)
|
||||||
@@ -104,10 +37,8 @@ pub struct UserListEntry {
|
|||||||
/// both admin AND self read. The name reflects "derived from the DB
|
/// both admin AND self read. The name reflects "derived from the DB
|
||||||
/// row, not intrinsic to the User entity".
|
/// row, not intrinsic to the User entity".
|
||||||
///
|
///
|
||||||
/// See `docs/plan/userdto-refactor.md` for the phasing that
|
/// See `docs/plan/userdto-refactor.md` for the design; this type
|
||||||
/// introduces this type; it will replace [`UserListEntry`] once the
|
/// replaced the earlier `UserListEntry` narrow projection as of P6.
|
||||||
/// list repo is switched from narrow projection to
|
|
||||||
/// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct UserDerivedFlags {
|
pub struct UserDerivedFlags {
|
||||||
pub has_password: bool,
|
pub has_password: bool,
|
||||||
@@ -211,22 +142,6 @@ pub trait UserRepository: Send + Sync + 'static {
|
|||||||
include_external: bool,
|
include_external: bool,
|
||||||
) -> UserRepositoryResult<Vec<User>>;
|
) -> UserRepositoryResult<Vec<User>>;
|
||||||
|
|
||||||
/// Lists the columns needed by compact user-management tables. Unlike
|
|
||||||
/// [`Self::list_users`], this never fetches password hashes, OIDC subjects,
|
|
||||||
/// avatars, names, locale state, or UI preferences.
|
|
||||||
///
|
|
||||||
/// **Deprecated** — [`Self::list_users_with_derived_flags`] supersedes
|
|
||||||
/// this: it returns the full `User` entity + [`UserDerivedFlags`] so
|
|
||||||
/// the application layer can build [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto)
|
|
||||||
/// directly. Kept only until P6 of `docs/plan/userdto-refactor.md`
|
|
||||||
/// removes `UserListEntry` + the last remaining caller.
|
|
||||||
async fn list_user_summaries(
|
|
||||||
&self,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
include_external: bool,
|
|
||||||
) -> UserRepositoryResult<Vec<UserListEntry>>;
|
|
||||||
|
|
||||||
/// Paginated admin user listing — full `User` entity + the derived
|
/// Paginated admin user listing — full `User` entity + the derived
|
||||||
/// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
|
/// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
|
||||||
/// SELECT. Called by the admin service to build
|
/// SELECT. Called by the admin service to build
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::UserStoragePort;
|
|||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::user::{User, UserFlags, UserRole};
|
use crate::domain::entities::user::{User, UserFlags, UserRole};
|
||||||
use crate::domain::repositories::user_repository::{
|
use crate::domain::repositories::user_repository::{
|
||||||
StorageStats, UserListEntry, UserRepository, UserRepositoryError, UserRepositoryResult,
|
StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult,
|
||||||
};
|
};
|
||||||
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
|
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
|
||||||
|
|
||||||
@@ -919,135 +919,6 @@ impl UserRepository for UserPgRepository {
|
|||||||
Ok(users)
|
Ok(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_user_summaries(
|
|
||||||
&self,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
include_external: bool,
|
|
||||||
) -> UserRepositoryResult<Vec<UserListEntry>> {
|
|
||||||
let rows = sqlx::query_as::<
|
|
||||||
_,
|
|
||||||
(
|
|
||||||
Uuid,
|
|
||||||
Option<String>,
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
i64,
|
|
||||||
i64,
|
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
bool,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
bool,
|
|
||||||
Option<String>,
|
|
||||||
bool,
|
|
||||||
),
|
|
||||||
>(
|
|
||||||
// Auth-credential columns projected as booleans via `IS NOT
|
|
||||||
// NULL` rather than as timestamps / hashes so the row-mapping
|
|
||||||
// tuple stays small and the wire shape is exactly what the
|
|
||||||
// admin table needs. Per-row scalar tests — no cost beyond
|
|
||||||
// the full-table sequential scan the LIMIT/OFFSET already
|
|
||||||
// pays. `has_password` on the password_hash column tells
|
|
||||||
// the admin table whether a server-verifiable password is
|
|
||||||
// on file; combined with the two OPAQUE flags and
|
|
||||||
// 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,
|
|
||||||
storage_quota_bytes, storage_used_bytes,
|
|
||||||
last_login_at, active,
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
LIMIT $1 OFFSET $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.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)?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(
|
|
||||||
|(
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
role,
|
|
||||||
storage_quota_bytes,
|
|
||||||
storage_used_bytes,
|
|
||||||
last_login_at,
|
|
||||||
active,
|
|
||||||
federation_kind,
|
|
||||||
federation_issuer,
|
|
||||||
is_external,
|
|
||||||
has_password,
|
|
||||||
opaque_registered,
|
|
||||||
opaque_migrated,
|
|
||||||
image,
|
|
||||||
is_online,
|
|
||||||
)| UserListEntry {
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
role: if role == "admin" {
|
|
||||||
UserRole::Admin
|
|
||||||
} else {
|
|
||||||
UserRole::User
|
|
||||||
},
|
|
||||||
storage_quota_bytes,
|
|
||||||
storage_used_bytes,
|
|
||||||
last_login_at,
|
|
||||||
active,
|
|
||||||
federation_kind,
|
|
||||||
federation_issuer,
|
|
||||||
is_external,
|
|
||||||
has_password,
|
|
||||||
opaque_registered,
|
|
||||||
opaque_migrated,
|
|
||||||
image,
|
|
||||||
is_online,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_users_with_derived_flags(
|
async fn list_users_with_derived_flags(
|
||||||
&self,
|
&self,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
@@ -1588,17 +1459,6 @@ impl UserStoragePort for UserPgRepository {
|
|||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_user_summaries(
|
|
||||||
&self,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
include_external: bool,
|
|
||||||
) -> Result<Vec<UserListEntry>, DomainError> {
|
|
||||||
UserRepository::list_user_summaries(self, limit, offset, include_external)
|
|
||||||
.await
|
|
||||||
.map_err(DomainError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_users_with_derived_flags(
|
async fn list_users_with_derived_flags(
|
||||||
&self,
|
&self,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
@@ -1992,26 +1852,27 @@ mod integration_tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let page = UserRepository::list_user_summaries(&repo, 3, 0, true)
|
// Migrated from the (now-deleted) `list_user_summaries` +
|
||||||
|
// `UserListEntry` to `list_users_with_derived_flags`, which
|
||||||
|
// returns `Vec<(User, UserDerivedFlags)>`. Field checks read
|
||||||
|
// through the `User` accessors instead of struct-field access.
|
||||||
|
let page = UserRepository::list_users_with_derived_flags(&repo, 3, 0, true)
|
||||||
.await
|
.await
|
||||||
.expect("compact projection query must decode");
|
.expect("compact projection query must decode");
|
||||||
assert_eq!(page.iter().map(|entry| entry.id).collect::<Vec<_>>(), ids);
|
assert_eq!(page.iter().map(|(u, _)| u.id()).collect::<Vec<_>>(), ids);
|
||||||
assert_eq!(page[0].username.as_deref(), Some(username_a.as_str()));
|
assert_eq!(page[0].0.username(), Some(username_a.as_str()));
|
||||||
assert_eq!(page[0].role, UserRole::Admin);
|
assert_eq!(page[0].0.role(), UserRole::Admin);
|
||||||
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
|
assert_eq!(page[0].0.storage_quota_bytes(), 10_737_418_240);
|
||||||
assert_eq!(page[1].username, None);
|
assert_eq!(page[1].0.username(), None);
|
||||||
assert!(page[1].is_external);
|
assert!(page[1].0.is_external());
|
||||||
assert_eq!(
|
assert_eq!(page[1].0.federation_issuer(), Some("integration-idp"));
|
||||||
page[1].federation_issuer.as_deref(),
|
|
||||||
Some("integration-idp")
|
|
||||||
);
|
|
||||||
|
|
||||||
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
|
let internal = UserRepository::list_users_with_derived_flags(&repo, 10, 0, false)
|
||||||
.await
|
.await
|
||||||
.expect("internal compact projection query must decode");
|
.expect("internal compact projection query must decode");
|
||||||
assert!(internal.iter().any(|entry| entry.id == ids[0]));
|
assert!(internal.iter().any(|(u, _)| u.id() == ids[0]));
|
||||||
assert!(internal.iter().any(|entry| entry.id == ids[2]));
|
assert!(internal.iter().any(|(u, _)| u.id() == ids[2]));
|
||||||
assert!(!internal.iter().any(|entry| entry.id == ids[1]));
|
assert!(!internal.iter().any(|(u, _)| u.id() == ids[1]));
|
||||||
|
|
||||||
sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)")
|
sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)")
|
||||||
.bind(ids.as_slice())
|
.bind(ids.as_slice())
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use crate::application::dtos::settings_dto::{
|
|||||||
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
|
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
|
||||||
UpdateUserRoleDto,
|
UpdateUserRoleDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::user_dto::{FullUserDto, UserDto};
|
use crate::application::dtos::user_dto::{FullUserDto, PublicUserDto};
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
|
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
|
||||||
// JobStoreProvider is used only by the storage-migration shims below,
|
// JobStoreProvider is used only by the storage-migration shims below,
|
||||||
@@ -42,10 +42,10 @@ use uuid::Uuid;
|
|||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
enum AdminUsersPayload {
|
enum AdminUsersPayload {
|
||||||
/// Fat-`UserDto` per row. Emitted when `?summary=false` — legacy
|
/// Fat-`PublicUserDto` per row. Emitted when `?summary=false` — legacy
|
||||||
/// path retained until the FE drops the `summary=false` query
|
/// path retained until the FE drops the `summary=false` query
|
||||||
/// (rare; the SPA uses `summary=true` for the paginated table).
|
/// (rare; the SPA uses `summary=true` for the paginated table).
|
||||||
Full(Vec<UserDto>),
|
Full(Vec<PublicUserDto>),
|
||||||
/// `FullUserDto` per row — same shape one row of the /me
|
/// `FullUserDto` per row — same shape one row of the /me
|
||||||
/// response's embedded `full` carries. Emitted when
|
/// response's embedded `full` carries. Emitted when
|
||||||
/// `?summary=true`. The FE seeds `resolveUser` cache from
|
/// `?summary=true`. The FE seeds `resolveUser` cache from
|
||||||
@@ -1571,7 +1571,7 @@ pub async fn reset_user_password(
|
|||||||
path = "/api/admin/users/{id}/promote-to-internal",
|
path = "/api/admin/users/{id}/promote-to-internal",
|
||||||
params(("id" = String, Path, description = "Target user id")),
|
params(("id" = String, Path, description = "Target user id")),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "User promoted", body = UserDto),
|
(status = 200, description = "User promoted", body = PublicUserDto),
|
||||||
(status = 400, description = "Magic-link login is disabled on this deployment"),
|
(status = 400, description = "Magic-link login is disabled on this deployment"),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 403, description = "Admin required (or target is OIDC-linked)"),
|
(status = 403, description = "Admin required (or target is OIDC-linked)"),
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async fn create_app_password(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Require a claimed username. NextCloud Basic Auth resolves users by
|
// Require a claimed username. NextCloud Basic Auth resolves users by
|
||||||
// username; an app password is unusable without one. UserDto carries
|
// username; an app password is unusable without one. PublicUserDto carries
|
||||||
// an empty string when the underlying `users.username` is NULL — the
|
// an empty string when the underlying `users.username` is NULL — the
|
||||||
// entity rejects empty strings on construction, so empty here is an
|
// entity rejects empty strings on construction, so empty here is an
|
||||||
// unambiguous signal that the column is NULL.
|
// unambiguous signal that the column is NULL.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::application::dtos::user_dto::{
|
use crate::application::dtos::user_dto::{
|
||||||
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto,
|
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, OidcCallbackQueryDto,
|
||||||
OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto,
|
OidcExchangeDto, OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto,
|
||||||
UpgradeToInternalDto, UserDto,
|
SetupAdminDto, UpgradeToInternalDto,
|
||||||
};
|
};
|
||||||
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
|
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
@@ -89,7 +89,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
|
|||||||
/// the `audit` channel as `auth.register` with `reason` one of
|
/// the `audit` channel as `auth.register` with `reason` one of
|
||||||
/// `created`, `email_taken`, `username_taken`.
|
/// `created`, `email_taken`, `username_taken`.
|
||||||
/// - **SMTP not configured**: there is no welcome-mail cover story, so
|
/// - **SMTP not configured**: there is no welcome-mail cover story, so
|
||||||
/// the classic `201 + UserDto` on success and `409` on collision
|
/// the classic `201 + PublicUserDto` on success and `409` on collision
|
||||||
/// apply. Anti-enumeration would just be misleading UX (telling the
|
/// apply. Anti-enumeration would just be misleading UX (telling the
|
||||||
/// user to check an email that will never arrive). Email-only
|
/// user to check an email that will never arrive). Email-only
|
||||||
/// signup is **503** in this mode because the user would otherwise
|
/// signup is **503** in this mode because the user would otherwise
|
||||||
@@ -106,7 +106,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
|
|||||||
request_body = RegisterDto,
|
request_body = RegisterDto,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"),
|
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"),
|
||||||
(status = 201, description = "User registered successfully (SMTP not configured)", body = UserDto),
|
(status = 201, description = "User registered successfully (SMTP not configured)", body = PublicUserDto),
|
||||||
(status = 400, description = "Validation error (malformed request body)"),
|
(status = 400, description = "Validation error (malformed request body)"),
|
||||||
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
|
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
|
||||||
(status = 409, description = "Username or email already taken (SMTP not configured)"),
|
(status = 409, description = "Username or email already taken (SMTP not configured)"),
|
||||||
@@ -280,7 +280,7 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
} else {
|
} else {
|
||||||
// Classic mode: clear 201 + UserDto so the frontend can
|
// Classic mode: clear 201 + PublicUserDto so the frontend can
|
||||||
// log the user in directly with the password they just
|
// log the user in directly with the password they just
|
||||||
// submitted. Unbox the DTO for the JSON serialisation.
|
// submitted. Unbox the DTO for the JSON serialisation.
|
||||||
Ok((StatusCode::CREATED, Json(*user)).into_response())
|
Ok((StatusCode::CREATED, Json(*user)).into_response())
|
||||||
@@ -659,7 +659,7 @@ pub async fn get_current_user(
|
|||||||
// `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags /
|
// `User` entity + `UserDerivedFlags` (has_password / OPAQUE flags /
|
||||||
// is_online) in one round-trip. That collapses what used to be a
|
// is_online) in one round-trip. That collapses what used to be a
|
||||||
// `get_user_by_id` + separate credential lookups into one wire trip,
|
// `get_user_by_id` + separate credential lookups into one wire trip,
|
||||||
// AND populates the OPAQUE flags on `/me` which the fat-UserDto path
|
// AND populates the OPAQUE flags on `/me` which the fat-PublicUserDto path
|
||||||
// never did (it left them at false — the "quiet lie" that motivated
|
// never did (it left them at false — the "quiet lie" that motivated
|
||||||
// this refactor, see `docs/plan/userdto-refactor.md`).
|
// this refactor, see `docs/plan/userdto-refactor.md`).
|
||||||
let (user, flags) = auth_service
|
let (user, flags) = auth_service
|
||||||
@@ -860,14 +860,14 @@ pub async fn change_password(
|
|||||||
/// self-registration policy. Refused with 403
|
/// self-registration policy. Refused with 403
|
||||||
/// `error_type = "RegistrationDomainNotAllowed"`.
|
/// `error_type = "RegistrationDomainNotAllowed"`.
|
||||||
///
|
///
|
||||||
/// Response: the updated `UserDto` (post-upgrade view — `is_external`
|
/// Response: the updated `PublicUserDto` (post-upgrade view — `is_external`
|
||||||
/// is false, `storage_quota_bytes` is set).
|
/// is false, `storage_quota_bytes` is set).
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/upgrade-to-internal",
|
path = "/api/auth/upgrade-to-internal",
|
||||||
request_body = UpgradeToInternalDto,
|
request_body = UpgradeToInternalDto,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Upgrade succeeded", body = UserDto),
|
(status = 200, description = "Upgrade succeeded", body = PublicUserDto),
|
||||||
(status = 400, description = "Password missing / too short"),
|
(status = 400, description = "Password missing / too short"),
|
||||||
(status = 401, description = "Not authenticated"),
|
(status = 401, description = "Not authenticated"),
|
||||||
(status = 403, description = "OIDC user, or domain not in allowlist"),
|
(status = 403, description = "OIDC user, or domain not in allowlist"),
|
||||||
@@ -965,7 +965,7 @@ pub async fn upgrade_to_internal(
|
|||||||
path = "/api/auth/me/profile",
|
path = "/api/auth/me/profile",
|
||||||
request_body = crate::application::dtos::user_dto::UpdateProfileDto,
|
request_body = crate::application::dtos::user_dto::UpdateProfileDto,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Updated profile (UserDto)", body = UserDto),
|
(status = 200, description = "Updated profile (PublicUserDto)", body = PublicUserDto),
|
||||||
(status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"),
|
(status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"),
|
||||||
(status = 401, description = "Not authenticated"),
|
(status = 401, description = "Not authenticated"),
|
||||||
(status = 403, description = "OIDC-managed profile — edit at the IdP"),
|
(status = 403, description = "OIDC-managed profile — edit at the IdP"),
|
||||||
@@ -1249,7 +1249,7 @@ pub struct BackchannelLogoutForm {
|
|||||||
path = "/api/setup",
|
path = "/api/setup",
|
||||||
request_body = SetupAdminDto,
|
request_body = SetupAdminDto,
|
||||||
responses(
|
responses(
|
||||||
(status = 201, description = "First admin created and system initialized", body = UserDto),
|
(status = 201, description = "First admin created and system initialized", body = PublicUserDto),
|
||||||
(status = 403, description = "System already initialized"),
|
(status = 403, description = "System already initialized"),
|
||||||
(status = 503, description = "Auth service not configured"),
|
(status = 503, description = "Auth service not configured"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::application::dtos::contact_dto::{
|
|||||||
AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto,
|
AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto,
|
||||||
GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto,
|
GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::user_dto::UserDto;
|
use crate::application::dtos::user_dto::PublicUserDto;
|
||||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||||
use crate::application::services::contact_service::ContactService;
|
use crate::application::services::contact_service::ContactService;
|
||||||
@@ -185,14 +185,14 @@ fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts
|
/// Map a `PublicUserDto` to a `ContactDto` so OxiCloud users appear as contacts
|
||||||
/// inside the virtual system address book.
|
/// inside the virtual system address book.
|
||||||
///
|
///
|
||||||
/// `given_name`/`family_name` come from OIDC standard claims at JIT
|
/// `given_name`/`family_name` come from OIDC standard claims at JIT
|
||||||
/// provisioning (or NULL for password-only or pre-OIDC users). When
|
/// provisioning (or NULL for password-only or pre-OIDC users). When
|
||||||
/// they're present, prefer a "First Last" full name; otherwise fall
|
/// they're present, prefer a "First Last" full name; otherwise fall
|
||||||
/// back to the username (which is always present).
|
/// back to the username (which is always present).
|
||||||
fn user_to_contact(user: UserDto) -> ContactDto {
|
fn user_to_contact(user: PublicUserDto) -> ContactDto {
|
||||||
// Display fallback chain: given+family name → username → email.
|
// Display fallback chain: given+family name → username → email.
|
||||||
// Username is `Option<String>` post PR 16; externals start with None.
|
// Username is `Option<String>` post PR 16; externals start with None.
|
||||||
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
|
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
|
||||||
@@ -222,8 +222,19 @@ fn user_to_contact(user: UserDto) -> ContactDto {
|
|||||||
photo_url: user.image.clone(),
|
photo_url: user.image.clone(),
|
||||||
birthday: None,
|
birthday: None,
|
||||||
anniversary: None,
|
anniversary: None,
|
||||||
created_at: user.created_at,
|
// System-book contacts are VIRTUAL projections of the user
|
||||||
updated_at: user.updated_at,
|
// directory — they have no independent creation history. Stamp
|
||||||
|
// both timestamps with `Utc::now()` so the ContactDto shape is
|
||||||
|
// satisfied; CardDAV clients ETag on the vCard content (see
|
||||||
|
// `etag` below, keyed on the stable user id), not on these
|
||||||
|
// wrapper timestamps.
|
||||||
|
//
|
||||||
|
// Previously read `user.created_at` / `user.updated_at` from the
|
||||||
|
// fat `UserDto`; those fields moved to `FullUserDto` under the
|
||||||
|
// three-layer refactor (docs/plan/userdto-refactor.md) and are
|
||||||
|
// not exposed on the slim `PublicUserDto` this function receives.
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
etag: user.id,
|
etag: user.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! User-profile lookup for the frontend.
|
//! User-profile lookup for the frontend.
|
||||||
//!
|
//!
|
||||||
//! `GET /api/users/{id}` returns a [`UserDto`] for the target user iff
|
//! `GET /api/users/{id}` returns a [`PublicUserDto`] for the target user iff
|
||||||
//! the authenticated caller has a legitimate relationship with them.
|
//! the authenticated caller has a legitimate relationship with them.
|
||||||
//! The visibility rule lives in
|
//! The visibility rule lives in
|
||||||
//! [`AuthApplicationService::get_user_profile`] — handlers never embed
|
//! [`AuthApplicationService::get_user_profile`] — handlers never embed
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ use crate::application::dtos::trash_dto::{
|
|||||||
};
|
};
|
||||||
use crate::application::dtos::user_dto::{
|
use crate::application::dtos::user_dto::{
|
||||||
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
|
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
|
||||||
RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
|
PublicUserDto, RefreshTokenDto, RegisterDto, SetupAdminDto,
|
||||||
};
|
};
|
||||||
use crate::application::ports::chunked_upload_ports::{
|
use crate::application::ports::chunked_upload_ports::{
|
||||||
ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto,
|
ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto,
|
||||||
@@ -367,7 +367,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
PaginationDto,
|
PaginationDto,
|
||||||
PaginationRequestDto,
|
PaginationRequestDto,
|
||||||
// User / Auth schemas
|
// User / Auth schemas
|
||||||
UserDto,
|
PublicUserDto,
|
||||||
LoginDto,
|
LoginDto,
|
||||||
RegisterDto,
|
RegisterDto,
|
||||||
SetupAdminDto,
|
SetupAdminDto,
|
||||||
@@ -583,7 +583,7 @@ mod tests {
|
|||||||
"FolderDto",
|
"FolderDto",
|
||||||
"ShareDto",
|
"ShareDto",
|
||||||
"TrashedItemDto",
|
"TrashedItemDto",
|
||||||
"UserDto",
|
"PublicUserDto",
|
||||||
] {
|
] {
|
||||||
assert!(schemas.contains_key(name), "missing schema: {name}");
|
assert!(schemas.contains_key(name), "missing schema: {name}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,15 @@ async fn user_provisioning_response(
|
|||||||
return Json(ocs_err(997, "Database pool not available")).into_response();
|
return Json(ocs_err(997, "Database pool not available")).into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_dto = match auth_service
|
// Two-step lookup: (1) `get_user_profile_by_username_with_perms`
|
||||||
|
// gates access via the same visibility engine the REST endpoint
|
||||||
|
// uses; (2) if visibility passes, `get_user_with_derived_flags`
|
||||||
|
// hydrates the OCS-specific fields (federation_kind / last_login_at
|
||||||
|
// / active) that live on `FullUserDto` but not on the slim
|
||||||
|
// `PublicUserDto` returned by the visibility gate. Second call is
|
||||||
|
// ~1 DB round-trip on the maintenance pool; NC OCS provisioning is
|
||||||
|
// not on any hot inner loop.
|
||||||
|
let public = match auth_service
|
||||||
.get_user_profile_by_username_with_perms(
|
.get_user_profile_by_username_with_perms(
|
||||||
user.id,
|
user.id,
|
||||||
&userid,
|
&userid,
|
||||||
@@ -205,9 +213,27 @@ async fn user_provisioning_response(
|
|||||||
return Json(ocs_err(404, "User not found")).into_response();
|
return Json(ocs_err(404, "User not found")).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let target_id = match uuid::Uuid::parse_str(&public.id) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => {
|
||||||
|
// Should be unreachable — PublicUserDto.id is always the
|
||||||
|
// serialised form of a Uuid. Fail closed if this invariant
|
||||||
|
// is ever violated.
|
||||||
|
return Json(ocs_err(500, "Malformed user id")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let user_dto = match auth_service.get_user_with_derived_flags(target_id).await {
|
||||||
|
Ok((user, flags)) => crate::application::dtos::user_dto::FullUserDto::build(user, flags),
|
||||||
|
Err(_) => {
|
||||||
|
// Visibility already passed above; a miss here would mean
|
||||||
|
// the user was deleted between the two round-trips. Fall
|
||||||
|
// back to the 404 shape (anti-enum invariant still holds).
|
||||||
|
return Json(ocs_err(404, "User not found")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Determine groups based on role
|
// Determine groups based on role
|
||||||
let groups = if user_dto.role == "admin" {
|
let groups = if user_dto.user.role == "admin" {
|
||||||
vec!["admin", "users"]
|
vec!["admin", "users"]
|
||||||
} else {
|
} else {
|
||||||
vec!["users"]
|
vec!["users"]
|
||||||
@@ -235,7 +261,7 @@ async fn user_provisioning_response(
|
|||||||
// Fetch quota from storage usage service
|
// Fetch quota from storage usage service
|
||||||
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
||||||
Some(service) => match service
|
Some(service) => match service
|
||||||
.get_user_storage_info(uuid::Uuid::parse_str(&user_dto.id).unwrap_or_default())
|
.get_user_storage_info(uuid::Uuid::parse_str(&user_dto.user.id).unwrap_or_default())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((used, total)) => (used, total),
|
Ok((used, total)) => (used, total),
|
||||||
@@ -256,10 +282,10 @@ async fn user_provisioning_response(
|
|||||||
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
|
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
|
||||||
"data": {
|
"data": {
|
||||||
"enabled": user_dto.active,
|
"enabled": user_dto.active,
|
||||||
"id": user_dto.username,
|
"id": user_dto.user.username,
|
||||||
"display-name": user_dto.username,
|
"display-name": user_dto.user.username,
|
||||||
"displayname": user_dto.username,
|
"displayname": user_dto.user.username,
|
||||||
"email": user_dto.email,
|
"email": user_dto.user.email,
|
||||||
"phone": "",
|
"phone": "",
|
||||||
"address": "",
|
"address": "",
|
||||||
"website": "",
|
"website": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user