feat(manager-core,picloud): expose instance_role + email on /auth/me
Login and /auth/me now return the same shape — id, username, instance_role, email — so the dashboard can gate UI on role from either the login response or the layout's me() refetch without an extra round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ use axum::response::{IntoResponse, Json, Response};
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -63,6 +63,8 @@ pub struct LoginResponse {
|
|||||||
pub struct AdminUserDto {
|
pub struct AdminUserDto {
|
||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -87,9 +89,11 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (stored_hash, user_id, username, is_active) = match creds {
|
// username from creds is discarded — the re-fetch below carries the
|
||||||
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
// canonical row used in the response DTO.
|
||||||
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
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);
|
let password_ok = verify_password(&stored_hash, &input.password);
|
||||||
@@ -98,6 +102,18 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
}
|
}
|
||||||
let user_id = user_id.unwrap();
|
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 token = generate_session_token();
|
||||||
let expires_at = Utc::now()
|
let expires_at = Utc::now()
|
||||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
@@ -130,8 +146,10 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
headers,
|
headers,
|
||||||
Json(LoginResponse {
|
Json(LoginResponse {
|
||||||
user: AdminUserDto {
|
user: AdminUserDto {
|
||||||
id: user_id,
|
id: user_row.id,
|
||||||
username,
|
username: user_row.username,
|
||||||
|
instance_role: user_row.instance_role,
|
||||||
|
email: user_row.email,
|
||||||
},
|
},
|
||||||
token: token.raw,
|
token: token.raw,
|
||||||
expires_at,
|
expires_at,
|
||||||
@@ -171,6 +189,8 @@ async fn me(
|
|||||||
Ok(Some(row)) => Json(AdminUserDto {
|
Ok(Some(row)) => Json(AdminUserDto {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
|
instance_role: row.instance_role,
|
||||||
|
email: row.email,
|
||||||
})
|
})
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Ok(None) => invalid_credentials(),
|
Ok(None) => invalid_credentials(),
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
assert_eq!(r.text(), "ok");
|
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
|
// Script CRUD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user