feat: persist current workspace in user sessions
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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. Порядок интеграции
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user