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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user