feat(manager-core,picloud): accept email on admin create + patch

The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 19:27:52 +02:00
parent 39a6df2bfe
commit 0c9f11558a
5 changed files with 163 additions and 12 deletions

View File

@@ -95,6 +95,9 @@ pub struct CreateAdminRequest {
/// channel that defaults to `Owner`.
#[serde(default = "default_create_role")]
pub instance_role: InstanceRole,
/// Optional contact email. Blank/whitespace is normalized to None.
#[serde(default)]
pub email: Option<String>,
}
const fn default_create_role() -> InstanceRole {
@@ -107,6 +110,26 @@ pub struct PatchAdminRequest {
pub password: Option<String>,
pub is_active: Option<bool>,
pub instance_role: Option<InstanceRole>,
/// JSON Merge Patch (RFC 7396) semantics for email:
/// absent → don't change
/// null → clear (set DB column to NULL)
/// "<string>" → set to that string
/// `Option<Option<T>>` is the idiomatic Rust shape for that
/// tri-state; the custom deserializer below distinguishes the
/// "missing" case from the "present-and-null" case that serde
/// would otherwise collapse together.
#[allow(clippy::option_option)]
#[serde(default, deserialize_with = "deserialize_present_optional")]
pub email: Option<Option<String>>,
}
#[allow(clippy::option_option)]
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<T>::deserialize(deserializer)?))
}
// ----------------------------------------------------------------------------
@@ -169,10 +192,11 @@ async fn create_admin(
let username = input.username.trim();
validate_username(username)?;
validate_password(&input.password)?;
let email = normalize_email(input.email.as_deref())?;
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
let row = state
.users
.create(username, &hash, input.instance_role)
.create(username, &hash, input.instance_role, email.as_deref())
.await?;
Ok((StatusCode::CREATED, Json(row.into())))
}
@@ -216,6 +240,12 @@ async fn patch_admin(
// for the initial cut.)
}
if let Some(email_patch) = input.email.as_ref() {
// email_patch is Some(None) → clear, Some(Some(s)) → set.
let normalized = normalize_email(email_patch.as_deref())?;
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
}
if let Some(new_role) = input.instance_role {
// Self-elevation guard: only an owner can promote anyone TO
// owner. An admin cannot turn themselves (or anyone else)
@@ -358,6 +388,26 @@ fn validate_password(s: &str) -> Result<(), AdminApiError> {
Ok(())
}
/// Trim and reject empty / pathological emails, returning the
/// canonical form (or None when the input was blank). The shape
/// check is intentionally loose — we mainly want to reject blanks
/// and obvious junk; real verification is a future concern.
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
let Some(raw) = raw else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.len() > 254 || !trimmed.contains('@') {
return Err(AdminApiError::InvalidEmail(
"email must contain '@' and be at most 254 characters".to_string(),
));
}
Ok(Some(trimmed.to_string()))
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
@@ -373,6 +423,9 @@ pub enum AdminApiError {
#[error("{0}")]
InvalidPassword(String),
#[error("{0}")]
InvalidEmail(String),
#[error("cannot leave the system with zero active admins")]
LastActiveAdmin,
@@ -414,6 +467,7 @@ impl IntoResponse for AdminApiError {
) => (StatusCode::CONFLICT, self.to_string()),
Self::InvalidUsername(_)
| Self::InvalidPassword(_)
| Self::InvalidEmail(_)
| Self::LastActiveAdmin
| Self::LastActiveOwner
| Self::CannotEscalate