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:
@@ -69,12 +69,14 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||
/// explicit role.
|
||||
/// explicit role. `email` is optional — pass `None` to leave the
|
||||
/// column NULL.
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn update_username(
|
||||
&self,
|
||||
@@ -86,6 +88,12 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
id: AdminUserId,
|
||||
password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
/// Set or clear the email address. `None` writes NULL to the column.
|
||||
async fn update_email(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||
/// before invoking when role transitions away from `Owner`.
|
||||
@@ -192,24 +200,37 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"INSERT INTO admin_users (username, password_hash, instance_role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(instance_role.as_str())
|
||||
.bind(email)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => row.try_into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
// username and email both have unique constraints; the
|
||||
// create path can collide on either, so peek at the
|
||||
// constraint name to surface the right error.
|
||||
if e.constraint() == Some("admin_users_email_key") {
|
||||
Err(AdminUserRepositoryError::DuplicateEmail(
|
||||
email.unwrap_or("").to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(AdminUserRepositoryError::DuplicateUsername(
|
||||
username.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
@@ -259,6 +280,32 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn update_email(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET email = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => row.try_into(),
|
||||
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
|
||||
Reference in New Issue
Block a user