refactor(User): move UserDto to PublicUserDto

This commit is contained in:
Edouard Vanbelle
2026-08-21 16:09:10 +02:00
parent d17b3b6bd3
commit a11ae679cf
13 changed files with 136 additions and 606 deletions
+5 -286
View File
@@ -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
-10
View File
@@ -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).