feat: connect workspace access lifecycle to admin api
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -124,6 +124,12 @@ impl From<RegistryError> 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"))
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateMembershipPayload>,
|
||||
) -> Result<Json<Value>, 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<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -127,3 +168,26 @@ pub async fn delete_platform_api_key(
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn export_workspace(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, 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<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_workspace(&path.workspace_id.as_str().into(), &session.user.id)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -219,6 +219,11 @@ pub struct InvitationPayload {
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[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<MembershipRecord>,
|
||||
pub invitations: Vec<Value>,
|
||||
pub operations: Vec<OperationSummaryView>,
|
||||
pub agents: Vec<AgentSummaryView>,
|
||||
pub platform_api_keys: Vec<PlatformApiKeyRecord>,
|
||||
pub exported_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LogsQuery {
|
||||
pub level: Option<InvocationLevel>,
|
||||
@@ -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<Vec<MembershipRecord>, 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<WorkspaceExportResponse, ApiError> {
|
||||
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,
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title">Export all data</div>
|
||||
<div class="danger-zone-desc">Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.</div>
|
||||
<div class="danger-zone-desc">Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.</div>
|
||||
</div>
|
||||
<button class="btn-danger" id="export-workspace-btn" type="button">Export</button>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
+104
-13
@@ -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 =
|
||||
'<div class="member-avatar" style="background:linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1);">' +
|
||||
membership.user.display_name.slice(0, 2).toUpperCase() +
|
||||
'</div>' +
|
||||
'<div class="member-info">' +
|
||||
'<div class="member-name">' + membership.user.display_name + '</div>' +
|
||||
'<div class="member-name">' + membership.user.display_name +
|
||||
(isCurrentUser ? ' <span style="font-size:11px;font-weight:400;color:var(--text-muted);">(you)</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="member-email">' + membership.user.email + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="member-last-active">—</div>' +
|
||||
'<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>';
|
||||
(
|
||||
canManage
|
||||
? '<select class="member-role-select" data-previous="' + membership.role + '" onchange="updateRole(this, \'' + membership.user.id + '\')">' +
|
||||
'<option value="owner"' + (membership.role === 'owner' ? ' selected' : '') + '>Owner</option>' +
|
||||
'<option value="admin"' + (membership.role === 'admin' ? ' selected' : '') + '>Admin</option>' +
|
||||
'<option value="operator"' + (membership.role === 'operator' ? ' selected' : '') + '>Developer</option>' +
|
||||
'<option value="viewer"' + (membership.role === 'viewer' ? ' selected' : '') + '>Viewer</option>' +
|
||||
'</select>' +
|
||||
'<button class="member-remove-btn" type="button" onclick="removeMember(\'' + membership.user.id + '\', \'' + membership.user.display_name.replace(/'/g, "\\'") + '\')" title="Remove member">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>' +
|
||||
'</button>'
|
||||
: '<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>'
|
||||
);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user