diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 515c225..edd01ce 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -8,20 +8,14 @@ use crate::{ rate_limit::apply_api_rate_limit, request_context::apply_request_context, routes::{ - access::{ - create_invitation, delete_invitation, delete_membership, delete_workspace, - export_workspace, list_invitations, list_memberships, update_membership, - }, + access::export_workspace, agents::{ archive_agent, create_agent, create_agent_platform_api_key, delete_agent, delete_agent_platform_api_key, get_agent, get_agent_version, list_agent_platform_api_keys, list_agents, publish_agent, revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent, }, - auth::{ - change_password, get_profile, get_session, login, logout, update_current_workspace, - update_profile, - }, + auth::{change_password, get_profile, get_session, login, logout, update_profile}, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, capabilities::get_capabilities, machine_auth::{issue_agent_token, issue_one_time_agent_token}, @@ -35,7 +29,7 @@ use crate::{ secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, streaming::list_protocol_capabilities, upstreams::{create_upstream, list_upstreams, update_upstream}, - workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, + workspaces::{get_workspace, list_workspaces, update_workspace}, }, state::AppState, }; @@ -118,16 +112,6 @@ pub fn build_app(state: AppState) -> Router { get(get_secret).delete(delete_secret), ) .route("/secrets/{secret_id}/rotate", post(rotate_secret)) - .route("/members", get(list_memberships)) - .route( - "/members/{user_id}", - axum::routing::patch(update_membership).delete(delete_membership), - ) - .route( - "/invitations", - get(list_invitations).post(create_invitation), - ) - .route("/invitations/{invitation_id}", delete(delete_invitation)) .route("/export", get(export_workspace)) .route("/logs", get(list_logs)) .route("/logs/{log_id}", get(get_log)) @@ -138,7 +122,7 @@ pub fn build_app(state: AppState) -> Router { let workspace_root_router = Router::new() .route("/capabilities", get(get_capabilities)) - .route("/workspaces", get(list_workspaces).post(create_workspace)) + .route("/workspaces", get(list_workspaces)) .layer(middleware::from_fn_with_state( state.clone(), require_session, @@ -147,9 +131,7 @@ pub fn build_app(state: AppState) -> Router { let workspace_scoped_router = Router::new() .route( "/workspaces/{workspace_id}", - get(get_workspace) - .patch(update_workspace) - .delete(delete_workspace), + get(get_workspace).patch(update_workspace), ) .nest("/workspaces/{workspace_id}", workspace_router) .layer(middleware::from_fn_with_state( @@ -163,7 +145,6 @@ pub fn build_app(state: AppState) -> Router { .route("/logout", post(logout)) .route("/session", get(get_session)) .route("/profile", get(get_profile).patch(update_profile)) - .route("/current-workspace", post(update_current_workspace)) .route("/password", post(change_password)) .layer(middleware::from_fn_with_state( state.clone(), @@ -1002,21 +983,19 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] - async fn manages_platform_access_resources() { + async fn rejects_workspace_access_management_in_community() { let registry = test_registry().await; let storage_root = test_storage_root("platform_access"); let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; - let members = client + let members_status = client .get(format!("{base_url}/members")) .send() .await .unwrap() - .json::() - .await - .unwrap(); - let created_invitation = client + .status(); + let create_invitation_status = client .post(format!("{base_url}/invitations")) .json(&json!({ "email": "operator@example.com", @@ -1025,44 +1004,10 @@ mod tests { .send() .await .unwrap() - .json::() - .await - .unwrap(); - let invitation_id = created_invitation["invitation"]["invitation"]["id"] - .as_str() - .unwrap() - .to_owned(); - let invitations = client - .get(format!("{base_url}/invitations")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let delete_invitation_status = client - .delete(format!("{base_url}/invitations/{invitation_id}")) - .send() - .await - .unwrap() .status(); - assert_eq!(members["items"][0]["role"], "owner"); - assert_eq!( - created_invitation["invitation"]["invitation"]["status"], - "pending" - ); - assert!( - created_invitation["invite_token"] - .as_str() - .unwrap() - .starts_with("invite_") - ); - assert_eq!( - invitations["items"][0]["invitation"]["email"], - "operator@example.com" - ); - assert_eq!(delete_invitation_status, reqwest::StatusCode::NO_CONTENT); + assert_eq!(members_status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(create_invitation_status, reqwest::StatusCode::NOT_FOUND); } #[tokio::test(flavor = "multi_thread")] @@ -1178,7 +1123,7 @@ mod tests { ); assert_eq!(response["limits"]["max_workspaces"], 1); assert_eq!(response["limits"]["max_users_per_workspace"], 1); - assert_eq!(response["limits"]["max_agents_per_workspace"], 1); + assert_eq!(response["limits"]["max_agents_per_workspace"], Value::Null); } #[tokio::test(flavor = "multi_thread")] @@ -1568,7 +1513,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] - async fn manages_workspace_access_lifecycle() { + async fn exports_single_workspace_but_rejects_access_lifecycle() { let registry = test_registry().await; let storage_root = test_storage_root("workspace_access"); let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; @@ -1587,22 +1532,14 @@ mod tests { .await .unwrap(); - let updated_members = assert_success_json( - client - .patch(format!("{base_url}/members/{}", second_user_id.as_str())) - .json(&json!({ "role": "admin" })) - .send() - .await - .unwrap(), - ) - .await; - let updated_member = updated_members["items"] - .as_array() + let update_member_status = client + .patch(format!("{base_url}/members/{}", second_user_id.as_str())) + .json(&json!({ "role": "admin" })) + .send() + .await .unwrap() - .iter() - .find(|item| item["user"]["id"] == second_user_id.as_str()) - .unwrap(); - assert_eq!(updated_member["role"], "admin"); + .status(); + assert_eq!(update_member_status, reqwest::StatusCode::NOT_FOUND); let exported = assert_success_json( client @@ -1616,7 +1553,8 @@ mod tests { exported["workspace"]["workspace"]["id"], DEFAULT_WORKSPACE_ID ); - assert_eq!(exported["memberships"].as_array().unwrap().len(), 2); + assert!(exported.get("memberships").is_none()); + assert!(exported.get("invitations").is_none()); let delete_member_status = client .delete(format!("{base_url}/members/{}", second_user_id.as_str())) @@ -1624,7 +1562,7 @@ mod tests { .await .unwrap() .status(); - assert_eq!(delete_member_status, reqwest::StatusCode::NO_CONTENT); + assert_eq!(delete_member_status, reqwest::StatusCode::NOT_FOUND); let delete_workspace_status = client .delete(base_url.as_ref()) @@ -1632,21 +1570,27 @@ mod tests { .await .unwrap() .status(); - assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT); + assert_eq!( + delete_workspace_status, + reqwest::StatusCode::METHOD_NOT_ALLOWED + ); let root_url = base_url .as_ref() .split("/api/admin/workspaces/") .next() .unwrap(); - let missing_workspace = client - .get(format!( - "{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}" - )) - .send() - .await - .unwrap(); - assert_eq!(missing_workspace.status(), reqwest::StatusCode::FORBIDDEN); + let still_available = assert_success_json( + client + .get(format!( + "{root_url}/api/admin/workspaces/{DEFAULT_WORKSPACE_ID}" + )) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(still_available["workspace"]["id"], DEFAULT_WORKSPACE_ID); } #[tokio::test] @@ -1679,11 +1623,7 @@ mod tests { .iter() .any(|item| item.workspace.slug == "default") ); - assert!( - workspaces - .iter() - .any(|item| item.workspace.slug == "growth-lab") - ); + assert_eq!(workspaces.len(), 1); let default_workspace_id = WorkspaceId::new(DEFAULT_WORKSPACE_ID); let operations = service @@ -1788,7 +1728,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] #[serial] - async fn switches_current_workspace_in_session() { + async fn rejects_multi_workspace_session_switching_in_community() { 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; @@ -1800,31 +1740,30 @@ mod tests { .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 create_workspace_status = client + .post(format!("{root_url}/api/admin/workspaces")) + .json(&json!({ + "slug": "growth-lab", + "display_name": "Growth Lab", + "settings": {} + })) + .send() + .await + .unwrap() + .status(); + assert_eq!( + create_workspace_status, + reqwest::StatusCode::METHOD_NOT_ALLOWED + ); - 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 switch_status = client + .post(format!("{root_url}/api/auth/current-workspace")) + .json(&json!({ "workspace_id": DEFAULT_WORKSPACE_ID })) + .send() + .await + .unwrap() + .status(); + assert_eq!(switch_status, reqwest::StatusCode::NOT_FOUND); let session = assert_success_json( client @@ -1834,7 +1773,7 @@ mod tests { .unwrap(), ) .await; - assert_eq!(session["current_workspace_id"], workspace_id); + assert_eq!(session["current_workspace_id"], DEFAULT_WORKSPACE_ID); } #[tokio::test(flavor = "multi_thread")] diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs index 51a74f1..fceb8c6 100644 --- a/apps/admin-api/src/routes/access.rs +++ b/apps/admin-api/src/routes/access.rs @@ -1,190 +1,17 @@ use axum::{ - Extension, Json, + Json, extract::{Path, State}, - http::StatusCode, -}; -use crank_core::{ - AuditActor, AuditEvent, AuditEventId, AuditTarget, AuditTargetKind, PolicyAction, - PolicyDecision, PolicyScope, SessionActor, }; use serde::Deserialize; use serde_json::{Value, json}; -use time::OffsetDateTime; -use uuid::Uuid; -use crate::{ - auth::AuthenticatedSession, - error::ApiError, - service::{InvitationPayload, UpdateMembershipPayload}, - state::AppState, -}; +use crate::{error::ApiError, state::AppState}; #[derive(Deserialize)] pub struct WorkspacePath { pub workspace_id: String, } -#[derive(Deserialize)] -pub struct WorkspaceInvitationPath { - pub workspace_id: String, - pub invitation_id: String, -} - -#[derive(Deserialize)] -pub struct WorkspaceMembershipPath { - pub workspace_id: String, - pub user_id: String, -} - -pub async fn list_memberships( - Path(path): Path, - State(state): State, -) -> Result, ApiError> { - let items = state - .service - .list_memberships(&path.workspace_id.as_str().into()) - .await?; - Ok(Json(json!({ "items": items }))) -} - -pub async fn update_membership( - Path(path): Path, - State(state): State, - Extension(session): Extension, - Json(payload): Json, -) -> Result, ApiError> { - let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); - enforce_workspace_policy( - &state, - &session, - &workspace_id, - PolicyAction::WriteWorkspaceAccess, - )?; - let items = state - .service - .update_membership_role( - &workspace_id, - &session.user.id, - &path.user_id.as_str().into(), - payload, - ) - .await?; - record_access_audit( - &state, - &session, - &workspace_id, - "membership.role_updated", - AuditTargetKind::Membership, - path.user_id.clone(), - json!({ "user_id": path.user_id }), - ) - .await?; - Ok(Json(json!({ "items": items }))) -} - -pub async fn delete_membership( - Path(path): Path, - State(state): State, - Extension(session): Extension, -) -> Result { - let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); - enforce_workspace_policy( - &state, - &session, - &workspace_id, - PolicyAction::WriteWorkspaceAccess, - )?; - state - .service - .remove_membership( - &workspace_id, - &session.user.id, - &path.user_id.as_str().into(), - ) - .await?; - record_access_audit( - &state, - &session, - &workspace_id, - "membership.removed", - AuditTargetKind::Membership, - path.user_id.clone(), - json!({ "user_id": path.user_id }), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -pub async fn list_invitations( - Path(path): Path, - State(state): State, -) -> Result, ApiError> { - let items = state - .service - .list_invitations(&path.workspace_id.as_str().into()) - .await?; - Ok(Json(json!({ "items": items }))) -} - -pub async fn create_invitation( - Path(path): Path, - State(state): State, - Extension(session): Extension, - Json(payload): Json, -) -> Result, ApiError> { - let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); - enforce_workspace_policy( - &state, - &session, - &workspace_id, - PolicyAction::WriteWorkspaceAccess, - )?; - let created = state - .service - .create_invitation(&workspace_id, payload) - .await?; - record_access_audit( - &state, - &session, - &workspace_id, - "invitation.created", - AuditTargetKind::Invitation, - created.invitation.invitation.id.as_str().to_owned(), - json!({ "invitation_id": created.invitation.invitation.id.as_str() }), - ) - .await?; - Ok(Json(json!(created))) -} - -pub async fn delete_invitation( - Path(path): Path, - State(state): State, - Extension(session): Extension, -) -> Result { - let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); - enforce_workspace_policy( - &state, - &session, - &workspace_id, - PolicyAction::WriteWorkspaceAccess, - )?; - state - .service - .delete_invitation(&workspace_id, &path.invitation_id.as_str().into()) - .await?; - record_access_audit( - &state, - &session, - &workspace_id, - "invitation.deleted", - AuditTargetKind::Invitation, - path.invitation_id.clone(), - json!({ "invitation_id": path.invitation_id }), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - pub async fn export_workspace( Path(path): Path, State(state): State, @@ -195,93 +22,3 @@ pub async fn export_workspace( .await?; Ok(Json(json!(exported))) } - -pub async fn delete_workspace( - Path(path): Path, - State(state): State, - Extension(session): Extension, -) -> Result { - let workspace_id: crank_core::WorkspaceId = path.workspace_id.as_str().into(); - enforce_workspace_policy( - &state, - &session, - &workspace_id, - PolicyAction::WriteWorkspace, - )?; - state - .service - .delete_workspace(&workspace_id, &session.user.id) - .await?; - record_access_audit( - &state, - &session, - &workspace_id, - "workspace.deleted", - AuditTargetKind::Workspace, - workspace_id.as_str().to_owned(), - json!({ "workspace_id": workspace_id.as_str() }), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -fn enforce_workspace_policy( - state: &AppState, - session: &AuthenticatedSession, - workspace_id: &crank_core::WorkspaceId, - action: PolicyAction, -) -> Result<(), ApiError> { - let membership = session - .memberships - .iter() - .find(|membership| membership.workspace.id == *workspace_id) - .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; - let actor = SessionActor { - user_id: session.user.id.clone(), - workspace_id: workspace_id.clone(), - role: membership.role, - }; - - match state.service.policy_engine().check( - &actor, - action, - PolicyScope::Workspace(workspace_id.clone()), - ) { - PolicyDecision::Allow => Ok(()), - PolicyDecision::Deny { reason } => Err(ApiError::forbidden(reason)), - } -} - -async fn record_access_audit( - state: &AppState, - session: &AuthenticatedSession, - workspace_id: &crank_core::WorkspaceId, - action: &str, - target_kind: AuditTargetKind, - target_id: String, - payload: Value, -) -> Result<(), ApiError> { - state - .service - .audit_sink() - .record(AuditEvent { - id: AuditEventId::new(format!("audit_{}", Uuid::now_v7().simple())), - occurred_at: OffsetDateTime::now_utc(), - actor: AuditActor { - user_id: session.user.id.clone(), - email: session.user.email.clone(), - session_id: Some(session.session_id.clone()), - }, - action: action.to_owned(), - target: AuditTarget { - workspace_id: workspace_id.clone(), - kind: target_kind, - id: target_id, - }, - payload, - source_ip: None, - user_agent: None, - }) - .await - .map_err(|error| ApiError::internal(format!("failed to record audit event: {error}"))) -} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c7c5423..4fb0a2b 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -7,23 +7,21 @@ use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, AsyncJobHandle, AuditSink, AuthConfig, AuthKind, AuthProfile, AuthProfileId, CapabilityProfile, CommunityCapabilityProfile, ConfigExport, EditionCapabilities, ExecutionMode, ExportMode, - GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvitationId, - InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, - InvocationSource, InvocationStatus, JobStatus, LoginOutcome, MachineTokenIssuer, - MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel, - OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId, - Samples, Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target, - TransportBehavior, UsagePeriod, UserId, UserSessionId, WizardState, Workspace, WorkspaceId, - WorkspaceStatus, + GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvocationLevel, + InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, JobStatus, LoginOutcome, + MachineTokenIssuer, MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, + OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, ProductEdition, + Protocol, ResponseCachePolicy, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, + StreamSession, StreamStatus, Target, TransportBehavior, UsagePeriod, UserId, UserSessionId, + WizardState, Workspace, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_registry::{ AgentSummary, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, - CreateAsyncJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, - CreatePlatformApiKeyRequest, CreateSecretRequest, CreateStreamSessionRequest, - CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, - ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, + CreateAsyncJobRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, + CreateSecretRequest, CreateStreamSessionRequest, CreateVersionRequest, CreateWorkspaceRequest, + InvocationLogRecord, ListInvocationLogsQuery, OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, @@ -389,24 +387,6 @@ pub struct AgentMutationResult { pub updated_at: String, } -#[derive(Clone, Debug, Deserialize)] -pub struct InvitationPayload { - pub email: String, - pub role: MembershipRole, - pub expires_at: Option, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct UpdateMembershipPayload { - pub role: MembershipRole, -} - -#[derive(Clone, Debug, Serialize)] -pub struct CreatedInvitationResponse { - pub invitation: InvitationRecord, - pub invite_token: String, -} - #[derive(Clone, Debug, Deserialize)] pub struct PlatformApiKeyPayload { pub name: String, @@ -422,8 +402,6 @@ pub struct CreatedPlatformApiKeyResponse { #[derive(Clone, Debug, Serialize)] pub struct WorkspaceExportResponse { pub workspace: WorkspaceRecord, - pub memberships: Vec, - pub invitations: Vec, pub operations: Vec, pub agents: Vec, pub platform_api_keys: Vec, @@ -833,7 +811,6 @@ impl AdminService { self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id) .await?; - self.seed_growth_workspace_demo(&admin_user_id).await?; Ok(()) } @@ -1178,215 +1155,17 @@ impl AdminService { Ok(WorkspaceRecord { workspace }) } - #[instrument(skip(self))] - pub async fn list_memberships( - &self, - workspace_id: &WorkspaceId, - ) -> Result, ApiError> { - self.ensure_workspace_exists(workspace_id).await?; - Ok(self.registry.list_memberships(workspace_id).await?) - } - - pub async fn update_membership_role( - &self, - workspace_id: &WorkspaceId, - actor_user_id: &crank_core::UserId, - target_user_id: &crank_core::UserId, - payload: UpdateMembershipPayload, - ) -> Result, ApiError> { - let memberships = self.list_memberships(workspace_id).await?; - let actor_membership = memberships - .iter() - .find(|membership| &membership.user.id == actor_user_id) - .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; - let target_membership = memberships - .iter() - .find(|membership| &membership.user.id == target_user_id) - .ok_or_else(|| { - ApiError::not_found_with_context( - format!( - "membership for user {} in workspace {} was not found", - target_user_id.as_str(), - workspace_id.as_str() - ), - json!({ - "workspace_id": workspace_id.as_str(), - "user_id": target_user_id.as_str(), - }), - ) - })?; - - if !matches!( - actor_membership.role, - MembershipRole::Owner | MembershipRole::Admin - ) { - return Err(ApiError::forbidden( - "only owners and admins can manage workspace members", - )); - } - - if matches!(target_membership.role, MembershipRole::Owner) - && !matches!(payload.role, MembershipRole::Owner) - { - let owner_count = memberships - .iter() - .filter(|membership| matches!(membership.role, MembershipRole::Owner)) - .count(); - if owner_count <= 1 { - return Err(ApiError::validation( - "workspace must keep at least one owner", - )); - } - } - - self.registry - .update_membership_role(workspace_id, target_user_id, payload.role) - .await?; - self.list_memberships(workspace_id).await - } - - pub async fn remove_membership( - &self, - workspace_id: &WorkspaceId, - actor_user_id: &crank_core::UserId, - target_user_id: &crank_core::UserId, - ) -> Result<(), ApiError> { - let memberships = self.list_memberships(workspace_id).await?; - let actor_membership = memberships - .iter() - .find(|membership| &membership.user.id == actor_user_id) - .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; - let target_membership = memberships - .iter() - .find(|membership| &membership.user.id == target_user_id) - .ok_or_else(|| { - ApiError::not_found_with_context( - format!( - "membership for user {} in workspace {} was not found", - target_user_id.as_str(), - workspace_id.as_str() - ), - json!({ - "workspace_id": workspace_id.as_str(), - "user_id": target_user_id.as_str(), - }), - ) - })?; - - if !matches!( - actor_membership.role, - MembershipRole::Owner | MembershipRole::Admin - ) { - return Err(ApiError::forbidden( - "only owners and admins can manage workspace members", - )); - } - - if matches!(target_membership.role, MembershipRole::Owner) { - let owner_count = memberships - .iter() - .filter(|membership| matches!(membership.role, MembershipRole::Owner)) - .count(); - if owner_count <= 1 { - return Err(ApiError::validation( - "workspace must keep at least one owner", - )); - } - } - - self.registry - .delete_membership(workspace_id, target_user_id) - .await?; - Ok(()) - } - - #[instrument(skip(self))] - pub async fn list_invitations( - &self, - workspace_id: &WorkspaceId, - ) -> Result, ApiError> { - self.ensure_workspace_exists(workspace_id).await?; - Ok(self.registry.list_invitations(workspace_id).await?) - } - - #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), email = %payload.email))] - pub async fn create_invitation( - &self, - workspace_id: &WorkspaceId, - payload: InvitationPayload, - ) -> Result { - self.ensure_workspace_exists(workspace_id).await?; - - let invite_token = generate_access_secret("invite"); - let invitation = InvitationRecord { - invitation: InvitationToken { - id: InvitationId::new(new_prefixed_id("inv")), - workspace_id: workspace_id.clone(), - email: payload.email, - role: payload.role, - status: InvitationStatus::Pending, - token_hash: hash_access_secret(&invite_token), - expires_at: match payload.expires_at { - Some(expires_at) => parse_timestamp(&expires_at)?, - None => default_invitation_expiry()?, - }, - created_at: OffsetDateTime::now_utc(), - }, - }; - - self.registry - .create_invitation(CreateInvitationRequest { - invitation: &invitation.invitation, - }) - .await?; - - Ok(CreatedInvitationResponse { - invitation, - invite_token, - }) - } - - #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), invitation_id = %invitation_id.as_str()))] - pub async fn delete_invitation( - &self, - workspace_id: &WorkspaceId, - invitation_id: &InvitationId, - ) -> Result<(), ApiError> { - self.registry - .delete_invitation(workspace_id, invitation_id) - .await?; - Ok(()) - } - pub async fn export_workspace( &self, workspace_id: &WorkspaceId, ) -> Result { let workspace = self.get_workspace(workspace_id).await?; - let memberships = self.list_memberships(workspace_id).await?; - let invitations = self - .list_invitations(workspace_id) - .await? - .into_iter() - .map(|record| { - json!({ - "id": record.invitation.id, - "email": record.invitation.email, - "role": record.invitation.role, - "status": record.invitation.status, - "expires_at": record.invitation.expires_at, - "created_at": record.invitation.created_at, - }) - }) - .collect(); let operations = self.list_operations(workspace_id).await?; let agents = self.list_agents(workspace_id).await?; let platform_api_keys = self.registry.list_platform_api_keys(workspace_id).await?; Ok(WorkspaceExportResponse { workspace, - memberships, - invitations, operations, agents, platform_api_keys, @@ -1394,27 +1173,6 @@ impl AdminService { }) } - pub async fn delete_workspace( - &self, - workspace_id: &WorkspaceId, - actor_user_id: &crank_core::UserId, - ) -> Result<(), ApiError> { - let memberships = self.list_memberships(workspace_id).await?; - let actor_membership = memberships - .iter() - .find(|membership| &membership.user.id == actor_user_id) - .ok_or_else(|| ApiError::forbidden("workspace access denied"))?; - - if !matches!(actor_membership.role, MembershipRole::Owner) { - return Err(ApiError::forbidden( - "only workspace owners can delete a workspace", - )); - } - - self.registry.delete_workspace(workspace_id).await?; - Ok(()) - } - #[instrument(skip(self))] pub async fn list_agent_platform_api_keys( &self, @@ -3570,47 +3328,9 @@ impl AdminService { owner_user_id: &crank_core::UserId, workspace_id: &WorkspaceId, ) -> Result<(), ApiError> { - let ops_admin_id = self - .ensure_demo_user("ops-manager@crank.demo", "Ops Manager") - .await?; - let analyst_id = self - .ensure_demo_user("analyst@crank.demo", "Revenue Analyst") - .await?; - let contractor_id = self - .ensure_demo_user("contractor@crank.demo", "Delivery Contractor") - .await?; - self.registry .ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner) .await?; - self.registry - .ensure_membership(workspace_id, &ops_admin_id, MembershipRole::Admin) - .await?; - self.registry - .ensure_membership(workspace_id, &analyst_id, MembershipRole::Operator) - .await?; - self.registry - .ensure_membership(workspace_id, &contractor_id, MembershipRole::Viewer) - .await?; - - self.ensure_demo_invitation( - workspace_id, - InvitationPayload { - email: "partner@crank.demo".to_owned(), - role: MembershipRole::Viewer, - expires_at: None, - }, - ) - .await?; - self.ensure_demo_invitation( - workspace_id, - InvitationPayload { - email: "automation@crank.demo".to_owned(), - role: MembershipRole::Operator, - expires_at: None, - }, - ) - .await?; let rest_operation = self .ensure_demo_operation(workspace_id, demo_rest_operation_payload()) @@ -3670,98 +3390,6 @@ impl AdminService { Ok(()) } - async fn seed_growth_workspace_demo( - &self, - owner_user_id: &crank_core::UserId, - ) -> Result<(), ApiError> { - let workspace = self - .ensure_demo_workspace( - owner_user_id, - WorkspacePayload { - slug: "growth-lab".to_owned(), - display_name: "Growth Lab".to_owned(), - settings: json!({ - "tier": "demo", - "region": "eu-central", - "notes": "Secondary workspace for workspace switch testing" - }), - }, - ) - .await?; - let workspace_id = workspace.workspace.id; - - let growth_pm_id = self - .ensure_demo_user("growth.pm@crank.demo", "Growth PM") - .await?; - self.registry - .ensure_membership(&workspace_id, owner_user_id, MembershipRole::Owner) - .await?; - self.registry - .ensure_membership(&workspace_id, &growth_pm_id, MembershipRole::Admin) - .await?; - - self.ensure_demo_invitation( - &workspace_id, - InvitationPayload { - email: "agency@crank.demo".to_owned(), - role: MembershipRole::Viewer, - expires_at: None, - }, - ) - .await?; - Ok(()) - } - - async fn ensure_demo_workspace( - &self, - owner_user_id: &crank_core::UserId, - payload: WorkspacePayload, - ) -> Result { - if let Some(existing) = self - .registry - .list_workspaces_for_user(owner_user_id) - .await? - .into_iter() - .find(|record| record.workspace.slug == payload.slug) - { - return Ok(WorkspaceRecord { - workspace: existing.workspace, - }); - } - - self.create_workspace(owner_user_id, payload).await - } - - async fn ensure_demo_user( - &self, - email: &str, - display_name: &str, - ) -> Result { - let password_hash = hash_password(DEMO_USER_PASSWORD, &self.auth_settings.password_pepper)?; - self.registry - .upsert_bootstrap_user(email, display_name, &password_hash) - .await - .map_err(ApiError::from) - } - - async fn ensure_demo_invitation( - &self, - workspace_id: &WorkspaceId, - payload: InvitationPayload, - ) -> Result<(), ApiError> { - if self - .list_invitations(workspace_id) - .await? - .iter() - .any(|record| record.invitation.email == payload.email) - { - return Ok(()); - } - - self.create_invitation(workspace_id, payload).await?; - Ok(()) - } - async fn ensure_demo_platform_api_key( &self, workspace_id: &WorkspaceId, @@ -4004,8 +3632,6 @@ impl AdminService { } } -const DEMO_USER_PASSWORD: &str = "CrankDemoPass123!"; - fn build_request_preview( mapping: &MappingSet, input: &Value, @@ -4306,11 +3932,6 @@ fn now_string() -> Result { .map_err(|error| ApiError::internal(error.to_string())) } -fn parse_timestamp(value: &str) -> Result { - OffsetDateTime::parse(value, &Rfc3339) - .map_err(|_| ApiError::validation("timestamp must be RFC 3339")) -} - fn format_timestamp(timestamp: OffsetDateTime) -> String { timestamp .format(&Rfc3339) @@ -4391,12 +4012,6 @@ async fn resolve_runtime_auth_for_task( ResolvedAuth::from_profile(&auth_profile, &secrets).map(Some) } -fn default_invitation_expiry() -> Result { - OffsetDateTime::now_utc() - .checked_add(time::Duration::days(7)) - .ok_or_else(|| ApiError::internal("failed to compute invitation expiry")) -} - fn runtime_error_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "schema_error", diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index 830afb5..eaa580d 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -32,12 +32,6 @@ diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index e6b6b3e..e3ce800 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -49,12 +49,6 @@ diff --git a/apps/ui/html/login.html b/apps/ui/html/login.html index 24f3368..e3b1c1f 100644 --- a/apps/ui/html/login.html +++ b/apps/ui/html/login.html @@ -23,7 +23,7 @@ diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index 3eef052..9060db0 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -30,12 +30,6 @@ diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html index ea86e52..8c9bd8b 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -37,12 +37,6 @@ diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index a69d71c..13c4c74 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -30,12 +30,6 @@ diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index cfe09a5..eb008a2 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -36,7 +36,7 @@
Workspace settings
-
Configure your workspace identity and team members.
+
Configure the identity of your self-hosted workspace.
@@ -79,164 +79,6 @@ - - - - - -
Cancel @@ -244,7 +86,7 @@
@@ -256,17 +98,10 @@
Export all data
-
Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.
+
Download a JSON snapshot of workspace settings, operations, agents, secrets, usage data and agent access keys.
-
-
-
Delete workspace
-
Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.
-
- -
diff --git a/apps/ui/index.html b/apps/ui/index.html index dda3a21..3b8630a 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -30,12 +30,6 @@ diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 6bb1e29..f445d47 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -162,52 +162,21 @@ 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() { return get('/workspaces'); }, getCapabilities: function() { return get('/capabilities'); }, - createWorkspace: function(payload) { - return post('/workspaces', payload); - }, getWorkspace: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId)); }, updateWorkspace: function(workspaceId, payload) { return patch('/workspaces/' + encodeURIComponent(workspaceId), payload); }, - listMemberships: function(workspaceId) { - return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members'); - }, - updateMembership: function(workspaceId, userId, payload) { - return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId), payload); - }, - deleteMembership: function(workspaceId, userId) { - return del('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId)); - }, - listInvitations: function(workspaceId) { - return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations'); - }, - createInvitation: function(workspaceId, payload) { - return post('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations', payload); - }, - deleteInvitation: function(workspaceId, invitationId) { - return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId)); - }, exportWorkspace: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/export'); }, - deleteWorkspace: function(workspaceId) { - return del('/workspaces/' + encodeURIComponent(workspaceId)); - }, listOperations: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations'); }, diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 09b4c0c..d5af26a 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -360,7 +360,6 @@ var TRANSLATIONS = { 'settings.title': 'Settings', 'settings.nav.workspace': 'Workspace', 'settings.nav.profile': 'Profile', - 'settings.nav.members': 'Members', 'settings.nav.security': 'Security', 'settings.nav.notif': 'Notifications', 'settings.nav.danger': 'Danger zone', @@ -459,7 +458,7 @@ var TRANSLATIONS = { // Workspace setup 'workspace_setup.title': 'Workspace settings', - 'workspace_setup.page_subtitle': 'Configure your workspace identity and team members.', + 'workspace_setup.page_subtitle': 'Configure the identity of your self-hosted workspace.', 'workspace_setup.back': 'Back', 'workspace_setup.identity.title': 'Workspace identity', 'workspace_setup.identity.avatar_hint': 'Avatar is derived from your workspace name.', @@ -470,68 +469,26 @@ var TRANSLATIONS = { 'workspace_setup.slug_hint': 'Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.', 'workspace_setup.description': 'Description', 'workspace_setup.description_placeholder': 'What does this workspace do?', - 'workspace_setup.members.title': 'Team members', - 'workspace_setup.members.invite': 'Invite member', - 'workspace_setup.members.email': 'Email address', - 'workspace_setup.members.role': 'Role', - 'workspace_setup.members.send_invite': 'Send invite', - 'workspace_setup.members.cancel': 'Cancel', - 'workspace_setup.members.invite_sent': '✓ Invite sent.', - 'workspace_setup.members.you': '(you)', - 'workspace_setup.members.today': 'Today', - 'workspace_setup.members.days_ago.one': '{count} day ago', - 'workspace_setup.members.days_ago.other': '{count} days ago', - 'workspace_setup.members.week_ago': '1 week ago', - 'workspace_setup.members.never': 'Never', - 'workspace_setup.members.pending': 'Pending invites', - 'workspace_setup.members.invited_pending': 'Invited {date} · pending', - 'workspace_setup.members.revoke': 'Revoke invite', - 'workspace_setup.members.remove': 'Remove member', - 'workspace_setup.roles.title': 'Roles & permissions', - 'workspace_setup.roles.owner_body': 'Full workspace control, billing access, can transfer or delete the workspace.', - 'workspace_setup.roles.admin_body': 'Manage members and roles, create and edit all operations and agents, manage API keys.', - 'workspace_setup.roles.developer_body': 'Create and edit operations and agents. Cannot manage members or workspace settings.', - 'workspace_setup.roles.viewer_body': 'Read-only access to operations, agents, and logs.', - 'workspace_setup.invite.title': 'Invite team members', - 'workspace_setup.invite.add_person': '+ Add person', - 'workspace_setup.invite.later': 'You can invite more people later from Workspace settings.', 'workspace_setup.actions.create': 'Create workspace', 'workspace_setup.actions.save': 'Save changes', 'workspace_setup.create.title': 'Create a new workspace', - 'workspace_setup.create.subtitle': "A workspace is a shared environment for your team's MCP operations and agents. Each workspace gets its own MCP endpoint namespace.", - 'workspace_setup.create.footer': "You'll be the Owner of this workspace. You can invite additional members after creation.", + 'workspace_setup.create.subtitle': 'This Community installation uses one workspace for MCP operations and agents.', + 'workspace_setup.create.footer': 'This Community installation uses one workspace.', 'workspace_setup.danger.title': 'Danger zone', 'workspace_setup.danger.export_title': 'Export all data', - 'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and agent access keys.', - 'workspace_setup.danger.delete_title': 'Delete workspace', - 'workspace_setup.danger.delete_body': 'Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.', + 'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, operations, agents, secrets, usage data and agent access keys.', 'workspace_setup.export': 'Export', - 'workspace_setup.delete': 'Delete workspace', 'workspace_setup.role.owner': 'Owner', 'workspace_setup.role.admin': 'Admin', 'workspace_setup.role.operator': 'Developer', 'workspace_setup.role.viewer': 'Viewer', - 'workspace_setup.member_count.one': '{count} member', - 'workspace_setup.member_count.other': '{count} members', 'workspace_setup.saving_create': 'Creating…', 'workspace_setup.saving_update': 'Saving…', 'workspace_setup.saved': 'Saved ✓', 'workspace_setup.save_error': 'Failed to save workspace', 'workspace_setup.save_error_title': 'Save failed', - 'workspace_setup.invite_error': 'Failed to send invite', - 'workspace_setup.invite_error_title': 'Invite failed', - 'workspace_setup.role_error': 'Failed to update role', - 'workspace_setup.role_error_title': 'Role update failed', - 'workspace_setup.remove_confirm': 'Remove {name} from this workspace?', - 'workspace_setup.remove_error': 'Failed to remove member', - 'workspace_setup.remove_error_title': 'Member removal failed', - 'workspace_setup.revoke_error': 'Failed to revoke invite', - 'workspace_setup.revoke_error_title': 'Invite revoke failed', 'workspace_setup.export_error': 'Failed to export workspace', 'workspace_setup.export_error_title': 'Export failed', - 'workspace_setup.delete_confirm': 'Delete this workspace? This cannot be undone.', - 'workspace_setup.delete_error': 'Failed to delete workspace', - 'workspace_setup.delete_error_title': 'Delete failed', 'workspace.switch_error': 'Failed to switch workspace', 'workspace.switch_error_title': 'Workspace switch failed', 'workspace_setup.custom_protocol.rest': 'REST', @@ -843,7 +800,7 @@ var TRANSLATIONS = { 'agents.subtitle.community': 'Dedicated MCP endpoints for AI agents. Community keeps machine access on static agent keys.', 'agents.new': 'New agent', 'agents.limit.title': 'Agent limit reached', - 'agents.limit.message': 'This Community build allows up to {limit} AI agent. Upgrade the edition or remove an existing agent to add another one.', + 'agents.limit.message': 'This edition allows up to {limit} AI agent. Remove an existing agent to add another one.', 'agents.stats.total': 'Total agents', 'agents.stats.total_sub': 'across this workspace', 'agents.stats.published': 'Published', @@ -949,17 +906,9 @@ var TRANSLATIONS = { 'login.email': 'Email', 'login.password': 'Password', 'login.submit': 'Sign in', - 'login.sso': 'Continue with Google', 'login.email_label': 'Email address', 'login.email_placeholder': 'you@acme.com', - 'login.forgot': 'Forgot password?', - 'login.divider': 'or continue with', - 'login.sso_short': 'Google SSO', - 'login.request_prefix': "Don't have an account?", - 'login.request_link': 'Request access', - 'login.planned_badge': 'Planned', 'login.password_only': 'Sign in with email and password.', - 'login.coming_soon': 'Password reset and SSO will be added later.', 'login.error.required': 'Please enter your email and password.', 'login.error.invalid': 'Invalid email or password. Please try again.', 'login.error.generic': 'Unable to sign in right now. Please try again.', @@ -1325,7 +1274,6 @@ var TRANSLATIONS = { 'settings.title': 'Настройки', 'settings.nav.workspace': 'Воркспейс', 'settings.nav.profile': 'Профиль', - 'settings.nav.members': 'Участники', 'settings.nav.security': 'Безопасность', 'settings.nav.notif': 'Уведомления', 'settings.nav.danger': 'Опасная зона', @@ -1424,7 +1372,7 @@ var TRANSLATIONS = { // Workspace setup 'workspace_setup.title': 'Настройки воркспейса', - 'workspace_setup.page_subtitle': 'Настройте идентичность воркспейса и состав команды.', + 'workspace_setup.page_subtitle': 'Настройте идентичность вашего self-hosted воркспейса.', 'workspace_setup.back': 'Назад', 'workspace_setup.identity.title': 'Идентичность воркспейса', 'workspace_setup.identity.avatar_hint': 'Аватар формируется из имени воркспейса.', @@ -1435,72 +1383,26 @@ var TRANSLATIONS = { 'workspace_setup.slug_hint': 'Только строчные буквы, цифры и дефисы. Используется в URL MCP endpoint-ов.', 'workspace_setup.description': 'Описание', 'workspace_setup.description_placeholder': 'Чем занимается этот воркспейс?', - 'workspace_setup.members.title': 'Участники команды', - 'workspace_setup.members.invite': 'Пригласить участника', - 'workspace_setup.members.email': 'Email адрес', - 'workspace_setup.members.role': 'Роль', - 'workspace_setup.members.send_invite': 'Отправить приглашение', - 'workspace_setup.members.cancel': 'Отмена', - 'workspace_setup.members.invite_sent': '✓ Приглашение отправлено.', - 'workspace_setup.members.you': '(вы)', - 'workspace_setup.members.today': 'Сегодня', - 'workspace_setup.members.days_ago.one': '{count} день назад', - 'workspace_setup.members.days_ago.few': '{count} дня назад', - 'workspace_setup.members.days_ago.many': '{count} дней назад', - 'workspace_setup.members.days_ago.other': '{count} дня назад', - 'workspace_setup.members.week_ago': '1 неделю назад', - 'workspace_setup.members.never': 'Никогда', - 'workspace_setup.members.pending': 'Ожидающие приглашения', - 'workspace_setup.members.invited_pending': 'Приглашен {date} · ожидает принятия', - 'workspace_setup.members.revoke': 'Отозвать приглашение', - 'workspace_setup.members.remove': 'Удалить участника', - 'workspace_setup.roles.title': 'Роли и права', - 'workspace_setup.roles.owner_body': 'Полный контроль над воркспейсом, доступ к биллингу, возможность передать или удалить воркспейс.', - 'workspace_setup.roles.admin_body': 'Управление участниками и ролями, создание и редактирование всех операций и агентов, управление API-ключами.', - 'workspace_setup.roles.developer_body': 'Создание и редактирование операций и агентов. Нельзя управлять участниками или настройками воркспейса.', - 'workspace_setup.roles.viewer_body': 'Доступ только на чтение к операциям, агентам и логам.', - 'workspace_setup.invite.title': 'Пригласить участников команды', - 'workspace_setup.invite.add_person': '+ Добавить человека', - 'workspace_setup.invite.later': 'Позже можно пригласить людей из настроек воркспейса.', 'workspace_setup.actions.create': 'Создать воркспейс', 'workspace_setup.actions.save': 'Сохранить изменения', 'workspace_setup.create.title': 'Создать новый воркспейс', - 'workspace_setup.create.subtitle': 'Воркспейс — это общее окружение для MCP-операций и агентов вашей команды. У каждого воркспейса свой namespace MCP endpoint-ов.', - 'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.', + 'workspace_setup.create.subtitle': 'В Community используется один воркспейс для MCP-операций и агентов.', + 'workspace_setup.create.footer': 'В Community используется один воркспейс.', 'workspace_setup.danger.title': 'Опасная зона', 'workspace_setup.danger.export_title': 'Экспортировать все данные', - 'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и ключей доступа агентов.', - 'workspace_setup.danger.delete_title': 'Удалить воркспейс', - 'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.', + 'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, операций, агентов, секретов, данных использования и ключей доступа агентов.', 'workspace_setup.export': 'Экспорт', - 'workspace_setup.delete': 'Удалить воркспейс', 'workspace_setup.role.owner': 'Владелец', 'workspace_setup.role.admin': 'Администратор', 'workspace_setup.role.operator': 'Разработчик', 'workspace_setup.role.viewer': 'Наблюдатель', - 'workspace_setup.member_count.one': '{count} участник', - 'workspace_setup.member_count.few': '{count} участника', - 'workspace_setup.member_count.many': '{count} участников', - 'workspace_setup.member_count.other': '{count} участника', 'workspace_setup.saving_create': 'Создание…', 'workspace_setup.saving_update': 'Сохранение…', 'workspace_setup.saved': 'Сохранено ✓', 'workspace_setup.save_error': 'Не удалось сохранить воркспейс', 'workspace_setup.save_error_title': 'Не удалось сохранить', - 'workspace_setup.invite_error': 'Не удалось отправить приглашение', - 'workspace_setup.invite_error_title': 'Не удалось пригласить', - 'workspace_setup.role_error': 'Не удалось обновить роль', - 'workspace_setup.role_error_title': 'Не удалось обновить роль', - 'workspace_setup.remove_confirm': 'Удалить {name} из этого воркспейса?', - 'workspace_setup.remove_error': 'Не удалось удалить участника', - 'workspace_setup.remove_error_title': 'Не удалось удалить участника', - 'workspace_setup.revoke_error': 'Не удалось отозвать приглашение', - 'workspace_setup.revoke_error_title': 'Не удалось отозвать приглашение', 'workspace_setup.export_error': 'Не удалось экспортировать воркспейс', 'workspace_setup.export_error_title': 'Не удалось экспортировать', - 'workspace_setup.delete_confirm': 'Удалить этот воркспейс? Действие нельзя отменить.', - 'workspace_setup.delete_error': 'Не удалось удалить воркспейс', - 'workspace_setup.delete_error_title': 'Не удалось удалить', 'workspace.switch_error': 'Не удалось переключить воркспейс', 'workspace.switch_error_title': 'Не удалось переключить воркспейс', 'workspace_setup.custom_protocol.rest': 'REST', @@ -1816,7 +1718,7 @@ var TRANSLATIONS = { 'agents.subtitle.community': 'Отдельные MCP endpoint-ы для AI-агентов. В Community машинный доступ держится на статических ключах агента.', 'agents.new': 'Новый агент', 'agents.limit.title': 'Достигнут лимит агентов', - 'agents.limit.message': 'В этой Community-сборке доступно не более {limit} AI-агента. Чтобы добавить еще одного, удалите существующего агента или перейдите на другую редакцию.', + 'agents.limit.message': 'В этой редакции доступно не более {limit} AI-агента. Чтобы добавить еще одного, удалите существующего агента.', 'agents.stats.total': 'Всего агентов', 'agents.stats.total_sub': 'в этом воркспейсе', 'agents.stats.published': 'Опубликованы', @@ -1922,17 +1824,9 @@ var TRANSLATIONS = { 'login.email': 'Email', 'login.password': 'Пароль', 'login.submit': 'Войти', - 'login.sso': 'Войти через Google', 'login.email_label': 'Email адрес', 'login.email_placeholder': 'you@acme.com', - 'login.forgot': 'Забыли пароль?', - 'login.divider': 'или продолжить через', - 'login.sso_short': 'Google SSO', - 'login.request_prefix': 'Нет аккаунта?', - 'login.request_link': 'Запросить доступ', - 'login.planned_badge': 'Запланировано', 'login.password_only': 'Войдите с помощью email и пароля.', - 'login.coming_soon': 'Сброс пароля и SSO появятся позже.', 'login.error.required': 'Введите email и пароль.', 'login.error.invalid': 'Неверный email или пароль. Попробуйте еще раз.', 'login.error.generic': 'Сейчас не удается войти. Попробуйте еще раз.', diff --git a/apps/ui/js/wizard-state.js b/apps/ui/js/wizard-state.js index 37f394a..f3e6209 100644 --- a/apps/ui/js/wizard-state.js +++ b/apps/ui/js/wizard-state.js @@ -56,7 +56,7 @@ limits: { max_workspaces: 1, max_users_per_workspace: 1, - max_agents_per_workspace: 1, + max_agents_per_workspace: null, }, }; } diff --git a/apps/ui/js/workspace-setup.js b/apps/ui/js/workspace-setup.js index 4636bcb..094db4d 100644 --- a/apps/ui/js/workspace-setup.js +++ b/apps/ui/js/workspace-setup.js @@ -5,16 +5,10 @@ var workspaceFormState = { formDirty: false, workspaceId: null, workspaceRecord: null, - memberships: [], - invitations: [], - currentUserId: null, }; function tKey(key) { return typeof t === 'function' ? t(key) : key; } -function tfKey(key, vars) { - return typeof tf === 'function' ? tf(key, vars) : key; -} function normalizeSlug(value) { return value @@ -24,28 +18,6 @@ function normalizeSlug(value) { .replace(/^-|-$/g, ''); } -function uiRoleToApi(value) { - if (value === 'developer') { - return 'operator'; - } - return value; -} - -function roleLabel(value) { - if (value === 'operator') return tKey('workspace_setup.role.operator'); - if (value === 'owner') return tKey('workspace_setup.role.owner'); - if (value === 'admin') return tKey('workspace_setup.role.admin'); - if (value === 'viewer') return tKey('workspace_setup.role.viewer'); - return value.charAt(0).toUpperCase() + value.slice(1); -} - -function roleClass(value) { - if (value === 'operator') { - return 'role-developer'; - } - return 'role-' + value; -} - function setFormDirty(value) { workspaceFormState.formDirty = value; } @@ -60,35 +32,6 @@ function formElements() { }; } -function createElement(tag, className, text) { - var node = document.createElement(tag); - if (className) { - node.className = className; - } - if (text !== undefined && text !== null) { - node.textContent = text; - } - return node; -} - -function buildIconSvg(href, width, height, viewBox, attrs) { - var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', String(width)); - svg.setAttribute('height', String(height)); - if (viewBox) { - svg.setAttribute('viewBox', viewBox); - } - if (attrs) { - Object.keys(attrs).forEach(function(key) { - svg.setAttribute(key, attrs[key]); - }); - } - var use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); - use.setAttribute('href', href); - svg.appendChild(use); - return svg; -} - function workspacePayload() { var elements = formElements(); return { @@ -124,251 +67,25 @@ function applyWorkspaceRecord(record) { setFormDirty(false); } -function addMemberRow(container, membership) { - var row = document.createElement('div'); - row.className = 'member-row'; - var isCurrentUser = membership.user.id === workspaceFormState.currentUserId; - var canManage = membership.role !== 'owner'; - var avatar = createElement( - 'div', - 'member-avatar', - membership.user.display_name.slice(0, 2).toUpperCase() - ); - avatar.style.background = - 'linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1)'; - row.appendChild(avatar); - - var info = createElement('div', 'member-info'); - var name = createElement('div', 'member-name', membership.user.display_name); - if (isCurrentUser) { - name.appendChild(document.createTextNode(' ')); - var currentUserLabel = createElement('span', null, '(you)'); - currentUserLabel.style.fontSize = '11px'; - currentUserLabel.style.fontWeight = '400'; - currentUserLabel.style.color = 'var(--text-muted)'; - name.appendChild(currentUserLabel); - } - info.appendChild(name); - info.appendChild(createElement('div', 'member-email', membership.user.email)); - row.appendChild(info); - - row.appendChild(createElement('div', 'member-last-active', '—')); - - if (canManage) { - var select = createElement('select', 'member-role-select'); - select.dataset.previous = membership.role; - [ - ['owner', tKey('workspace_setup.role.owner')], - ['admin', tKey('workspace_setup.role.admin')], - ['operator', tKey('workspace_setup.role.operator')], - ['viewer', tKey('workspace_setup.role.viewer')] - ].forEach(function(optionData) { - var option = document.createElement('option'); - option.value = optionData[0]; - option.textContent = optionData[1]; - option.selected = membership.role === optionData[0]; - select.appendChild(option); - }); - select.addEventListener('change', function() { - updateRole(select, membership.user.id); - }); - row.appendChild(select); - - var removeButton = createElement('button', 'member-remove-btn'); - removeButton.type = 'button'; - removeButton.title = tKey('workspace_setup.members.remove'); - removeButton.appendChild( - buildIconSvg( - '', - 12, - 12, - '0 0 16 16', - { - fill: 'none', - stroke: 'currentColor', - 'stroke-width': '1.8', - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round' - } - ) - ); - var removeSvg = removeButton.querySelector('svg'); - var pathA = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - pathA.setAttribute('d', 'M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4'); - var pathB = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - pathB.setAttribute('d', 'M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9'); - removeSvg.appendChild(pathA); - removeSvg.appendChild(pathB); - removeButton.addEventListener('click', function() { - removeMember(membership.user.id, membership.user.display_name); - }); - row.appendChild(removeButton); - } else { - row.appendChild( - createElement( - 'span', - 'member-role-badge ' + roleClass(membership.role), - roleLabel(membership.role) - ) - ); - } - container.appendChild(row); -} - -function renderMembers() { - var container = document.getElementById('members-list'); - var label = document.getElementById('members-count-label'); - if (!container || !label) { - return; - } - - container.innerHTML = ''; - workspaceFormState.memberships.forEach(function (membership) { - addMemberRow(container, membership); - }); - label.textContent = window.tPlural( - 'workspace_setup.member_count', - workspaceFormState.memberships.length, - { count: workspaceFormState.memberships.length } - ); -} - -function renderInvitations() { - var pending = document.getElementById('pending-invites'); - var count = document.getElementById('pending-count'); - if (!pending || !count) { - return; - } - - var items = workspaceFormState.invitations.filter(function (record) { - return record.invitation.status === 'pending'; - }); - - count.textContent = String(items.length); - if (!items.length) { - pending.hidden = true; - return; - } - - pending.hidden = false; - pending.innerHTML = ''; - var heading = createElement('div', null, tKey('workspace_setup.members.pending') + ' '); - heading.style.fontSize = '11px'; - heading.style.fontWeight = '600'; - heading.style.textTransform = 'uppercase'; - heading.style.letterSpacing = '0.06em'; - heading.style.color = 'var(--text-muted)'; - heading.style.marginBottom = '10px'; - var countPill = createElement('span', null, String(items.length)); - countPill.id = 'pending-count'; - countPill.style.background = 'rgba(139,148,158,0.15)'; - countPill.style.borderRadius = '10px'; - countPill.style.padding = '1px 7px'; - countPill.style.fontSize = '10px'; - heading.appendChild(countPill); - pending.appendChild(heading); - - items.forEach(function (record) { - var invitation = record.invitation; - var row = document.createElement('div'); - row.className = 'member-row'; - var inviteAvatar = createElement('div', 'member-avatar', '?'); - inviteAvatar.style.background = 'var(--bg-canvas)'; - inviteAvatar.style.border = '1.5px dashed var(--border)'; - inviteAvatar.style.color = 'var(--text-muted)'; - inviteAvatar.style.fontSize = '16px'; - row.appendChild(inviteAvatar); - - var inviteInfo = createElement('div', 'member-info'); - inviteInfo.appendChild(createElement('div', 'member-name', invitation.email)); - inviteInfo.appendChild( - createElement( - 'div', - 'member-email', - tfKey('workspace_setup.members.invited_pending', { date: invitation.created_at.slice(0, 10) }) - ) - ); - row.appendChild(inviteInfo); - row.appendChild(createElement('div', 'member-last-active', '—')); - - var roleBadge = createElement('span', 'member-role-badge', roleLabel(invitation.role)); - roleBadge.style.background = 'rgba(139,148,158,0.12)'; - roleBadge.style.color = 'var(--text-muted)'; - roleBadge.style.border = '1px solid var(--border)'; - row.appendChild(roleBadge); - - var revokeButton = createElement('button', 'member-remove-btn'); - revokeButton.type = 'button'; - revokeButton.title = tKey('workspace_setup.members.revoke'); - var revokeSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - revokeSvg.setAttribute('width', '12'); - revokeSvg.setAttribute('height', '12'); - revokeSvg.setAttribute('viewBox', '0 0 16 16'); - revokeSvg.setAttribute('fill', 'none'); - revokeSvg.setAttribute('stroke', 'currentColor'); - revokeSvg.setAttribute('stroke-width', '1.8'); - revokeSvg.setAttribute('stroke-linecap', 'round'); - var lineA = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - lineA.setAttribute('x1', '2'); - lineA.setAttribute('y1', '2'); - lineA.setAttribute('x2', '14'); - lineA.setAttribute('y2', '14'); - var lineB = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - lineB.setAttribute('x1', '14'); - lineB.setAttribute('y1', '2'); - lineB.setAttribute('x2', '2'); - lineB.setAttribute('y2', '14'); - revokeSvg.appendChild(lineA); - revokeSvg.appendChild(lineB); - revokeButton.appendChild(revokeSvg); - revokeButton.addEventListener('click', function() { - revokeInviteById(invitation.id); - }); - row.appendChild(revokeButton); - pending.appendChild(row); - }); -} - -async function loadWorkspaceAccessData() { - if (workspaceFormState.isCreateMode || !window.CrankApi || !workspaceFormState.workspaceId) { - return; - } - - if (window.CrankAuth && typeof window.CrankAuth.fetchSession === 'function') { - try { - var session = await window.CrankAuth.fetchSession(false); - workspaceFormState.currentUserId = session && session.user ? session.user.id : null; - } catch (_error) {} - } - - var membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId); - var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId); - workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : []; - workspaceFormState.invitations = invitationsResponse && invitationsResponse.items ? invitationsResponse.items : []; - renderMembers(); - renderInvitations(); -} - function updatePageMode() { var params = new URLSearchParams(window.location.search); workspaceFormState.isCreateMode = params.get('mode') === 'create'; + if (workspaceFormState.isCreateMode) { + window.location.replace((window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup'); + return; + } var submit = document.getElementById('submit-btn'); - if (workspaceFormState.isCreateMode) { - document.title = 'Crank — ' + tKey('workspace_setup.create.title'); - document.getElementById('page-title').textContent = tKey('workspace_setup.create.title'); - document.getElementById('page-subtitle').textContent = tKey('workspace_setup.create.subtitle'); - document.getElementById('section-invite').hidden = false; - document.getElementById('footer-note').hidden = false; - submit.textContent = tKey('workspace_setup.actions.create'); - submit.disabled = true; - submit.classList.add('is-disabled'); - } else { - document.getElementById('section-members').hidden = false; - document.getElementById('section-danger').hidden = false; - submit.textContent = tKey('workspace_setup.actions.save'); - submit.classList.remove('is-disabled'); + var footerNote = document.getElementById('footer-note'); + if (footerNote) { + footerNote.hidden = true; } + var dangerSection = document.getElementById('section-danger'); + if (dangerSection) { + dangerSection.hidden = false; + } + submit.textContent = tKey('workspace_setup.actions.save'); + submit.classList.remove('is-disabled'); } function onWsNameInput(value) { @@ -410,17 +127,6 @@ function validateCreateForm() { elements.submit.classList.toggle('is-disabled', !valid); } -function inviteRows() { - return Array.from(document.querySelectorAll('#invite-rows .invite-row')).map(function (row) { - return { - email: row.querySelector('input').value.trim(), - role: uiRoleToApi(row.querySelector('select').value), - }; - }).filter(function (row) { - return row.email; - }); -} - async function submitForm() { var payload = workspacePayload(); if (!payload.display_name || !payload.slug || !window.CrankApi) { @@ -433,26 +139,11 @@ async function submitForm() { submit.textContent = workspaceFormState.isCreateMode ? tKey('workspace_setup.saving_create') : tKey('workspace_setup.saving_update'); try { - var response; - if (workspaceFormState.isCreateMode) { - response = await window.CrankApi.createWorkspace(payload); - } else { - response = await window.CrankApi.updateWorkspace(workspaceFormState.workspaceId, payload); - } + var response = await window.CrankApi.updateWorkspace(workspaceFormState.workspaceId, payload); var record = response && response.workspace ? response : { workspace: response.workspace || response }; var workspace = record.workspace; - if (workspaceFormState.isCreateMode) { - var invites = inviteRows(); - for (var index = 0; index < invites.length; index += 1) { - await window.CrankApi.createInvitation(workspace.id, { - email: invites[index].email, - role: invites[index].role, - }); - } - } - var workspaces = await window.refreshWorkspaces(); var mapped = workspaces.find(function (item) { return item.id === workspace.id; }) || { id: workspace.id, @@ -470,13 +161,7 @@ async function submitForm() { workspaceFormState.workspaceRecord = { workspace: workspace }; setFormDirty(false); - if (workspaceFormState.isCreateMode) { - window.location.href = (window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup'; - return; - } - applyWorkspaceRecord({ workspace: workspace }); - await loadWorkspaceAccessData(); submit.textContent = tKey('workspace_setup.saved'); setTimeout(function () { submit.textContent = originalLabel; @@ -491,161 +176,6 @@ async function submitForm() { } } -function addInviteRow() { - var rows = document.getElementById('invite-rows'); - var node = document.createElement('div'); - node.className = 'invite-row'; - var emailInput = createElement('input', 'form-input'); - emailInput.type = 'email'; - emailInput.placeholder = 'colleague@company.com'; - emailInput.autocomplete = 'off'; - emailInput.style.flex = '1'; - emailInput.style.marginBottom = '0'; - node.appendChild(emailInput); - - var roleSelect = createElement('select', 'form-input'); - roleSelect.style.width = '130px'; - roleSelect.style.marginBottom = '0'; - [ - ['admin', tKey('workspace_setup.role.admin')], - ['developer', tKey('workspace_setup.role.operator')], - ['viewer', tKey('workspace_setup.role.viewer')] - ].forEach(function(entry) { - var option = document.createElement('option'); - option.value = entry[0]; - option.textContent = entry[1]; - option.selected = entry[0] === 'developer'; - roleSelect.appendChild(option); - }); - node.appendChild(roleSelect); - - var removeButton = createElement('button', 'invite-row-remove'); - removeButton.type = 'button'; - removeButton.title = tKey('workspace_setup.members.remove'); - removeButton.appendChild( - buildIconSvg( - (window.APP_BASE || '') + 'icons/general/close.svg#icon', - 12, - 12 - ) - ); - removeButton.addEventListener('click', function() { - removeInviteRow(removeButton); - }); - node.appendChild(removeButton); - rows.appendChild(node); -} - -function removeInviteRow(button) { - button.closest('.invite-row').remove(); -} - -function toggleInviteForm() { - var form = document.getElementById('invite-form'); - var success = document.getElementById('invite-success'); - if (!form) { - return; - } - - if (form.hidden) { - form.hidden = false; - if (success) { - success.hidden = true; - } - document.getElementById('invite-email').focus(); - } else { - form.hidden = true; - } -} - -async function sendInvite() { - if (!window.CrankApi || !workspaceFormState.workspaceId) { - return; - } - - var email = document.getElementById('invite-email').value.trim(); - var role = uiRoleToApi(document.getElementById('invite-role').value); - if (!email || email.indexOf('@') === -1) { - return; - } - - try { - await window.CrankApi.createInvitation(workspaceFormState.workspaceId, { email: email, role: role }); - document.getElementById('invite-email').value = ''; - document.getElementById('invite-success').hidden = false; - await loadWorkspaceAccessData(); - } catch (error) { - if (window.CrankUi) { - window.CrankUi.error(error.message || tKey('workspace_setup.invite_error'), tKey('workspace_setup.invite_error_title')); - } - } -} - -async function updateRole(select, userId) { - if (!workspaceFormState.workspaceId || !window.CrankApi) { - return; - } - - var previous = select.dataset.previous || select.value; - select.disabled = true; - - try { - var response = await window.CrankApi.updateMembership(workspaceFormState.workspaceId, userId, { - role: select.value, - }); - workspaceFormState.memberships = response && response.items ? response.items : workspaceFormState.memberships; - renderMembers(); - } catch (error) { - select.value = previous; - if (window.CrankUi) { - window.CrankUi.error(error.message || tKey('workspace_setup.role_error'), tKey('workspace_setup.role_error_title')); - } - } finally { - select.disabled = false; - } -} - -async function removeMember(userId, name) { - if (!workspaceFormState.workspaceId || !window.CrankApi) { - return; - } - if (!confirm(tfKey('workspace_setup.remove_confirm', { name: name }))) { - return; - } - - try { - await window.CrankApi.deleteMembership(workspaceFormState.workspaceId, userId); - await loadWorkspaceAccessData(); - } catch (error) { - if (window.CrankUi) { - window.CrankUi.error(error.message || tKey('workspace_setup.remove_error'), tKey('workspace_setup.remove_error_title')); - } - } -} - -async function revokeInviteById(invitationId) { - if (!window.CrankApi || !workspaceFormState.workspaceId) { - return; - } - - try { - await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId); - await loadWorkspaceAccessData(); - } catch (error) { - if (window.CrankUi) { - window.CrankUi.error(error.message || tKey('workspace_setup.revoke_error'), tKey('workspace_setup.revoke_error_title')); - } - } -} - -function revokeInvite(button) { - var row = button.closest('.member-row'); - if (!row) { - return; - } - row.remove(); -} - function downloadJsonFile(fileName, value) { var blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' }); var url = URL.createObjectURL(blob); @@ -674,32 +204,15 @@ async function exportWorkspaceSnapshot() { } } -async function deleteWorkspaceAction() { - if (!workspaceFormState.workspaceId || !window.CrankApi || !window.CrankAuth) { - return; - } - if (!confirm(tKey('workspace_setup.delete_confirm'))) { - return; - } - - try { - await window.CrankApi.deleteWorkspace(workspaceFormState.workspaceId); - await window.CrankAuth.logout(); - } catch (error) { - if (window.CrankUi) { - window.CrankUi.error(error.message || tKey('workspace_setup.delete_error'), tKey('workspace_setup.delete_error_title')); - } - } -} - async function initPage() { updatePageMode(); - document.getElementById('export-workspace-btn').addEventListener('click', exportWorkspaceSnapshot); - document.getElementById('delete-workspace-btn').addEventListener('click', deleteWorkspaceAction); + var exportButton = document.getElementById('export-workspace-btn'); + if (exportButton) { + exportButton.addEventListener('click', exportWorkspaceSnapshot); + } if (workspaceFormState.isCreateMode) { - document.getElementById('ws-name').focus(); return; } @@ -712,7 +225,6 @@ async function initPage() { try { var record = await window.CrankApi.getWorkspace(workspace.id); applyWorkspaceRecord(record); - await loadWorkspaceAccessData(); } catch (error) { if (window.CrankUi) { window.CrankUi.error(error.message || 'Failed to load workspace', 'Workspace load failed'); diff --git a/apps/ui/js/workspace.js b/apps/ui/js/workspace.js index 874f198..d44cd80 100644 --- a/apps/ui/js/workspace.js +++ b/apps/ui/js/workspace.js @@ -118,6 +118,10 @@ function renderWorkspaceList() { var current = getCurrentWs(); updateWorkspaceHeader(current); + document.querySelectorAll('.ws-dropdown-footer').forEach(function(footer) { + footer.hidden = true; + }); + var list = document.getElementById('ws-dropdown-list'); if (!list) return; window.CrankDom.clear(list); @@ -127,7 +131,8 @@ function renderWorkspaceList() { var item = document.createElement('div'); item.className = 'ws-dropdown-item' + (active ? ' active' : ''); item.addEventListener('click', function() { - switchWorkspace(workspace.id); + var dd = document.getElementById('ws-dropdown'); + if (dd) dd.hidden = true; }); var dot = document.createElement('div'); @@ -232,28 +237,10 @@ function switchWorkspace(workspaceId) { var dd = document.getElementById('ws-dropdown'); if (dd) dd.hidden = true; - 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 || tKey('workspace.switch_error'), tKey('workspace.switch_error_title')); - } - throw error; - }); + cacheCurrentWorkspace(workspace); + renderWorkspaceList(); + emitWorkspaceChange(workspace); + return Promise.resolve(workspace); } function setCurrentWorkspace(workspace) { diff --git a/apps/ui/tests/e2e/workspace-settings.spec.js b/apps/ui/tests/e2e/workspace-settings.spec.js index fb06a60..1356a09 100644 --- a/apps/ui/tests/e2e/workspace-settings.spec.js +++ b/apps/ui/tests/e2e/workspace-settings.spec.js @@ -8,9 +8,9 @@ test('workspace and settings pages show live session data', async ({ page }) => await expect(page.locator('[data-i18n="workspace_setup.defaults.title"]')).toHaveCount(0); await expect(page.locator('#ws-protocol')).toHaveCount(0); await expect(page.locator('#ws-timezone')).toHaveCount(0); - await expect(page.locator('#section-members')).toBeVisible(); - await expect(page.locator('#members-count-label')).toBeVisible(); - await expect(page.locator('#pending-invites')).toBeVisible(); + await expect(page.locator('#section-members')).toHaveCount(0); + await expect(page.locator('#section-invite')).toHaveCount(0); + await expect(page.locator('#delete-workspace-btn')).toHaveCount(0); await page.goto('/settings'); await expect(page.locator('.page-title')).toHaveText(localized('Account settings', 'Настройки аккаунта')); diff --git a/crates/crank-core/src/ext/capability.rs b/crates/crank-core/src/ext/capability.rs index 4e6e504..e0c78ef 100644 --- a/crates/crank-core/src/ext/capability.rs +++ b/crates/crank-core/src/ext/capability.rs @@ -20,7 +20,7 @@ impl CapabilityProfile for CommunityCapabilityProfile { limits: EditionLimits { max_workspaces: Some(1), max_users_per_workspace: Some(1), - max_agents_per_workspace: Some(1), + max_agents_per_workspace: None, }, } } diff --git a/docs/admin-api.md b/docs/admin-api.md index f90a198..8bb468a 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -15,7 +15,6 @@ Base path: - `GET /api/auth/session` - `GET /api/auth/profile` - `PATCH /api/auth/profile` -- `POST /api/auth/current-workspace` - `POST /api/auth/password` Авторизация основана на email/password и HttpOnly session cookie. @@ -34,13 +33,12 @@ Community capabilities: ## Workspaces - `GET /api/admin/workspaces` -- `POST /api/admin/workspaces` - `GET /api/admin/workspaces/{workspace_id}` - `PATCH /api/admin/workspaces/{workspace_id}` -- `DELETE /api/admin/workspaces/{workspace_id}` -- `GET /api/admin/workspaces/{workspace_id}/members` -- `PATCH /api/admin/workspaces/{workspace_id}/members/{user_id}` -- `DELETE /api/admin/workspaces/{workspace_id}/members/{user_id}` + +Community работает с одним bootstrap workspace. API не поддерживает создание +дополнительных workspace-ов, переключение текущего workspace-а, приглашения +пользователей и управление ролями. ## Operations diff --git a/docs/database-schema.md b/docs/database-schema.md index c1437c0..ab6150e 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -8,7 +8,6 @@ - `users` - `user_sessions` - `memberships` -- `invitation_tokens` - `operations` - `operation_versions` - `published_operations` @@ -25,6 +24,9 @@ - `usage_rollups` - `yaml_import_jobs` +`memberships` используется как служебная привязка единственного администратора +к bootstrap workspace. + ## Operations `operations` хранит текущую карточку operation. diff --git a/docs/manual-regression-checklist.md b/docs/manual-regression-checklist.md index 790e0de..9e6a654 100644 --- a/docs/manual-regression-checklist.md +++ b/docs/manual-regression-checklist.md @@ -144,12 +144,10 @@ cd apps/ui && npm run e2e ### 5.8. Workspace и Settings - открыть `/workspace-setup`; -- проверить members; -- проверить invitations; -- проверить role change; -- проверить remove member; +- проверить редактирование имени, slug, описания и цвета единственного workspace; +- проверить, что интерфейса приглашений, ролей и управления пользователями нет; - проверить export workspace; -- проверить delete workspace только на disposable workspace; +- проверить, что delete workspace не отображается; - открыть `/settings`; - проверить profile update; - проверить password change.