feat: persist current workspace in user sessions

This commit is contained in:
a.tolmachev
2026-03-31 15:17:07 +03:00
parent 84f4437ce0
commit 86b61523bd
15 changed files with 331 additions and 57 deletions
+6 -7
View File
@@ -2,22 +2,21 @@
## Current ## Current
### `feat/demo-assets` ### `feat/current-workspace-session-model`
Status: in_progress Status: in_progress
DoD: DoD:
- project has an idempotent PostgreSQL demo seed for live UI screens - current workspace is stored in authenticated session, not only in localStorage
- demo seed fills workspaces, memberships, invitations, operations, agents, keys and usage - workspace switcher updates session state through auth API
- seed is controlled by env and does not live in migrations - shell identity and page data follow session-selected workspace after reload
- docs explain how to enable and use demo data - frontend retains only cache/fallback state, not source-of-truth selection
## Next ## Next
- `feat/alpine-polish` - `feat/agent-lifecycle-polish`
## Backlog ## Backlog
- `feat/current-workspace-session-model`
- `feat/agent-lifecycle-polish` - `feat/agent-lifecycle-polish`
- `feat/platform-key-usage` - `feat/platform-key-usage`
+56 -1
View File
@@ -15,7 +15,10 @@ 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::{change_password, get_profile, get_session, login, logout, update_profile}, auth::{
change_password, get_profile, get_session, login, logout, update_current_workspace,
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::{
@@ -149,6 +152,7 @@ pub fn build_app(state: AppState) -> Router {
.route("/logout", post(logout)) .route("/logout", post(logout))
.route("/session", get(get_session)) .route("/session", get(get_session))
.route("/profile", get(get_profile).patch(update_profile)) .route("/profile", get(get_profile).patch(update_profile))
.route("/current-workspace", post(update_current_workspace))
.route("/password", post(change_password)) .route("/password", post(change_password))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
@@ -991,6 +995,57 @@ mod tests {
assert_eq!(relogin_status, reqwest::StatusCode::OK); assert_eq!(relogin_status, reqwest::StatusCode::OK);
} }
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn switches_current_workspace_in_session() {
let registry = test_registry().await;
let storage_root = test_storage_root("session_workspace");
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 created_workspace = assert_success_json(
client
.post(format!("{root_url}/api/admin/workspaces"))
.json(&json!({
"slug": "growth-lab",
"display_name": "Growth Lab",
"settings": {}
}))
.send()
.await
.unwrap(),
)
.await;
let workspace_id = created_workspace["workspace"]["id"].as_str().unwrap();
let switched = assert_success_json(
client
.post(format!("{root_url}/api/auth/current-workspace"))
.json(&json!({ "workspace_id": workspace_id }))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(switched["current_workspace_id"], workspace_id);
let session = assert_success_json(
client
.get(format!("{root_url}/api/auth/session"))
.send()
.await
.unwrap(),
)
.await;
assert_eq!(session["current_workspace_id"], workspace_id);
}
#[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() {
+2
View File
@@ -38,8 +38,10 @@ pub struct AuthSettings {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct AuthenticatedSession { pub struct AuthenticatedSession {
pub session_id: UserSessionId,
pub user: User, pub user: User,
pub memberships: Vec<WorkspaceMembershipRecord>, pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<WorkspaceId>,
} }
#[derive(Clone)] #[derive(Clone)]
+29 -5
View File
@@ -5,7 +5,9 @@ use serde_json::json;
use crate::{ use crate::{
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie}, auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
error::ApiError, error::ApiError,
service::{ChangePasswordPayload, LoginPayload, UpdateProfilePayload}, service::{
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
},
state::AppState, state::AppState,
}; };
@@ -55,9 +57,11 @@ pub async fn get_session(
pub async fn get_profile( pub async fn get_profile(
Extension(session): Extension<AuthenticatedSession>, Extension(session): Extension<AuthenticatedSession>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json( Ok(Json(json!({
json!({ "user": session.user, "memberships": session.memberships }), "user": session.user,
)) "memberships": session.memberships,
"current_workspace_id": session.current_workspace_id
})))
} }
pub async fn update_profile( pub async fn update_profile(
@@ -67,7 +71,27 @@ pub async fn update_profile(
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let updated = state let updated = state
.service .service
.update_profile(&session.user.id, payload) .update_profile(
&session.user.id,
session.current_workspace_id.as_ref(),
payload,
)
.await?;
Ok(Json(json!(updated)))
}
pub async fn update_current_workspace(
State(state): State<AppState>,
Extension(session): Extension<AuthenticatedSession>,
Json(payload): Json<UpdateCurrentWorkspacePayload>,
) -> Result<Json<serde_json::Value>, ApiError> {
let updated = state
.service
.set_current_workspace(
&session.session_id,
&session.user.id,
&payload.workspace_id.as_str().into(),
)
.await?; .await?;
Ok(Json(json!(updated))) Ok(Json(json!(updated)))
} }
+60 -5
View File
@@ -64,6 +64,7 @@ pub struct LoginPayload {
pub struct SessionResponse { pub struct SessionResponse {
pub user: crank_core::User, pub user: crank_core::User,
pub memberships: Vec<WorkspaceMembershipRecord>, pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<String>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -78,6 +79,11 @@ pub struct ChangePasswordPayload {
pub new_password: String, pub new_password: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateCurrentWorkspacePayload {
pub workspace_id: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OperationPayload { pub struct OperationPayload {
pub name: String, pub name: String,
@@ -546,8 +552,10 @@ impl AdminService {
.get_user_session(session_id, &secret_hash) .get_user_session(session_id, &secret_hash)
.await? .await?
.map(|record| AuthenticatedSession { .map(|record| AuthenticatedSession {
session_id: record.session_id,
user: record.user, user: record.user,
memberships: record.memberships, memberships: record.memberships,
current_workspace_id: record.current_workspace_id,
}); });
Ok(session) Ok(session)
@@ -582,24 +590,31 @@ impl AdminService {
&session_cookie.value, &session_cookie.value,
&self.auth_settings.session_secret, &self.auth_settings.session_secret,
); );
let memberships = self
.registry
.list_workspaces_for_user(&user.user.id)
.await?;
let current_workspace_id = memberships
.first()
.map(|membership| membership.workspace.id.as_str().to_owned());
self.registry self.registry
.create_user_session( .create_user_session(
&session_cookie.session_id, &session_cookie.session_id,
&user.user.id, &user.user.id,
memberships
.first()
.map(|membership| &membership.workspace.id),
&secret_hash, &secret_hash,
&session_cookie.expires_at, &session_cookie.expires_at,
) )
.await?; .await?;
let memberships = self
.registry
.list_workspaces_for_user(&user.user.id)
.await?;
Ok(( Ok((
session_cookie, session_cookie,
SessionResponse { SessionResponse {
user: user.user, user: user.user,
memberships, memberships,
current_workspace_id,
}, },
)) ))
} }
@@ -672,12 +687,16 @@ impl AdminService {
.map(|session| SessionResponse { .map(|session| SessionResponse {
user: session.user, user: session.user,
memberships: session.memberships, memberships: session.memberships,
current_workspace_id: session
.current_workspace_id
.map(|id| id.as_str().to_owned()),
})) }))
} }
pub async fn update_profile( pub async fn update_profile(
&self, &self,
user_id: &crank_core::UserId, user_id: &crank_core::UserId,
current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload, payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> { ) -> Result<SessionResponse, ApiError> {
let display_name = payload.display_name.trim(); let display_name = payload.display_name.trim();
@@ -696,7 +715,43 @@ impl AdminService {
.await?; .await?;
let memberships = self.registry.list_workspaces_for_user(user_id).await?; let memberships = self.registry.list_workspaces_for_user(user_id).await?;
Ok(SessionResponse { user, memberships }) Ok(SessionResponse {
user,
memberships,
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
})
}
pub async fn set_current_workspace(
&self,
session_id: &UserSessionId,
user_id: &crank_core::UserId,
workspace_id: &WorkspaceId,
) -> Result<SessionResponse, ApiError> {
if !self
.user_has_workspace_access(user_id, workspace_id)
.await?
{
return Err(ApiError::forbidden("workspace access denied"));
}
self.registry
.set_user_session_current_workspace(session_id, workspace_id)
.await?;
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())))?
.user;
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
Ok(SessionResponse {
user,
memberships,
current_workspace_id: Some(workspace_id.as_str().to_owned()),
})
} }
pub async fn change_password( pub async fn change_password(
+7
View File
@@ -162,6 +162,13 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
}, },
setCurrentWorkspace: function(workspaceId) {
return request(AUTH_BASE + '/current-workspace', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ workspace_id: workspaceId }),
});
},
listWorkspaces: function() { listWorkspaces: function() {
return get('/workspaces'); return get('/workspaces');
}, },
+24 -3
View File
@@ -24,9 +24,29 @@
} }
function primaryMembership(session) { function primaryMembership(session) {
return session && session.memberships && session.memberships.length if (!(session && session.memberships && session.memberships.length)) {
? session.memberships[0] return null;
: null; }
if (session.current_workspace_id) {
var currentMembership = session.memberships.find(function(item) {
return item.workspace && item.workspace.id === session.current_workspace_id;
});
if (currentMembership) {
return currentMembership;
}
}
if (window.getCurrentWorkspace) {
var currentWorkspace = window.getCurrentWorkspace();
if (currentWorkspace) {
var matched = session.memberships.find(function(item) {
return item.workspace && item.workspace.id === currentWorkspace.id;
});
if (matched) {
return matched;
}
}
}
return session.memberships[0];
} }
function initials(displayName, email) { function initials(displayName, email) {
@@ -138,6 +158,7 @@
window.CrankAuth = { window.CrankAuth = {
fetchSession: fetchSession, fetchSession: fetchSession,
replaceSession: replaceSession, replaceSession: replaceSession,
getCachedSession: function() { return sessionCache; },
guardProtectedPage: guardProtectedPage, guardProtectedPage: guardProtectedPage,
guardLoginPage: guardLoginPage, guardLoginPage: guardLoginPage,
login: login, login: login,
+1 -1
View File
@@ -249,7 +249,7 @@ async function loadWorkspaceSettings() {
var workspaces = await window.refreshWorkspaces(); var workspaces = await window.refreshWorkspaces();
var updated = workspaces.find(function (entry) { return entry.id === item.id; }); var updated = workspaces.find(function (entry) { return entry.id === item.id; });
if (updated && window.setCurrentWorkspace) { if (updated && window.setCurrentWorkspace) {
window.setCurrentWorkspace(updated); await window.setCurrentWorkspace(updated);
} }
} }
+1 -1
View File
@@ -324,7 +324,7 @@ async function submitForm() {
settings: payload.settings, settings: payload.settings,
}; };
window.setCurrentWorkspace(mapped); await window.setCurrentWorkspace(mapped);
workspaceFormState.workspaceId = workspace.id; workspaceFormState.workspaceId = workspace.id;
workspaceFormState.workspaceRecord = { workspace: workspace }; workspaceFormState.workspaceRecord = { workspace: workspace };
setFormDirty(false); setFormDirty(false);
+85 -20
View File
@@ -4,6 +4,7 @@ var WS_LIST = [
var workspaceLoadPromise = null; var workspaceLoadPromise = null;
var workspaceLoadFailed = false; var workspaceLoadFailed = false;
var lastWorkspaceId = null;
function workspaceColor(index) { function workspaceColor(index) {
return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5]; return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5];
@@ -13,11 +14,14 @@ function mapWorkspace(record, index) {
var workspace = record && record.workspace ? record.workspace : record; var workspace = record && record.workspace ? record.workspace : record;
var displayName = workspace.display_name || workspace.slug || workspace.id; var displayName = workspace.display_name || workspace.slug || workspace.id;
var settings = workspace.settings || {}; var settings = workspace.settings || {};
var role = record && record.role
? String(record.role).replace(/^\w/, function(char) { return char.toUpperCase(); })
: 'Owner';
return { return {
id: workspace.id, id: workspace.id,
slug: workspace.slug, slug: workspace.slug,
name: displayName, name: displayName,
role: 'Owner', role: role,
letter: displayName.charAt(0).toUpperCase(), letter: displayName.charAt(0).toUpperCase(),
color: settings.color || workspaceColor(index), color: settings.color || workspaceColor(index),
status: workspace.status, status: workspace.status,
@@ -25,29 +29,60 @@ function mapWorkspace(record, index) {
}; };
} }
function selectedWorkspaceId() { function cachedWorkspaceId() {
return localStorage.getItem('crank_workspace_id'); try {
return localStorage.getItem('crank_workspace_id');
} catch (_error) {
return null;
}
} }
function selectedWorkspaceSlug() { function cachedWorkspaceSlug() {
return localStorage.getItem('crank_workspace_slug'); try {
return localStorage.getItem('crank_workspace_slug');
} catch (_error) {
return null;
}
} }
function persistCurrentWorkspace(workspace) { function sessionWorkspaceId() {
if (!window.CrankAuth || typeof window.CrankAuth.getCachedSession !== 'function') {
return null;
}
var session = window.CrankAuth.getCachedSession();
return session && session.current_workspace_id ? session.current_workspace_id : null;
}
function cacheCurrentWorkspace(workspace) {
if (!workspace) return; if (!workspace) return;
localStorage.setItem('crank_workspace_id', workspace.id); try {
localStorage.setItem('crank_workspace_slug', workspace.slug); localStorage.setItem('crank_workspace_id', workspace.id);
localStorage.setItem('crank_workspace_slug', workspace.slug);
} catch (_error) {}
}
function resolveCurrentWorkspace() {
var workspaceId = sessionWorkspaceId() || cachedWorkspaceId();
var workspaceSlug = cachedWorkspaceSlug();
return WS_LIST.find(function(item) { return item.id === workspaceId; })
|| WS_LIST.find(function(item) { return item.slug === workspaceSlug; })
|| WS_LIST[0]
|| null;
}
function emitWorkspaceChange(workspace) {
if (!workspace) {
return;
}
lastWorkspaceId = workspace.id;
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
} }
function getCurrentWs() { function getCurrentWs() {
var workspaceId = selectedWorkspaceId(); var workspace = resolveCurrentWorkspace();
var workspaceSlug = selectedWorkspaceSlug();
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; })
|| WS_LIST.find(function(item) { return item.slug === workspaceSlug; })
|| WS_LIST[0];
if (workspace) { if (workspace) {
persistCurrentWorkspace(workspace); cacheCurrentWorkspace(workspace);
} }
return workspace; return workspace;
@@ -134,17 +169,39 @@ function toggleWsSwitcher(e) {
function switchWorkspace(workspaceId) { function switchWorkspace(workspaceId) {
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; }); var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; });
if (!workspace) return; if (!workspace) return;
persistCurrentWorkspace(workspace);
var dd = document.getElementById('ws-dropdown'); var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = 'none'; if (dd) dd.style.display = 'none';
renderWorkspaceList();
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); if (!window.CrankApi || !window.CrankAuth || typeof window.CrankAuth.replaceSession !== 'function') {
cacheCurrentWorkspace(workspace);
renderWorkspaceList();
emitWorkspaceChange(workspace);
return;
}
return window.CrankApi
.setCurrentWorkspace(workspace.id)
.then(function(session) {
window.CrankAuth.replaceSession(session);
cacheCurrentWorkspace(workspace);
renderWorkspaceList();
emitWorkspaceChange(workspace);
return workspace;
})
.catch(function(error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to switch workspace', 'Workspace switch failed');
}
throw error;
});
} }
function setCurrentWorkspace(workspace) { function setCurrentWorkspace(workspace) {
persistCurrentWorkspace(workspace); if (!workspace) {
renderWorkspaceList(); return Promise.resolve(null);
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); }
return Promise.resolve(switchWorkspace(workspace.id));
} }
window.getCurrentWorkspace = getCurrentWs; window.getCurrentWorkspace = getCurrentWs;
@@ -155,6 +212,14 @@ window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher); document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
window.addEventListener('crank:sessionchange', function() {
var workspace = getCurrentWs();
renderWorkspaceList();
if (workspace && workspace.id !== lastWorkspaceId) {
emitWorkspaceChange(workspace);
}
});
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!e.target.closest('#ws-switcher')) { if (!e.target.closest('#ws-switcher')) {
var dd = document.getElementById('ws-dropdown'); var dd = document.getElementById('ws-dropdown');
+8
View File
@@ -67,6 +67,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
"create table if not exists user_sessions ( "create table if not exists user_sessions (
id text primary key, id text primary key,
user_id text not null references users(id) on delete cascade, user_id text not null references users(id) on delete cascade,
current_workspace_id text null references workspaces(id) on delete set null,
secret_hash text not null, secret_hash text not null,
status text not null, status text not null,
expires_at timestamptz not null, expires_at timestamptz not null,
@@ -77,6 +78,13 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
.execute(pool) .execute(pool)
.await?; .await?;
query(
"alter table user_sessions
add column if not exists current_workspace_id text null references workspaces(id) on delete set null",
)
.execute(pool)
.await?;
query( query(
"insert into workspaces ( "insert into workspaces (
id, id,
+1
View File
@@ -72,6 +72,7 @@ pub struct SessionRecord {
pub session_id: UserSessionId, pub session_id: UserSessionId,
pub user: User, pub user: User,
pub memberships: Vec<WorkspaceMembershipRecord>, pub memberships: Vec<WorkspaceMembershipRecord>,
pub current_workspace_id: Option<WorkspaceId>,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+39 -1
View File
@@ -257,6 +257,7 @@ impl PostgresRegistry {
&self, &self,
session_id: &UserSessionId, session_id: &UserSessionId,
user_id: &UserId, user_id: &UserId,
current_workspace_id: Option<&WorkspaceId>,
secret_hash: &str, secret_hash: &str,
expires_at: &str, expires_at: &str,
) -> Result<(), RegistryError> { ) -> Result<(), RegistryError> {
@@ -264,6 +265,7 @@ impl PostgresRegistry {
"insert into user_sessions ( "insert into user_sessions (
id, id,
user_id, user_id,
current_workspace_id,
secret_hash, secret_hash,
status, status,
expires_at, expires_at,
@@ -273,14 +275,16 @@ impl PostgresRegistry {
$1, $1,
$2, $2,
$3, $3,
$4,
'active', 'active',
$4::timestamptz, $5::timestamptz,
now(), now(),
now() now()
)", )",
) )
.bind(session_id.as_str()) .bind(session_id.as_str())
.bind(user_id.as_str()) .bind(user_id.as_str())
.bind(current_workspace_id.map(|id| id.as_str()))
.bind(secret_hash) .bind(secret_hash)
.bind(expires_at) .bind(expires_at)
.execute(&self.pool) .execute(&self.pool)
@@ -298,6 +302,7 @@ impl PostgresRegistry {
"select "select
s.id, s.id,
s.user_id, s.user_id,
s.current_workspace_id,
u.email, u.email,
u.display_name, u.display_name,
u.status, u.status,
@@ -328,11 +333,26 @@ impl PostgresRegistry {
created_at: row.try_get("created_at")?, created_at: row.try_get("created_at")?,
}; };
let memberships = self.list_workspaces_for_user(&user_id).await?; let memberships = self.list_workspaces_for_user(&user_id).await?;
let stored_workspace_id = row
.try_get::<Option<String>, _>("current_workspace_id")?
.map(WorkspaceId::new);
let current_workspace_id = stored_workspace_id
.filter(|workspace_id| {
memberships
.iter()
.any(|membership| membership.workspace.id == *workspace_id)
})
.or_else(|| {
memberships
.first()
.map(|membership| membership.workspace.id.clone())
});
Ok(Some(SessionRecord { Ok(Some(SessionRecord {
session_id: UserSessionId::new(row.try_get::<String, _>("id")?), session_id: UserSessionId::new(row.try_get::<String, _>("id")?),
user, user,
memberships, memberships,
current_workspace_id,
})) }))
} }
@@ -368,6 +388,24 @@ impl PostgresRegistry {
Ok(()) Ok(())
} }
pub async fn set_user_session_current_workspace(
&self,
session_id: &UserSessionId,
workspace_id: &WorkspaceId,
) -> Result<(), RegistryError> {
sqlx::query(
"update user_sessions
set current_workspace_id = $2
where id = $1",
)
.bind(session_id.as_str())
.bind(workspace_id.as_str())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn user_has_workspace_access( pub async fn user_has_workspace_access(
&self, &self,
user_id: &UserId, user_id: &UserId,
+4 -2
View File
@@ -76,15 +76,17 @@
- `GET /api/auth/session` - `GET /api/auth/session`
- `GET /api/auth/profile` - `GET /api/auth/profile`
- `PATCH /api/auth/profile` - `PATCH /api/auth/profile`
- `POST /api/auth/current-workspace`
- `POST /api/auth/password` - `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 и `current_workspace_id`;
- `GET /profile` возвращает текущего пользователя и memberships для settings UI; - `GET /profile` возвращает текущего пользователя, memberships и `current_workspace_id` для settings UI;
- `PATCH /profile` обновляет `display_name` и `email` текущего пользователя; - `PATCH /profile` обновляет `display_name` и `email` текущего пользователя;
- `POST /current-workspace` переключает текущий workspace внутри текущей authenticated session;
- `POST /password` меняет пароль текущего пользователя после проверки `current_password`; - `POST /password` меняет пароль текущего пользователя после проверки `current_password`;
- `POST /logout` инвалидирует текущую session. - `POST /logout` инвалидирует текущую session.
+8 -11
View File
@@ -115,7 +115,6 @@ UI-файлы:
Что еще не хватает: Что еще не хватает:
- server-side current workspace model вместо client-side `localStorage`;
- дальнейший UX polish вокруг gRPC discovery, потому что server reflection сознательно заменен на descriptor-driven live flow; - дальнейший UX polish вокруг gRPC discovery, потому что server reflection сознательно заменен на descriptor-driven live flow;
- возможные smoke tests для `wizard`, чтобы закрепить уже подключенный live contract. - возможные smoke tests для `wizard`, чтобы закрепить уже подключенный live contract.
@@ -128,6 +127,7 @@ UI-файлы:
Простой итог: Простой итог:
- wizard почти готов для реального backend; - wizard почти готов для реального backend;
- текущий workspace для wizard уже приходит из auth session, а не только из client-side `localStorage`;
- это основной экран второй очереди после catalog; - это основной экран второй очереди после catalog;
- базовый create/edit draft flow уже можно посадить на live `create/get version/update` endpoints; - базовый create/edit draft flow уже можно посадить на live `create/get version/update` endpoints;
- следующий разрыв здесь - test/publish/import/export wiring и полноценный gRPC descriptor-set lifecycle. - следующий разрыв здесь - test/publish/import/export wiring и полноценный gRPC descriptor-set lifecycle.
@@ -312,13 +312,10 @@ UI-файлы:
Что еще не хватает: Что еще не хватает:
- `switch current workspace` все еще живет на клиенте, а не в session/backend;
- session-aware current workspace model;
- finer-grained permission matrix beyond current `owner/admin` management rules. - finer-grained permission matrix beyond current `owner/admin` management rules.
Отдельный конфликт: Отдельный конфликт:
- current workspace по-прежнему client-side;
- memberships, role-management и invitations уже live; - memberships, role-management и invitations уже live;
- `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта - `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта
@@ -326,7 +323,7 @@ UI-файлы:
- `workspace-setup` уже подключен к live backend; - `workspace-setup` уже подключен к live backend;
- create/edit workspace, memberships, invitations, export и delete работают; - create/edit workspace, memberships, invitations, export и delete работают;
- текущий workspace все еще остается client-side моделью. - текущий workspace уже синхронизируется через auth session и используется всеми live страницами.
### 4.8. Settings ### 4.8. Settings
@@ -347,6 +344,7 @@ UI-файлы:
- `GET /api/auth/session` - `GET /api/auth/session`
- `GET /api/auth/profile` - `GET /api/auth/profile`
- `PATCH /api/auth/profile` - `PATCH /api/auth/profile`
- `POST /api/auth/current-workspace`
- `POST /api/auth/password` - `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}`
@@ -354,8 +352,7 @@ UI-файлы:
Что еще не хватает: Что еще не хватает:
- preferences endpoint; - preferences endpoint;
- полноценный advanced security model (`2FA`, passkeys, session inventory); - полноценный advanced security model (`2FA`, passkeys, session inventory).
- session-aware current workspace model вместо client-side `localStorage`.
Отдельный конфликт: Отдельный конфликт:
@@ -437,13 +434,13 @@ UI вводит scope `deploy`, backend пока не отражает полн
### 5.4. Workspace switching ### 5.4. Workspace switching
UI хранит current workspace в `localStorage`. UI хранит current workspace в auth session и использует `localStorage` только как cache/fallback.
Решение: Решение:
- на ближайшем этапе оставить client-side current workspace; - backend хранит `current_workspace_id` в user session;
- данные workspace брать с backend; - `POST /api/auth/current-workspace` переключает активный workspace;
- позже заменить на session-aware current workspace model. - клиент использует `localStorage` только как cache/fallback для shell state.
## 6. Порядок интеграции ## 6. Порядок интеграции