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

@@ -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,