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

@@ -123,6 +123,7 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
&username,
&password_hash,
picloud_shared::InstanceRole::Owner,
None,
)
.await?;
info!(username = %username, "bootstrapped initial admin user");
@@ -176,13 +177,14 @@ mod tests {
username: &str,
_password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = AdminUserRow {
id: AdminUserId::new(),
username: username.to_string(),
is_active: true,
instance_role,
email: None,
email: email.map(str::to_string),
created_at: Utc::now(),
updated_at: Utc::now(),
last_login_at: None,
@@ -204,6 +206,13 @@ mod tests {
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_email(
&self,
_i: AdminUserId,
_e: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_instance_role(
&self,
_i: AdminUserId,
@@ -272,7 +281,7 @@ mod tests {
#[tokio::test]
async fn populated_db_is_noop() {
let repo = InMemoryRepo::default();
repo.create("seeded", "x", InstanceRole::Owner)
repo.create("seeded", "x", InstanceRole::Owner, None)
.await
.unwrap();
let env = BootstrapEnv {