diff --git a/TASKS.md b/TASKS.md index 63d1bc2..c6d4d00 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,18 @@ ## Current -### `feat/settings-live-flow` +### `feat/workspace-access-polish` -Status: completed +Status: in_progress DoD: -- `apps/ui/html/settings.html` и `apps/ui/js/settings.js` используют live backend для `profile` и `password` -- `profile` перестает читать данные из `localStorage` как source of truth -- security-block больше не притворяется готовой интеграцией там, где backend-контракта еще нет +- `workspace-setup` использует live backend для role-management, member removal, export и delete +- UI больше не показывает placeholder-поведение для workspace access lifecycle +- backend дает workspace export и delete контракты, согласованные с UI - `test-ui` остается нетронутым как fallback ## Next -- `feat/workspace-access-polish` - `feat/alpine-polish` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 25a38b8..3de6106 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -7,8 +7,9 @@ use crate::{ auth::{require_session, require_workspace_session}, routes::{ access::{ - create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key, - list_invitations, list_memberships, list_platform_api_keys, revoke_platform_api_key, + create_invitation, create_platform_api_key, delete_invitation, delete_membership, + delete_platform_api_key, delete_workspace, export_workspace, list_invitations, + list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership, }, agents::{ create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent, @@ -94,11 +95,16 @@ pub fn build_app(state: AppState) -> Router { ) .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)) .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( "/platform-api-keys", get(list_platform_api_keys).post(create_platform_api_key), @@ -127,7 +133,9 @@ pub fn build_app(state: AppState) -> Router { let workspace_scoped_router = Router::new() .route( "/workspaces/{workspace_id}", - get(get_workspace).patch(update_workspace), + get(get_workspace) + .patch(update_workspace) + .delete(delete_workspace), ) .nest("/workspaces/{workspace_id}", workspace_router) .layer(middleware::from_fn_with_state( @@ -765,6 +773,89 @@ mod tests { assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn manages_workspace_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; + let client = authorized_client(&base_url).await; + + let second_user_id = registry + .upsert_bootstrap_user("operator-2@crank.local", "Operator Two", "external-hash") + .await + .unwrap(); + registry + .ensure_membership( + &WorkspaceId::new(DEFAULT_WORKSPACE_ID), + &second_user_id, + MembershipRole::Viewer, + ) + .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() + .unwrap() + .iter() + .find(|item| item["user"]["id"] == second_user_id.as_str()) + .unwrap(); + assert_eq!(updated_member["role"], "admin"); + + let exported = assert_success_json( + client + .get(format!("{base_url}/export")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!( + exported["workspace"]["workspace"]["id"], + DEFAULT_WORKSPACE_ID + ); + assert_eq!(exported["memberships"].as_array().unwrap().len(), 2); + + let delete_member_status = client + .delete(format!("{base_url}/members/{}", second_user_id.as_str())) + .send() + .await + .unwrap() + .status(); + assert_eq!(delete_member_status, reqwest::StatusCode::NO_CONTENT); + + let delete_workspace_status = client + .delete(base_url.as_ref()) + .send() + .await + .unwrap() + .status(); + assert_eq!(delete_workspace_status, reqwest::StatusCode::NO_CONTENT); + + 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); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn updates_profile_and_changes_password() { diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 17e680b..9018099 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -124,6 +124,12 @@ impl From for ApiError { RegistryError::UserNotFound { user_id } => { Self::not_found(format!("user {user_id} was not found")) } + RegistryError::MembershipNotFound { + workspace_id, + user_id, + } => Self::not_found(format!( + "membership for user {user_id} in workspace {workspace_id} was not found" + )), RegistryError::AgentNotFound { agent_id } => { Self::not_found(format!("agent {agent_id} was not found")) } diff --git a/apps/admin-api/src/routes/access.rs b/apps/admin-api/src/routes/access.rs index 79edb45..d8774a2 100644 --- a/apps/admin-api/src/routes/access.rs +++ b/apps/admin-api/src/routes/access.rs @@ -1,5 +1,5 @@ use axum::{ - Json, + Extension, Json, extract::{Path, State}, http::StatusCode, }; @@ -7,8 +7,9 @@ use serde::Deserialize; use serde_json::{Value, json}; use crate::{ + auth::AuthenticatedSession, error::ApiError, - service::{InvitationPayload, PlatformApiKeyPayload}, + service::{InvitationPayload, PlatformApiKeyPayload, UpdateMembershipPayload}, state::AppState, }; @@ -23,6 +24,12 @@ pub struct WorkspaceInvitationPath { pub invitation_id: String, } +#[derive(Deserialize)] +pub struct WorkspaceMembershipPath { + pub workspace_id: String, + pub user_id: String, +} + #[derive(Deserialize)] pub struct WorkspacePlatformApiKeyPath { pub workspace_id: String, @@ -40,6 +47,40 @@ pub async fn list_memberships( 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 items = state + .service + .update_membership_role( + &path.workspace_id.as_str().into(), + &session.user.id, + &path.user_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn delete_membership( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + state + .service + .remove_membership( + &path.workspace_id.as_str().into(), + &session.user.id, + &path.user_id.as_str().into(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + pub async fn list_invitations( Path(path): Path, State(state): State, @@ -127,3 +168,26 @@ pub async fn delete_platform_api_key( .await?; Ok(StatusCode::NO_CONTENT) } + +pub async fn export_workspace( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let exported = state + .service + .export_workspace(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!(exported))) +} + +pub async fn delete_workspace( + Path(path): Path, + State(state): State, + Extension(session): Extension, +) -> Result { + state + .service + .delete_workspace(&path.workspace_id.as_str().into(), &session.user.id) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c6daf10..750a6ec 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -219,6 +219,11 @@ pub struct InvitationPayload { pub expires_at: Option, } +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateMembershipPayload { + pub role: MembershipRole, +} + #[derive(Clone, Debug, Serialize)] pub struct CreatedInvitationResponse { pub invitation: InvitationRecord, @@ -237,6 +242,17 @@ pub struct CreatedPlatformApiKeyResponse { pub secret: String, } +#[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, + pub exported_at: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct LogsQuery { pub level: Option, @@ -746,6 +762,107 @@ impl AdminService { 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(format!( + "membership for user {} in workspace {} was not found", + target_user_id.as_str(), + workspace_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(format!( + "membership for user {} in workspace {} was not found", + target_user_id.as_str(), + workspace_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, @@ -804,6 +921,63 @@ impl AdminService { 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.list_platform_api_keys(workspace_id).await?; + + Ok(WorkspaceExportResponse { + workspace, + memberships, + invitations, + operations, + agents, + platform_api_keys, + exported_at: now_string()?, + }) + } + + 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_platform_api_keys( &self, diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 4374132..070d735 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -304,7 +304,7 @@
Export all data
-
Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.
+
Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 4e7b7ea..8c2bbfa 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -177,6 +177,12 @@ 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'); }, @@ -186,6 +192,12 @@ 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/workspace-setup.js b/apps/ui/js/workspace-setup.js index 81156c7..2762c5b 100644 --- a/apps/ui/js/workspace-setup.js +++ b/apps/ui/js/workspace-setup.js @@ -7,6 +7,7 @@ var workspaceFormState = { workspaceRecord: null, memberships: [], invitations: [], + currentUserId: null, }; function normalizeSlug(value) { @@ -101,16 +102,32 @@ function applyWorkspaceRecord(record) { 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'; row.innerHTML = '
' + membership.user.display_name.slice(0, 2).toUpperCase() + '
' + '
' + - '
' + membership.user.display_name + '
' + + '
' + membership.user.display_name + + (isCurrentUser ? ' (you)' : '') + + '
' + '
' + membership.user.email + '
' + '
' + '
' + - '' + roleLabel(membership.role) + ''; + ( + canManage + ? '' + + '' + : '' + roleLabel(membership.role) + '' + ); container.appendChild(row); } @@ -175,6 +192,13 @@ async function loadWorkspaceAccessData() { 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 : []; @@ -384,12 +408,42 @@ async function sendInvite() { } } -function updateRole() { - alert('Role management is not available yet in the backend.'); +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; + alert(error.message || 'Failed to update role'); + } finally { + select.disabled = false; + } } -function removeMember() { - alert('Member removal is not available yet in the backend.'); +async function removeMember(userId, name) { + if (!workspaceFormState.workspaceId || !window.CrankApi) { + return; + } + if (!confirm('Remove ' + name + ' from this workspace?')) { + return; + } + + try { + await window.CrankApi.deleteMembership(workspaceFormState.workspaceId, userId); + await loadWorkspaceAccessData(); + } catch (error) { + alert(error.message || 'Failed to remove member'); + } } async function revokeInviteById(invitationId) { @@ -413,6 +467,48 @@ function revokeInvite(button) { 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); + var link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +async function exportWorkspaceSnapshot() { + if (!workspaceFormState.workspaceId || !window.CrankApi) { + return; + } + + try { + var snapshot = await window.CrankApi.exportWorkspace(workspaceFormState.workspaceId); + var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace + ? workspaceFormState.workspaceRecord.workspace.slug + : 'workspace'; + downloadJsonFile(slug + '-snapshot.json', snapshot); + } catch (error) { + alert(error.message || 'Failed to export workspace'); + } +} + +async function deleteWorkspaceAction() { + if (!workspaceFormState.workspaceId || !window.CrankApi || !window.CrankAuth) { + return; + } + if (!confirm('Delete this workspace? This cannot be undone.')) { + return; + } + + try { + await window.CrankApi.deleteWorkspace(workspaceFormState.workspaceId); + await window.CrankAuth.logout(); + } catch (error) { + alert(error.message || 'Failed to delete workspace'); + } +} + async function initPage() { updatePageMode(); @@ -421,13 +517,8 @@ async function initPage() { button.classList.toggle('active', button.dataset.lang === language); }); - document.getElementById('export-workspace-btn').addEventListener('click', function () { - alert('Workspace export is not available yet in the backend.'); - }); - - document.getElementById('delete-workspace-btn').addEventListener('click', function () { - alert('Workspace deletion is not available yet in the backend.'); - }); + document.getElementById('export-workspace-btn').addEventListener('click', exportWorkspaceSnapshot); + document.getElementById('delete-workspace-btn').addEventListener('click', deleteWorkspaceAction); if (workspaceFormState.isCreateMode) { document.getElementById('ws-name').focus(); diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index df0d8c7..8089561 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -14,6 +14,11 @@ pub enum RegistryError { UserNotFound { user_id: String }, #[error("user with email {email} already exists")] UserEmailAlreadyExists { email: String }, + #[error("membership for user {user_id} in workspace {workspace_id} was not found")] + MembershipNotFound { + workspace_id: String, + user_id: String, + }, #[error("invitation {invitation_id} was not found")] InvitationNotFound { invitation_id: String }, #[error("platform api key {key_id} was not found")] diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 25805e5..9fc38a3 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -415,6 +415,57 @@ impl PostgresRegistry { rows.iter().map(map_membership_record).collect() } + pub async fn update_membership_role( + &self, + workspace_id: &WorkspaceId, + user_id: &UserId, + role: MembershipRole, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "update memberships + set role = $3 + where workspace_id = $1 and user_id = $2", + ) + .bind(workspace_id.as_str()) + .bind(user_id.as_str()) + .bind(serialize_enum_text(&role, "role")?) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::MembershipNotFound { + workspace_id: workspace_id.as_str().to_owned(), + user_id: user_id.as_str().to_owned(), + }); + } + + Ok(()) + } + + pub async fn delete_membership( + &self, + workspace_id: &WorkspaceId, + user_id: &UserId, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "delete from memberships + where workspace_id = $1 and user_id = $2", + ) + .bind(workspace_id.as_str()) + .bind(user_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::MembershipNotFound { + workspace_id: workspace_id.as_str().to_owned(), + user_id: user_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn list_invitations( &self, workspace_id: &WorkspaceId, @@ -493,6 +544,21 @@ impl PostgresRegistry { Ok(()) } + pub async fn delete_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), RegistryError> { + let result = sqlx::query("delete from workspaces where id = $1") + .bind(workspace_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::WorkspaceNotFound { + workspace_id: workspace_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn list_platform_api_keys( &self, workspace_id: &WorkspaceId, diff --git a/docs/admin-api.md b/docs/admin-api.md index 113a638..09d59c8 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -51,15 +51,23 @@ - `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}` - `GET /api/admin/workspaces/{workspace_id}/invitations` - `POST /api/admin/workspaces/{workspace_id}/invitations` - `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}` +- `GET /api/admin/workspaces/{workspace_id}/export` Контракт: - `POST /invitations` возвращает metadata invitation и одноразовый `invite_token`; -- `invite_token` доступен только в create-response и не возвращается повторно в list endpoints. +- `invite_token` доступен только в create-response и не возвращается повторно в list endpoints; +- `PATCH /members/{user_id}` меняет роль участника; +- `DELETE /members/{user_id}` удаляет участника из workspace; +- `GET /export` возвращает JSON snapshot workspace lifecycle-данных; +- `DELETE /workspaces/{workspace_id}` разрешен только `owner`. ### 5.2. Auth and session diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 0105d2a..61e775c 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -302,27 +302,31 @@ UI-файлы: - `GET /api/admin/workspaces/{workspace_id}` - `PATCH /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}` - `GET /api/admin/workspaces/{workspace_id}/invitations` - `POST /api/admin/workspaces/{workspace_id}/invitations` - `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}` +- `GET /api/admin/workspaces/{workspace_id}/export` +- `DELETE /api/admin/workspaces/{workspace_id}` Что еще не хватает: - `switch current workspace` все еще живет на клиенте, а не в session/backend; -- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать; -- delete/export workspace lifecycle пока не реализован на backend +- session-aware current workspace model; +- finer-grained permission matrix beyond current `owner/admin` management rules. Отдельный конфликт: - current workspace по-прежнему client-side; -- memberships и invitations уже live, но role-management пока read-only; +- memberships, role-management и invitations уже live; - `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта Простой итог: - `workspace-setup` уже подключен к live backend; -- create/edit workspace, refresh списка workspace и invitations работают; -- role management и workspace deletion остаются отдельным следующим этапом. +- create/edit workspace, memberships, invitations, export и delete работают; +- текущий workspace все еще остается client-side моделью. ### 4.8. Settings