diff --git a/crates/manager-core/src/auth_api.rs b/crates/manager-core/src/auth_api.rs index 44ecb28..6fd8a76 100644 --- a/crates/manager-core/src/auth_api.rs +++ b/crates/manager-core/src/auth_api.rs @@ -18,7 +18,7 @@ use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::Router; use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use picloud_shared::AdminUserId; +use picloud_shared::{AdminUserId, InstanceRole}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -63,6 +63,8 @@ pub struct LoginResponse { pub struct AdminUserDto { pub id: AdminUserId, pub username: String, + pub instance_role: InstanceRole, + pub email: Option, } // ---------------------------------------------------------------------------- @@ -87,9 +89,11 @@ async fn login(State(state): State, Json(input): Json) } }; - let (stored_hash, user_id, username, is_active) = match creds { - Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active), - None => (DUMMY_HASH.to_string(), None, String::new(), false), + // username from creds is discarded — the re-fetch below carries the + // canonical row used in the response DTO. + let (stored_hash, user_id, is_active) = match creds { + Some(c) => (c.password_hash, Some(c.id), c.is_active), + None => (DUMMY_HASH.to_string(), None, false), }; let password_ok = verify_password(&stored_hash, &input.password); @@ -98,6 +102,18 @@ async fn login(State(state): State, Json(input): Json) } let user_id = user_id.unwrap(); + // Re-fetch the full row so the login response carries the same + // shape /me does (instance_role, email). The credentials struct + // intentionally omits email; one extra query per login is fine. + let user_row = match state.users.get(user_id).await { + Ok(Some(row)) => row, + Ok(None) => return invalid_credentials(), + Err(err) => { + tracing::error!(?err, "admin_users lookup after login failed"); + return internal_error(); + } + }; + let token = generate_session_token(); let expires_at = Utc::now() + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24)); @@ -130,8 +146,10 @@ async fn login(State(state): State, Json(input): Json) headers, Json(LoginResponse { user: AdminUserDto { - id: user_id, - username, + id: user_row.id, + username: user_row.username, + instance_role: user_row.instance_role, + email: user_row.email, }, token: token.raw, expires_at, @@ -171,6 +189,8 @@ async fn me( Ok(Some(row)) => Json(AdminUserDto { id: row.id, username: row.username, + instance_role: row.instance_role, + email: row.email, }) .into_response(), Ok(None) => invalid_credentials(), diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index d183dbe..e1da44e 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -93,6 +93,27 @@ async fn healthz_responds_ok(pool: PgPool) { assert_eq!(r.text(), "ok"); } +// ============================================================================ +// Auth +// ============================================================================ + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[sqlx::test(migrations = "../manager-core/migrations")] +async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) { + let s = server(pool).await; + let r = s.get("/api/v1/admin/auth/me").await; + r.assert_status_ok(); + let body: Value = r.json(); + assert_eq!(body["username"], "test-admin"); + assert_eq!(body["instance_role"], "owner"); + // Seeded admin has no email — must round-trip as null, not be missing. + assert!( + body.get("email").map(Value::is_null).unwrap_or(false), + "email should be present and null, got: {body}" + ); + assert!(body["id"].as_str().is_some()); +} + // ============================================================================ // Script CRUD // ============================================================================