From 44363c0b1c3c9a263a31619196e1be8d4d8f97b6 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 31 Mar 2026 02:21:31 +0300 Subject: [PATCH] feat: connect settings live flow to auth api --- TASKS.md | 9 +- apps/admin-api/src/app.rs | 86 ++++++++++++++- apps/admin-api/src/error.rs | 6 ++ apps/admin-api/src/routes/auth.rs | 39 ++++++- apps/admin-api/src/service.rs | 72 +++++++++++++ apps/ui/html/settings.html | 71 ++++--------- apps/ui/js/api.js | 17 +++ apps/ui/js/auth.js | 14 ++- apps/ui/js/settings.js | 144 +++++++++++++++++++++++--- crates/crank-registry/src/error.rs | 4 + crates/crank-registry/src/postgres.rs | 109 +++++++++++++++++-- docs/admin-api.md | 6 ++ docs/alpine-ui-integration-plan.md | 27 +++-- 13 files changed, 502 insertions(+), 102 deletions(-) diff --git a/TASKS.md b/TASKS.md index 9469127..63d1bc2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,18 @@ ## Current -### `feat/wizard-live-flow` +### `feat/settings-live-flow` Status: completed DoD: -- `apps/ui/html/wizard/index.html` и `apps/ui/js/wizard.js` работают без локальных draft override-костылей -- `test run`, `publish`, `import/export`, `sample upload`, `draft generation` и `gRPC descriptor` flow подключены к live backend -- wizard для `REST`, `GraphQL` и `gRPC` использует один реальный end-to-end контракт +- `apps/ui/html/settings.html` и `apps/ui/js/settings.js` используют live backend для `profile` и `password` +- `profile` перестает читать данные из `localStorage` как source of truth +- security-block больше не притворяется готовой интеграцией там, где backend-контракта еще нет - `test-ui` остается нетронутым как fallback ## Next -- `feat/settings-live-flow` - `feat/workspace-access-polish` - `feat/alpine-polish` diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 26c99af..25a38b8 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -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() { diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 87db551..17e680b 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -121,6 +121,9 @@ impl From 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 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 { .. } diff --git a/apps/admin-api/src/routes/auth.rs b/apps/admin-api/src/routes/auth.rs index 141ea52..421d669 100644 --- a/apps/admin-api/src/routes/auth.rs +++ b/apps/admin-api/src/routes/auth.rs @@ -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, +) -> Result, ApiError> { + Ok(Json( + json!({ "user": session.user, "memberships": session.memberships }), + )) +} + +pub async fn update_profile( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let updated = state + .service + .update_profile(&session.user.id, payload) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn change_password( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result { + state + .service + .change_password(&session.user.id, payload) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 107afd4..c6daf10 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -62,6 +62,18 @@ pub struct SessionResponse { pub memberships: Vec, } +#[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 { + 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, diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index 6090f1f..0b83fd3 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -211,13 +211,13 @@
AT
-
-
Operator
-
operator@acme-workspace
- -
+
+
Operator
+
operator@acme-workspace
+
Profile photo upload is not available yet.
-
Avatar will appear in the console and team views.
+
+
Your name and email are stored on the backend and used across the console.
@@ -233,11 +233,12 @@
-
Changing email requires re-verification. You'll receive a confirmation link.
+
Changing email updates the login identity for the current account.
+
- +
@@ -442,75 +443,42 @@
- +
- +
- +
+
- +
-
Two-factor authentication
- -
-
-
TOTP authenticator app
-
Use Google Authenticator, 1Password, or any TOTP-compatible app.
-
- Enabled - -
- -
-
-
Hardware security key
-
FIDO2 / WebAuthn passkey or YubiKey.
-
- -
+
Advanced security
+
Two-factor authentication, passkeys and session management are not implemented yet. The current live security flow is password-based login with HttpOnly session cookies.
-
Active sessions
+
Current session
-
Chrome · macOS — current
-
Last active: now · 93.184.216.34 (US)
+
Browser session — current
+
Session expires according to server-side auth settings. Use Log out to revoke the current browser session.
-
-
-
Safari · iPhone
-
Last active: 2 days ago · 93.184.216.34 (US)
-
- -
-
-
-
Firefox · Linux
-
Last active: 8 days ago · 172.16.0.4 (Internal)
-
- -
@@ -557,6 +525,7 @@
+
Notification preferences are local UI placeholders for now and are not synced to the backend yet.
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 11c612d..4e7b7ea 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -145,6 +145,23 @@ getSession: function() { 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() { return get('/workspaces'); }, diff --git a/apps/ui/js/auth.js b/apps/ui/js/auth.js index a1a1ffc..bde39fd 100644 --- a/apps/ui/js/auth.js +++ b/apps/ui/js/auth.js @@ -56,6 +56,12 @@ } catch (_error) {} } + function replaceSession(session) { + sessionCache = session; + persistUserMirror(session); + return session; + } + async function fetchSession(force) { if (!force && sessionCache) { return sessionCache; @@ -66,9 +72,7 @@ sessionPromise = window.CrankApi.getSession() .then(function(session) { - sessionCache = session; - persistUserMirror(session); - return session; + return replaceSession(session); }) .catch(function(error) { clearUserMirror(); @@ -109,8 +113,7 @@ email: email, password: password, }); - sessionCache = session; - persistUserMirror(session); + replaceSession(session); window.location.href = homeUrl(); } @@ -134,6 +137,7 @@ window.CrankAuth = { fetchSession: fetchSession, + replaceSession: replaceSession, guardProtectedPage: guardProtectedPage, guardLoginPage: guardLoginPage, login: login, diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index d97418b..c4262b4 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -1,24 +1,132 @@ -function populateProfile() { - try { - var user = JSON.parse(localStorage.getItem('crank_user')); - if (!user) { +var settingsSession = null; + +function initials(displayName, email) { + 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; } - document.getElementById('profile-avatar').textContent = user.initials || 'AT'; - document.getElementById('profile-display-name').textContent = user.name || 'Operator'; - document.getElementById('profile-email').textContent = user.email || ''; + var original = button.textContent; + button.disabled = true; + button.textContent = 'Saving…'; + setStatus('settings-password-status', '', false); - var firstName = document.getElementById('field-firstname'); - var email = document.getElementById('field-email'); - if (firstName && user.name) { - firstName.value = user.name; + try { + await window.CrankApi.changePassword({ + current_password: currentPassword, + 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() { @@ -161,8 +269,10 @@ async function loadWorkspaceSettings() { } document.addEventListener('DOMContentLoaded', function () { - populateProfile(); injectLanguageSwitcher(); bindSectionNavigation(); + loadProfile(); + bindProfileSave(); + bindPasswordSave(); loadWorkspaceSettings(); }); diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index f407c4c..df0d8c7 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -10,6 +10,10 @@ pub enum RegistryError { WorkspaceNotFound { workspace_id: String }, #[error("workspace with slug {slug} already exists")] 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")] InvitationNotFound { invitation_id: String }, #[error("platform api key {key_id} was not found")] diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index c981251..25805e5 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -178,6 +178,81 @@ impl PostgresRegistry { row.as_ref().map(map_auth_user_record).transpose() } + pub async fn get_auth_user_by_id( + &self, + user_id: &UserId, + ) -> Result, 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 { + 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( &self, session_id: &UserSessionId, @@ -2686,17 +2761,37 @@ fn map_membership_record(row: &PgRow) -> Result fn map_auth_user_record(row: &PgRow) -> Result { Ok(AuthUserRecord { - user: User { - id: UserId::new(row.try_get::("id")?), - email: row.try_get("email")?, - display_name: row.try_get("display_name")?, - status: deserialize_enum_text(&row.try_get::("status")?, "status")?, - created_at: row.try_get("created_at")?, - }, + user: map_user(row)?, password_hash: row.try_get("password_hash")?, }) } +fn map_user(row: &PgRow) -> Result { + Ok(User { + id: UserId::new(row.try_get::("id")?), + email: row.try_get("email")?, + display_name: row.try_get("display_name")?, + status: deserialize_enum_text(&row.try_get::("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 { Ok(InvitationRecord { invitation: InvitationToken { diff --git a/docs/admin-api.md b/docs/admin-api.md index fcdd41c..113a638 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -66,12 +66,18 @@ - `POST /api/auth/login` - `POST /api/auth/logout` - `GET /api/auth/session` +- `GET /api/auth/profile` +- `PATCH /api/auth/profile` +- `POST /api/auth/password` Контракт: - `POST /login` принимает `email` и `password`; - при успешном логине backend выставляет `HttpOnly` session cookie; - `GET /session` возвращает текущего пользователя и memberships; +- `GET /profile` возвращает текущего пользователя и memberships для settings UI; +- `PATCH /profile` обновляет `display_name` и `email` текущего пользователя; +- `POST /password` меняет пароль текущего пользователя после проверки `current_password`; - `POST /logout` инвалидирует текущую session. ### 5.3. Operations diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 5d6ac3d..0105d2a 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -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}` - `PATCH /api/admin/workspaces/{workspace_id}` Что еще не хватает: -- current user endpoint; -- user profile update 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; -- backend пока не имеет session/user profile API; -- страницу стоит разбить: - - workspace settings подключать раньше; - - profile/security оставить временно mock или read-only. +- UI исторически притворялся, что `2FA`, passkeys и active sessions уже реализованы; +- backend пока дает только live `profile` и `password change`, без advanced security lifecycle; +- поэтому страница должна честно разделять: + - live `profile`; + - live `password change`; + - read-only/security roadmap блоки без fake data. Простой итог: -- workspace block уже можно держать live; -- profile/security остаются временно mock/read-only; -- страницу целиком считать интегрированной пока нельзя. +- workspace block уже live; +- profile и password change уже live; +- notifications остаются локальным placeholder; +- страницу теперь можно считать частично интегрированной без ложных mock-сценариев. ### 4.9. Login