feat: connect settings live flow to auth api

This commit is contained in:
a.tolmachev
2026-03-31 02:21:31 +03:00
parent 61718bce5a
commit 44363c0b1c
13 changed files with 502 additions and 102 deletions
+83 -3
View File
@@ -14,7 +14,7 @@ use crate::{
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
save_agent_bindings, update_agent,
},
auth::{get_session, login, logout},
auth::{change_password, get_profile, get_session, login, logout, update_profile},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
operations::{
@@ -137,14 +137,23 @@ pub fn build_app(state: AppState) -> Router {
let admin_router = workspace_root_router.merge(workspace_scoped_router);
let protected_auth_router = Router::new()
.route("/logout", post(logout))
.route("/session", get(get_session))
.route("/profile", get(get_profile).patch(update_profile))
.route("/password", post(change_password))
.layer(middleware::from_fn_with_state(
state.clone(),
require_session,
));
Router::new()
.route("/health", get(crate::routes::health))
.nest(
"/api/auth",
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/session", get(get_session)),
.merge(protected_auth_router),
)
.nest("/api/admin", admin_router)
.with_state(state)
@@ -756,6 +765,77 @@ mod tests {
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn updates_profile_and_changes_password() {
let registry = test_registry().await;
let storage_root = test_storage_root("settings_profile");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let root_url = base_url
.as_ref()
.split("/api/admin/workspaces/")
.next()
.unwrap()
.to_owned();
let client = authorized_client(&base_url).await;
let profile = assert_success_json(
client
.get(format!("{root_url}/api/auth/profile"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(profile["user"]["email"], TEST_AUTH_EMAIL);
let updated_profile = assert_success_json(
client
.patch(format!("{root_url}/api/auth/profile"))
.json(&json!({
"display_name": "Updated Owner",
"email": "updated-owner@crank.local"
}))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(updated_profile["user"]["display_name"], "Updated Owner");
assert_eq!(
updated_profile["user"]["email"],
"updated-owner@crank.local"
);
let password_status = client
.post(format!("{root_url}/api/auth/password"))
.json(&json!({
"current_password": TEST_AUTH_PASSWORD,
"new_password": "updated-password-123"
}))
.send()
.await
.unwrap()
.status();
assert_eq!(password_status, reqwest::StatusCode::NO_CONTENT);
let relogin_client = reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap();
let relogin_status = relogin_client
.post(format!("{root_url}/api/auth/login"))
.json(&json!({
"email": "updated-owner@crank.local",
"password": "updated-password-123"
}))
.send()
.await
.unwrap()
.status();
assert_eq!(relogin_status, reqwest::StatusCode::OK);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn exposes_logs_and_usage_from_real_test_runs() {
+6
View File
@@ -121,6 +121,9 @@ impl From<RegistryError> for ApiError {
RegistryError::WorkspaceNotFound { workspace_id } => {
Self::not_found(format!("workspace {workspace_id} was not found"))
}
RegistryError::UserNotFound { user_id } => {
Self::not_found(format!("user {user_id} was not found"))
}
RegistryError::AgentNotFound { agent_id } => {
Self::not_found(format!("agent {agent_id} was not found"))
}
@@ -162,6 +165,9 @@ impl From<RegistryError> for ApiError {
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
Self::conflict(format!("workspace with slug {slug} already exists"))
}
RegistryError::UserEmailAlreadyExists { email } => {
Self::conflict(format!("user with email {email} already exists"))
}
RegistryError::InvalidInitialVersion { .. }
| RegistryError::InvalidVersionSequence { .. }
| RegistryError::ImmutableOperationFieldChanged { .. }
+36 -3
View File
@@ -1,10 +1,11 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use axum::{Extension, Json, extract::State, http::StatusCode, response::IntoResponse};
use axum_extra::extract::cookie::CookieJar;
use serde_json::json;
use crate::{
auth::{cleared_session_cookie, extract_session_token, session_cookie},
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
error::ApiError,
service::LoginPayload,
service::{ChangePasswordPayload, LoginPayload, UpdateProfilePayload},
state::AppState,
};
@@ -50,3 +51,35 @@ pub async fn get_session(
Ok(Json(serde_json::json!(session)))
}
pub async fn get_profile(
Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(
json!({ "user": session.user, "memberships": session.memberships }),
))
}
pub async fn update_profile(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<UpdateProfilePayload>,
) -> Result<Json<serde_json::Value>, ApiError> {
let updated = state
.service
.update_profile(&session.user.id, payload)
.await?;
Ok(Json(json!(updated)))
}
pub async fn change_password(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<ChangePasswordPayload>,
) -> Result<StatusCode, ApiError> {
state
.service
.change_password(&session.user.id, payload)
.await?;
Ok(StatusCode::NO_CONTENT)
}
+72
View File
@@ -62,6 +62,18 @@ pub struct SessionResponse {
pub memberships: Vec<WorkspaceMembershipRecord>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateProfilePayload {
pub display_name: String,
pub email: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ChangePasswordPayload {
pub current_password: String,
pub new_password: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OperationPayload {
pub name: String,
@@ -627,6 +639,66 @@ impl AdminService {
}))
}
pub async fn update_profile(
&self,
user_id: &crank_core::UserId,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
let display_name = payload.display_name.trim();
let email = payload.email.trim().to_ascii_lowercase();
if display_name.is_empty() {
return Err(ApiError::validation("display name is required"));
}
if email.is_empty() || !email.contains('@') {
return Err(ApiError::validation("a valid email address is required"));
}
let user = self
.registry
.update_user_profile(user_id, &email, display_name)
.await?;
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
Ok(SessionResponse { user, memberships })
}
pub async fn change_password(
&self,
user_id: &crank_core::UserId,
payload: ChangePasswordPayload,
) -> Result<(), ApiError> {
if payload.new_password.len() < 12 {
return Err(ApiError::validation(
"new password must be at least 12 characters long",
));
}
let user = self
.registry
.get_auth_user_by_id(user_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("user {} was not found", user_id.as_str()))
})?;
if !verify_password(
&payload.current_password,
&self.auth_settings.password_pepper,
&user.password_hash,
) {
return Err(ApiError::unauthorized("current password is invalid"));
}
let password_hash =
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
self.registry
.update_user_password(user_id, &password_hash)
.await?;
Ok(())
}
pub async fn get_workspace(
&self,
workspace_id: &WorkspaceId,