refactor(User): move UserDto to PublicUserDto
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::user_repository::UserListEntry;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol_str::SmolStr;
|
||||
@@ -7,287 +6,6 @@ use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
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.
|
||||
//
|
||||
@@ -301,9 +19,10 @@ impl From<User> for UserDto {
|
||||
// `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.
|
||||
// Adding a field? Decide by audience:
|
||||
// * Any authenticated caller may see it about another user → `PublicUserDto`.
|
||||
// * 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
|
||||
@@ -823,7 +542,7 @@ pub struct OidcProviderInfoDto {
|
||||
/// users JIT-provisioned via this IdP.
|
||||
///
|
||||
/// 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
|
||||
/// issuer URLs like `https://sso.example.com/realms/main` in the
|
||||
/// 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::session::Session;
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::user_repository::UserListEntry;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -194,15 +193,6 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
include_external: bool,
|
||||
) -> 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.
|
||||
/// See [`list_users`] for the meaning of `include_external`.
|
||||
async fn search_users(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::application::dtos::user_dto::{
|
||||
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, RefreshTokenDto, RegisterDto,
|
||||
SelfUserDto, UpgradeToInternalDto, UserDto,
|
||||
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, PublicUserDto, RefreshTokenDto,
|
||||
RegisterDto, SelfUserDto, UpgradeToInternalDto,
|
||||
};
|
||||
use crate::application::ports::auth_ports::{
|
||||
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
||||
@@ -319,11 +319,11 @@ pub enum OidcCallbackResult {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RegisterResult {
|
||||
/// 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
|
||||
/// small. `register` is called once per request; the
|
||||
/// allocation cost is negligible.
|
||||
Created(Box<UserDto>),
|
||||
Created(Box<PublicUserDto>),
|
||||
UsernameTaken,
|
||||
EmailTaken,
|
||||
}
|
||||
@@ -876,7 +876,7 @@ impl AuthApplicationService {
|
||||
is_external = false,
|
||||
"🛂 user registered",
|
||||
);
|
||||
Ok(RegisterResult::Created(Box::new(UserDto::from(
|
||||
Ok(RegisterResult::Created(Box::new(PublicUserDto::from(
|
||||
created_user,
|
||||
))))
|
||||
}
|
||||
@@ -894,7 +894,7 @@ impl AuthApplicationService {
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
) -> Result<PublicUserDto, DomainError> {
|
||||
// Validate username
|
||||
if username.len() < 3 || username.len() > 254 {
|
||||
return Err(DomainError::new(
|
||||
@@ -981,7 +981,7 @@ impl AuthApplicationService {
|
||||
username,
|
||||
created_user.id()
|
||||
);
|
||||
Ok(UserDto::from(created_user))
|
||||
Ok(PublicUserDto::from(created_user))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
@@ -2081,7 +2081,7 @@ impl AuthApplicationService {
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
dto: UpgradeToInternalDto,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
) -> Result<PublicUserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
|
||||
// Precondition: caller is currently external. Fast-path 409 so
|
||||
@@ -2182,7 +2182,7 @@ impl AuthApplicationService {
|
||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||
}
|
||||
|
||||
Ok(UserDto::from(updated))
|
||||
Ok(PublicUserDto::from(updated))
|
||||
}
|
||||
|
||||
/// Admin-driven external → internal promotion.
|
||||
@@ -2211,7 +2211,7 @@ impl AuthApplicationService {
|
||||
&self,
|
||||
admin_id: Uuid,
|
||||
target_id: Uuid,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
) -> Result<PublicUserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(target_id).await?;
|
||||
|
||||
if !user.is_external() {
|
||||
@@ -2293,7 +2293,7 @@ impl AuthApplicationService {
|
||||
"👮🏻♂️ 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
|
||||
@@ -2548,9 +2548,9 @@ impl AuthApplicationService {
|
||||
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?;
|
||||
Ok(UserDto::from(user))
|
||||
Ok(PublicUserDto::from(user))
|
||||
}
|
||||
|
||||
/// Cached, image-free lookup of the caller's authorization flags
|
||||
@@ -2699,7 +2699,7 @@ impl AuthApplicationService {
|
||||
caller_id: Uuid,
|
||||
dto: crate::application::dtos::user_dto::UpdateProfileDto,
|
||||
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?;
|
||||
|
||||
// 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() {
|
||||
// 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
|
||||
@@ -2906,11 +2906,11 @@ impl AuthApplicationService {
|
||||
// Refetch so the returned DTO reflects the merged JSONB bag
|
||||
// (the in-memory `user` above holds the pre-merge value).
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3003,12 +3003,12 @@ impl AuthApplicationService {
|
||||
target_id: Uuid,
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
) -> Result<PublicUserDto, DomainError> {
|
||||
// (1) Self — a single fetch suffices (the check compares the input
|
||||
// UUIDs, so the target read is never needed on this path).
|
||||
if caller_id == target_id {
|
||||
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
|
||||
@@ -3069,7 +3069,7 @@ impl AuthApplicationService {
|
||||
})?;
|
||||
|
||||
if related.is_some() {
|
||||
return Ok(UserDto::from(target));
|
||||
return Ok(PublicUserDto::from(target));
|
||||
}
|
||||
|
||||
// (3) External callers stop here — no directory enumeration.
|
||||
@@ -3097,12 +3097,12 @@ impl AuthApplicationService {
|
||||
|
||||
// (4) Internal target + system-address-book exposed: already public.
|
||||
if !target.is_external() && expose_system_users {
|
||||
return Ok(UserDto::from(target));
|
||||
return Ok(PublicUserDto::from(target));
|
||||
}
|
||||
|
||||
// (5) Admin caller: always visible.
|
||||
if caller.role() == UserRole::Admin {
|
||||
return Ok(UserDto::from(target));
|
||||
return Ok(PublicUserDto::from(target));
|
||||
}
|
||||
|
||||
// (6) No relationship — anti-enumeration NotFound.
|
||||
@@ -3155,7 +3155,7 @@ impl AuthApplicationService {
|
||||
username: &str,
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
) -> Result<PublicUserDto, DomainError> {
|
||||
let target = match self.user_storage.get_user_by_username(username).await {
|
||||
Ok(u) => u,
|
||||
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
|
||||
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?;
|
||||
Ok(UserDto::from(user))
|
||||
Ok(PublicUserDto::from(user))
|
||||
}
|
||||
|
||||
// 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
|
||||
/// surfaces that need the full list should call
|
||||
/// [`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?;
|
||||
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.
|
||||
@@ -3215,10 +3219,10 @@ impl AuthApplicationService {
|
||||
caller_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<UserDto>, DomainError> {
|
||||
) -> Result<Vec<PublicUserDto>, DomainError> {
|
||||
self.require_admin_caller(authorization, caller_id).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
|
||||
@@ -3267,9 +3271,13 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
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
|
||||
@@ -3375,7 +3383,7 @@ impl AuthApplicationService {
|
||||
pub async fn admin_create_user(
|
||||
&self,
|
||||
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
) -> Result<PublicUserDto, DomainError> {
|
||||
// Validate username length
|
||||
if dto.username.len() < 3 || dto.username.len() > 254 {
|
||||
return Err(DomainError::new(
|
||||
@@ -3533,7 +3541,7 @@ impl AuthApplicationService {
|
||||
created.id(),
|
||||
created.is_external()
|
||||
);
|
||||
Ok(UserDto::from(created))
|
||||
Ok(PublicUserDto::from(created))
|
||||
}
|
||||
|
||||
/// Admin-only: reset a user's password.
|
||||
@@ -3630,9 +3638,9 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
Ok(UserDto::from(user))
|
||||
Ok(PublicUserDto::from(user))
|
||||
}
|
||||
|
||||
/// Delete a user by ID (admin only).
|
||||
|
||||
Reference in New Issue
Block a user