Merge pull request #11 from bsodfather/feat/settings-live-flow
This commit is contained in:
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/wizard-live-flow`
|
### `feat/settings-live-flow`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- `apps/ui/html/wizard/index.html` и `apps/ui/js/wizard.js` работают без локальных draft override-костылей
|
- `apps/ui/html/settings.html` и `apps/ui/js/settings.js` используют live backend для `profile` и `password`
|
||||||
- `test run`, `publish`, `import/export`, `sample upload`, `draft generation` и `gRPC descriptor` flow подключены к live backend
|
- `profile` перестает читать данные из `localStorage` как source of truth
|
||||||
- wizard для `REST`, `GraphQL` и `gRPC` использует один реальный end-to-end контракт
|
- security-block больше не притворяется готовой интеграцией там, где backend-контракта еще нет
|
||||||
- `test-ui` остается нетронутым как fallback
|
- `test-ui` остается нетронутым как fallback
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/settings-live-flow`
|
|
||||||
- `feat/workspace-access-polish`
|
- `feat/workspace-access-polish`
|
||||||
- `feat/alpine-polish`
|
- `feat/alpine-polish`
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
|
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
|
||||||
save_agent_bindings, update_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},
|
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
||||||
operations::{
|
operations::{
|
||||||
@@ -137,14 +137,23 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
|
|
||||||
let admin_router = workspace_root_router.merge(workspace_scoped_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()
|
Router::new()
|
||||||
.route("/health", get(crate::routes::health))
|
.route("/health", get(crate::routes::health))
|
||||||
.nest(
|
.nest(
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/logout", post(logout))
|
.merge(protected_auth_router),
|
||||||
.route("/session", get(get_session)),
|
|
||||||
)
|
)
|
||||||
.nest("/api/admin", admin_router)
|
.nest("/api/admin", admin_router)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
@@ -756,6 +765,77 @@ mod tests {
|
|||||||
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn exposes_logs_and_usage_from_real_test_runs() {
|
async fn exposes_logs_and_usage_from_real_test_runs() {
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ impl From<RegistryError> for ApiError {
|
|||||||
RegistryError::WorkspaceNotFound { workspace_id } => {
|
RegistryError::WorkspaceNotFound { workspace_id } => {
|
||||||
Self::not_found(format!("workspace {workspace_id} was not found"))
|
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 } => {
|
RegistryError::AgentNotFound { agent_id } => {
|
||||||
Self::not_found(format!("agent {agent_id} was not found"))
|
Self::not_found(format!("agent {agent_id} was not found"))
|
||||||
}
|
}
|
||||||
@@ -162,6 +165,9 @@ impl From<RegistryError> for ApiError {
|
|||||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
|
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
|
||||||
Self::conflict(format!("workspace with slug {slug} already exists"))
|
Self::conflict(format!("workspace with slug {slug} already exists"))
|
||||||
}
|
}
|
||||||
|
RegistryError::UserEmailAlreadyExists { email } => {
|
||||||
|
Self::conflict(format!("user with email {email} already exists"))
|
||||||
|
}
|
||||||
RegistryError::InvalidInitialVersion { .. }
|
RegistryError::InvalidInitialVersion { .. }
|
||||||
| RegistryError::InvalidVersionSequence { .. }
|
| RegistryError::InvalidVersionSequence { .. }
|
||||||
| RegistryError::ImmutableOperationFieldChanged { .. }
|
| RegistryError::ImmutableOperationFieldChanged { .. }
|
||||||
|
|||||||
@@ -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 axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{cleared_session_cookie, extract_session_token, session_cookie},
|
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
service::LoginPayload,
|
service::{ChangePasswordPayload, LoginPayload, UpdateProfilePayload},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,3 +51,35 @@ pub async fn get_session(
|
|||||||
|
|
||||||
Ok(Json(serde_json::json!(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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ pub struct SessionResponse {
|
|||||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct OperationPayload {
|
pub struct OperationPayload {
|
||||||
pub name: String,
|
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(
|
pub async fn get_workspace(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
|
|||||||
+20
-51
@@ -211,13 +211,13 @@
|
|||||||
<div class="section-card-body">
|
<div class="section-card-body">
|
||||||
<div class="avatar-upload">
|
<div class="avatar-upload">
|
||||||
<div class="avatar-large" id="profile-avatar">AT</div>
|
<div class="avatar-large" id="profile-avatar">AT</div>
|
||||||
<div class="avatar-upload-actions">
|
<div class="avatar-upload-actions">
|
||||||
<div class="avatar-name" id="profile-display-name">Operator</div>
|
<div class="avatar-name" id="profile-display-name">Operator</div>
|
||||||
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
|
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
|
||||||
<button class="btn-secondary" type="button" style="padding:5px 12px;font-size:12.5px;">Change photo</button>
|
<div class="field-hint">Profile photo upload is not available yet.</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint" style="margin-bottom:16px;">Avatar will appear in the console and team views.</div>
|
</div>
|
||||||
|
<div class="field-hint" style="margin-bottom:16px;">Your name and email are stored on the backend and used across the console.</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
@@ -233,11 +233,12 @@
|
|||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label">Email address</label>
|
<label class="field-label">Email address</label>
|
||||||
<input class="field-input" id="field-email" type="email" value="operator@acme.com" autocomplete="email">
|
<input class="field-input" id="field-email" type="email" value="operator@acme.com" autocomplete="email">
|
||||||
<div class="field-hint">Changing email requires re-verification. You'll receive a confirmation link.</div>
|
<div class="field-hint">Changing email updates the login identity for the current account.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-hint" id="settings-profile-status" style="margin-bottom:12px;"></div>
|
||||||
<div style="display:flex;justify-content:flex-end;">
|
<div style="display:flex;justify-content:flex-end;">
|
||||||
<button class="btn-primary" type="button">Save profile</button>
|
<button class="btn-primary" id="settings-profile-save-btn" type="button">Save profile</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,75 +443,42 @@
|
|||||||
<div class="section-card-body" style="padding-bottom:8px;">
|
<div class="section-card-body" style="padding-bottom:8px;">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label">Current password</label>
|
<label class="field-label">Current password</label>
|
||||||
<input class="field-input" type="password" placeholder="••••••••" autocomplete="current-password">
|
<input class="field-input" id="security-current-password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label">New password</label>
|
<label class="field-label">New password</label>
|
||||||
<input class="field-input" type="password" placeholder="min. 12 characters" autocomplete="new-password">
|
<input class="field-input" id="security-new-password" type="password" placeholder="min. 12 characters" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label">Confirm new password</label>
|
<label class="field-label">Confirm new password</label>
|
||||||
<input class="field-input" type="password" placeholder="••••••••" autocomplete="new-password">
|
<input class="field-input" id="security-confirm-password" type="password" placeholder="••••••••" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field-hint" id="settings-password-status" style="margin-bottom:12px;"></div>
|
||||||
<div style="display:flex;justify-content:flex-end;padding-bottom:8px;">
|
<div style="display:flex;justify-content:flex-end;padding-bottom:8px;">
|
||||||
<button class="btn-primary" type="button">Change password</button>
|
<button class="btn-primary" id="settings-password-save-btn" type="button">Change password</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 4px 20px 16px;">
|
<div style="padding: 4px 20px 16px;">
|
||||||
<div class="settings-section-divider" style="margin-top:0;"></div>
|
<div class="settings-section-divider" style="margin-top:0;"></div>
|
||||||
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;">Two-factor authentication</div>
|
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;">Advanced security</div>
|
||||||
|
<div class="field-hint" style="margin-bottom:12px;">Two-factor authentication, passkeys and session management are not implemented yet. The current live security flow is password-based login with HttpOnly session cookies.</div>
|
||||||
<div class="toggle-setting-row">
|
|
||||||
<div class="toggle-setting-text">
|
|
||||||
<div class="toggle-setting-label">TOTP authenticator app</div>
|
|
||||||
<div class="toggle-setting-desc">Use Google Authenticator, 1Password, or any TOTP-compatible app.</div>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-active" style="margin-right:8px;">Enabled</span>
|
|
||||||
<button class="btn-secondary" type="button" style="padding:5px 12px;font-size:12.5px;">Configure</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toggle-setting-row">
|
|
||||||
<div class="toggle-setting-text">
|
|
||||||
<div class="toggle-setting-label">Hardware security key</div>
|
|
||||||
<div class="toggle-setting-desc">FIDO2 / WebAuthn passkey or YubiKey.</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-secondary" type="button" style="padding:5px 12px;font-size:12.5px;">Add key</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card" style="margin-bottom: 20px;">
|
<div class="section-card" style="margin-bottom: 20px;">
|
||||||
<div class="section-card-header">
|
<div class="section-card-header">
|
||||||
<div class="section-card-title">Active sessions</div>
|
<div class="section-card-title">Current session</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 8px 20px 16px;">
|
<div style="padding: 8px 20px 16px;">
|
||||||
<div class="member-row">
|
<div class="member-row">
|
||||||
<div class="member-info">
|
<div class="member-info">
|
||||||
<div class="member-name">Chrome · macOS — <span style="color:var(--green);font-size:12px;">current</span></div>
|
<div class="member-name">Browser session — <span style="color:var(--green);font-size:12px;">current</span></div>
|
||||||
<div class="member-email">Last active: now · 93.184.216.34 (US)</div>
|
<div class="member-email">Session expires according to server-side auth settings. Use Log out to revoke the current browser session.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-row">
|
|
||||||
<div class="member-info">
|
|
||||||
<div class="member-name">Safari · iPhone</div>
|
|
||||||
<div class="member-email">Last active: 2 days ago · 93.184.216.34 (US)</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-icon danger" title="Revoke session">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="member-row">
|
|
||||||
<div class="member-info">
|
|
||||||
<div class="member-name">Firefox · Linux</div>
|
|
||||||
<div class="member-email">Last active: 8 days ago · 172.16.0.4 (Internal)</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-icon danger" title="Revoke session">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -557,6 +525,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
|
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field-hint" style="margin-top:12px;">Notification preferences are local UI placeholders for now and are not synced to the backend yet.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,6 +145,23 @@
|
|||||||
getSession: function() {
|
getSession: function() {
|
||||||
return request(AUTH_BASE + '/session');
|
return request(AUTH_BASE + '/session');
|
||||||
},
|
},
|
||||||
|
getProfile: function() {
|
||||||
|
return request(AUTH_BASE + '/profile');
|
||||||
|
},
|
||||||
|
updateProfile: function(payload) {
|
||||||
|
return request(AUTH_BASE + '/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: headers({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
changePassword: function(payload) {
|
||||||
|
return request(AUTH_BASE + '/password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
listWorkspaces: function() {
|
listWorkspaces: function() {
|
||||||
return get('/workspaces');
|
return get('/workspaces');
|
||||||
},
|
},
|
||||||
|
|||||||
+9
-5
@@ -56,6 +56,12 @@
|
|||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceSession(session) {
|
||||||
|
sessionCache = session;
|
||||||
|
persistUserMirror(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSession(force) {
|
async function fetchSession(force) {
|
||||||
if (!force && sessionCache) {
|
if (!force && sessionCache) {
|
||||||
return sessionCache;
|
return sessionCache;
|
||||||
@@ -66,9 +72,7 @@
|
|||||||
|
|
||||||
sessionPromise = window.CrankApi.getSession()
|
sessionPromise = window.CrankApi.getSession()
|
||||||
.then(function(session) {
|
.then(function(session) {
|
||||||
sessionCache = session;
|
return replaceSession(session);
|
||||||
persistUserMirror(session);
|
|
||||||
return session;
|
|
||||||
})
|
})
|
||||||
.catch(function(error) {
|
.catch(function(error) {
|
||||||
clearUserMirror();
|
clearUserMirror();
|
||||||
@@ -109,8 +113,7 @@
|
|||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
});
|
});
|
||||||
sessionCache = session;
|
replaceSession(session);
|
||||||
persistUserMirror(session);
|
|
||||||
window.location.href = homeUrl();
|
window.location.href = homeUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@
|
|||||||
|
|
||||||
window.CrankAuth = {
|
window.CrankAuth = {
|
||||||
fetchSession: fetchSession,
|
fetchSession: fetchSession,
|
||||||
|
replaceSession: replaceSession,
|
||||||
guardProtectedPage: guardProtectedPage,
|
guardProtectedPage: guardProtectedPage,
|
||||||
guardLoginPage: guardLoginPage,
|
guardLoginPage: guardLoginPage,
|
||||||
login: login,
|
login: login,
|
||||||
|
|||||||
+127
-17
@@ -1,24 +1,132 @@
|
|||||||
function populateProfile() {
|
var settingsSession = null;
|
||||||
try {
|
|
||||||
var user = JSON.parse(localStorage.getItem('crank_user'));
|
function initials(displayName, email) {
|
||||||
if (!user) {
|
var source = displayName || email || 'Crank';
|
||||||
|
return source
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(function(part) { return part.charAt(0).toUpperCase(); })
|
||||||
|
.join('') || 'CR';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(id, text, isError) {
|
||||||
|
var node = document.getElementById(id);
|
||||||
|
if (!node) return;
|
||||||
|
node.textContent = text || '';
|
||||||
|
node.style.color = text
|
||||||
|
? (isError ? 'var(--red)' : 'var(--green)')
|
||||||
|
: 'var(--text-muted)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateProfile(session) {
|
||||||
|
if (!session || !session.user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = session.user;
|
||||||
|
document.getElementById('profile-avatar').textContent = initials(user.display_name, user.email);
|
||||||
|
document.getElementById('profile-display-name').textContent = user.display_name || 'Operator';
|
||||||
|
document.getElementById('profile-email').textContent = user.email || '';
|
||||||
|
|
||||||
|
var firstName = document.getElementById('field-firstname');
|
||||||
|
var email = document.getElementById('field-email');
|
||||||
|
if (firstName) {
|
||||||
|
firstName.value = user.display_name || '';
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
email.value = user.email || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
if (!window.CrankAuth || !window.CrankApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsSession = await window.CrankAuth.fetchSession(true);
|
||||||
|
populateProfile(settingsSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindProfileSave() {
|
||||||
|
var button = document.getElementById('settings-profile-save-btn');
|
||||||
|
if (!button || button.dataset.bound === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.dataset.bound = 'true';
|
||||||
|
|
||||||
|
button.addEventListener('click', async function() {
|
||||||
|
var original = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Saving…';
|
||||||
|
setStatus('settings-profile-status', '', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var session = await window.CrankApi.updateProfile({
|
||||||
|
display_name: document.getElementById('field-firstname').value.trim(),
|
||||||
|
email: document.getElementById('field-email').value.trim(),
|
||||||
|
});
|
||||||
|
settingsSession = session;
|
||||||
|
if (window.CrankAuth && typeof window.CrankAuth.replaceSession === 'function') {
|
||||||
|
window.CrankAuth.replaceSession(session);
|
||||||
|
}
|
||||||
|
populateProfile(session);
|
||||||
|
setStatus('settings-profile-status', 'Profile saved.', false);
|
||||||
|
button.textContent = 'Saved ✓';
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('settings-profile-status', error.message || 'Failed to save profile', true);
|
||||||
|
button.textContent = original;
|
||||||
|
} finally {
|
||||||
|
setTimeout(function() {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = original;
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPasswordSave() {
|
||||||
|
var button = document.getElementById('settings-password-save-btn');
|
||||||
|
if (!button || button.dataset.bound === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.dataset.bound = 'true';
|
||||||
|
|
||||||
|
button.addEventListener('click', async function() {
|
||||||
|
var currentPassword = document.getElementById('security-current-password').value;
|
||||||
|
var newPassword = document.getElementById('security-new-password').value;
|
||||||
|
var confirmPassword = document.getElementById('security-confirm-password').value;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setStatus('settings-password-status', 'New password and confirmation do not match.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('profile-avatar').textContent = user.initials || 'AT';
|
var original = button.textContent;
|
||||||
document.getElementById('profile-display-name').textContent = user.name || 'Operator';
|
button.disabled = true;
|
||||||
document.getElementById('profile-email').textContent = user.email || '';
|
button.textContent = 'Saving…';
|
||||||
|
setStatus('settings-password-status', '', false);
|
||||||
|
|
||||||
var firstName = document.getElementById('field-firstname');
|
try {
|
||||||
var email = document.getElementById('field-email');
|
await window.CrankApi.changePassword({
|
||||||
if (firstName && user.name) {
|
current_password: currentPassword,
|
||||||
firstName.value = user.name;
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
document.getElementById('security-current-password').value = '';
|
||||||
|
document.getElementById('security-new-password').value = '';
|
||||||
|
document.getElementById('security-confirm-password').value = '';
|
||||||
|
setStatus('settings-password-status', 'Password updated.', false);
|
||||||
|
button.textContent = 'Saved ✓';
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('settings-password-status', error.message || 'Failed to change password', true);
|
||||||
|
button.textContent = original;
|
||||||
|
} finally {
|
||||||
|
setTimeout(function() {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = original;
|
||||||
|
}, 1200);
|
||||||
}
|
}
|
||||||
if (email && user.email) {
|
});
|
||||||
email.value = user.email;
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectLanguageSwitcher() {
|
function injectLanguageSwitcher() {
|
||||||
@@ -161,8 +269,10 @@ async function loadWorkspaceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
populateProfile();
|
|
||||||
injectLanguageSwitcher();
|
injectLanguageSwitcher();
|
||||||
bindSectionNavigation();
|
bindSectionNavigation();
|
||||||
|
loadProfile();
|
||||||
|
bindProfileSave();
|
||||||
|
bindPasswordSave();
|
||||||
loadWorkspaceSettings();
|
loadWorkspaceSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ pub enum RegistryError {
|
|||||||
WorkspaceNotFound { workspace_id: String },
|
WorkspaceNotFound { workspace_id: String },
|
||||||
#[error("workspace with slug {slug} already exists")]
|
#[error("workspace with slug {slug} already exists")]
|
||||||
WorkspaceSlugAlreadyExists { slug: String },
|
WorkspaceSlugAlreadyExists { slug: String },
|
||||||
|
#[error("user {user_id} was not found")]
|
||||||
|
UserNotFound { user_id: String },
|
||||||
|
#[error("user with email {email} already exists")]
|
||||||
|
UserEmailAlreadyExists { email: String },
|
||||||
#[error("invitation {invitation_id} was not found")]
|
#[error("invitation {invitation_id} was not found")]
|
||||||
InvitationNotFound { invitation_id: String },
|
InvitationNotFound { invitation_id: String },
|
||||||
#[error("platform api key {key_id} was not found")]
|
#[error("platform api key {key_id} was not found")]
|
||||||
|
|||||||
@@ -178,6 +178,81 @@ impl PostgresRegistry {
|
|||||||
row.as_ref().map(map_auth_user_record).transpose()
|
row.as_ref().map(map_auth_user_record).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_auth_user_by_id(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Option<AuthUserRecord>, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"select
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
password_hash,
|
||||||
|
status,
|
||||||
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at
|
||||||
|
from users
|
||||||
|
where id = $1
|
||||||
|
limit 1",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.as_ref().map(map_auth_user_record).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_user_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
email: &str,
|
||||||
|
display_name: &str,
|
||||||
|
) -> Result<User, RegistryError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"update users
|
||||||
|
set email = $2,
|
||||||
|
display_name = $3
|
||||||
|
where id = $1
|
||||||
|
returning
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
status,
|
||||||
|
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_str())
|
||||||
|
.bind(email)
|
||||||
|
.bind(display_name)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_user_update_error(error, user_id, email))?;
|
||||||
|
|
||||||
|
map_user(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_user_password(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<(), RegistryError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"update users
|
||||||
|
set password_hash = $2
|
||||||
|
where id = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_str())
|
||||||
|
.bind(password_hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(RegistryError::UserNotFound {
|
||||||
|
user_id: user_id.as_str().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_user_session(
|
pub async fn create_user_session(
|
||||||
&self,
|
&self,
|
||||||
session_id: &UserSessionId,
|
session_id: &UserSessionId,
|
||||||
@@ -2686,17 +2761,37 @@ fn map_membership_record(row: &PgRow) -> Result<MembershipRecord, RegistryError>
|
|||||||
|
|
||||||
fn map_auth_user_record(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
fn map_auth_user_record(row: &PgRow) -> Result<AuthUserRecord, RegistryError> {
|
||||||
Ok(AuthUserRecord {
|
Ok(AuthUserRecord {
|
||||||
user: User {
|
user: map_user(row)?,
|
||||||
id: UserId::new(row.try_get::<String, _>("id")?),
|
|
||||||
email: row.try_get("email")?,
|
|
||||||
display_name: row.try_get("display_name")?,
|
|
||||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
|
||||||
created_at: row.try_get("created_at")?,
|
|
||||||
},
|
|
||||||
password_hash: row.try_get("password_hash")?,
|
password_hash: row.try_get("password_hash")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_user(row: &PgRow) -> Result<User, RegistryError> {
|
||||||
|
Ok(User {
|
||||||
|
id: UserId::new(row.try_get::<String, _>("id")?),
|
||||||
|
email: row.try_get("email")?,
|
||||||
|
display_name: row.try_get("display_name")?,
|
||||||
|
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||||
|
created_at: row.try_get("created_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_user_update_error(error: sqlx::Error, user_id: &UserId, email: &str) -> RegistryError {
|
||||||
|
match error {
|
||||||
|
sqlx::Error::RowNotFound => RegistryError::UserNotFound {
|
||||||
|
user_id: user_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
sqlx::Error::Database(database_error)
|
||||||
|
if database_error.code().as_deref() == Some("23505") =>
|
||||||
|
{
|
||||||
|
RegistryError::UserEmailAlreadyExists {
|
||||||
|
email: email.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => RegistryError::Storage(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_invitation_record(row: &PgRow) -> Result<InvitationRecord, RegistryError> {
|
fn map_invitation_record(row: &PgRow) -> Result<InvitationRecord, RegistryError> {
|
||||||
Ok(InvitationRecord {
|
Ok(InvitationRecord {
|
||||||
invitation: InvitationToken {
|
invitation: InvitationToken {
|
||||||
|
|||||||
@@ -66,12 +66,18 @@
|
|||||||
- `POST /api/auth/login`
|
- `POST /api/auth/login`
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
- `GET /api/auth/session`
|
- `GET /api/auth/session`
|
||||||
|
- `GET /api/auth/profile`
|
||||||
|
- `PATCH /api/auth/profile`
|
||||||
|
- `POST /api/auth/password`
|
||||||
|
|
||||||
Контракт:
|
Контракт:
|
||||||
|
|
||||||
- `POST /login` принимает `email` и `password`;
|
- `POST /login` принимает `email` и `password`;
|
||||||
- при успешном логине backend выставляет `HttpOnly` session cookie;
|
- при успешном логине backend выставляет `HttpOnly` session cookie;
|
||||||
- `GET /session` возвращает текущего пользователя и memberships;
|
- `GET /session` возвращает текущего пользователя и memberships;
|
||||||
|
- `GET /profile` возвращает текущего пользователя и memberships для settings UI;
|
||||||
|
- `PATCH /profile` обновляет `display_name` и `email` текущего пользователя;
|
||||||
|
- `POST /password` меняет пароль текущего пользователя после проверки `current_password`;
|
||||||
- `POST /logout` инвалидирует текущую session.
|
- `POST /logout` инвалидирует текущую session.
|
||||||
|
|
||||||
### 5.3. Operations
|
### 5.3. Operations
|
||||||
|
|||||||
@@ -340,29 +340,34 @@ UI-файлы:
|
|||||||
|
|
||||||
Что уже можно подключить:
|
Что уже можно подключить:
|
||||||
|
|
||||||
|
- `GET /api/auth/session`
|
||||||
|
- `GET /api/auth/profile`
|
||||||
|
- `PATCH /api/auth/profile`
|
||||||
|
- `POST /api/auth/password`
|
||||||
- workspace block через `GET /api/admin/workspaces/{workspace_id}`
|
- workspace block через `GET /api/admin/workspaces/{workspace_id}`
|
||||||
- `PATCH /api/admin/workspaces/{workspace_id}`
|
- `PATCH /api/admin/workspaces/{workspace_id}`
|
||||||
|
|
||||||
Что еще не хватает:
|
Что еще не хватает:
|
||||||
|
|
||||||
- current user endpoint;
|
|
||||||
- user profile update endpoint;
|
|
||||||
- preferences endpoint;
|
- preferences endpoint;
|
||||||
- security/auth settings model
|
- полноценный advanced security model (`2FA`, passkeys, session inventory);
|
||||||
|
- session-aware current workspace model вместо client-side `localStorage`.
|
||||||
|
|
||||||
Отдельный конфликт:
|
Отдельный конфликт:
|
||||||
|
|
||||||
- UI считает, что у нас уже есть app-level user profile;
|
- UI исторически притворялся, что `2FA`, passkeys и active sessions уже реализованы;
|
||||||
- backend пока не имеет session/user profile API;
|
- backend пока дает только live `profile` и `password change`, без advanced security lifecycle;
|
||||||
- страницу стоит разбить:
|
- поэтому страница должна честно разделять:
|
||||||
- workspace settings подключать раньше;
|
- live `profile`;
|
||||||
- profile/security оставить временно mock или read-only.
|
- live `password change`;
|
||||||
|
- read-only/security roadmap блоки без fake data.
|
||||||
|
|
||||||
Простой итог:
|
Простой итог:
|
||||||
|
|
||||||
- workspace block уже можно держать live;
|
- workspace block уже live;
|
||||||
- profile/security остаются временно mock/read-only;
|
- profile и password change уже live;
|
||||||
- страницу целиком считать интегрированной пока нельзя.
|
- notifications остаются локальным placeholder;
|
||||||
|
- страницу теперь можно считать частично интегрированной без ложных mock-сценариев.
|
||||||
|
|
||||||
### 4.9. Login
|
### 4.9. Login
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user