Limit community to single-user workspace
This commit is contained in:
+64
-125
@@ -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::<Value>()
|
||||
.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::<Value>()
|
||||
.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::<Value>()
|
||||
.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")]
|
||||
|
||||
@@ -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<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, 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<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<UpdateMembershipPayload>,
|
||||
) -> Result<Json<Value>, 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<WorkspaceMembershipPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, 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<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<InvitationPayload>,
|
||||
) -> Result<Json<Value>, 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<WorkspaceInvitationPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -195,93 +22,3 @@ pub async fn export_workspace(
|
||||
.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> {
|
||||
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}")))
|
||||
}
|
||||
|
||||
+11
-396
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<MembershipRecord>,
|
||||
pub invitations: Vec<Value>,
|
||||
pub operations: Vec<OperationSummaryView>,
|
||||
pub agents: Vec<AgentSummaryView>,
|
||||
pub platform_api_keys: Vec<PlatformApiKeyRecord>,
|
||||
@@ -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<Vec<MembershipRecord>, 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<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_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<Vec<InvitationRecord>, 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<CreatedInvitationResponse, ApiError> {
|
||||
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<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.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<WorkspaceRecord, ApiError> {
|
||||
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<crank_core::UserId, ApiError> {
|
||||
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<String, ApiError> {
|
||||
.map_err(|error| ApiError::internal(error.to_string()))
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: &str) -> Result<OffsetDateTime, ApiError> {
|
||||
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, ApiError> {
|
||||
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",
|
||||
|
||||
@@ -32,12 +32,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,12 +49,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="login-box">
|
||||
<div class="login-heading" data-i18n="login.title">Sign in</div>
|
||||
<div class="login-sub" data-i18n="login.subtitle">Welcome back to your workspace</div>
|
||||
<div class="login-note" data-i18n="login.coming_soon">Password reset and SSO will be added later.</div>
|
||||
<div class="login-note" data-i18n="login.password_only">Sign in with email and password.</div>
|
||||
|
||||
<div class="login-error" id="login-error" data-i18n="login.error.invalid">
|
||||
Invalid email or password. Please try again.
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,12 +63,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,12 +37,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<div class="ws-setup-header">
|
||||
<div class="ws-setup-title" id="page-title" data-i18n="workspace_setup.title">Workspace settings</div>
|
||||
<div class="ws-setup-subtitle" id="page-subtitle" data-i18n="workspace_setup.page_subtitle">Configure your workspace identity and team members.</div>
|
||||
<div class="ws-setup-subtitle" id="page-subtitle" data-i18n="workspace_setup.page_subtitle">Configure the identity of your self-hosted workspace.</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Identity ── -->
|
||||
@@ -79,164 +79,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Members (edit mode only) ── -->
|
||||
<div id="section-members" class="ws-form-section" hidden style="padding:0; overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;">
|
||||
<div>
|
||||
<div class="ws-form-section-title" style="margin-bottom:2px;" data-i18n="workspace_setup.members.title">Team members</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);" id="members-count-label">4 members</div>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="toggleInviteForm()">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="margin-right:4px;"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="workspace_setup.members.invite">Invite member</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Invite form -->
|
||||
<div id="invite-form" hidden style="padding: 0 24px 16px; border-bottom: 1px solid var(--border-subtle);">
|
||||
<div style="display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;">
|
||||
<div class="form-group" style="flex:1; min-width:200px; margin-bottom:0;">
|
||||
<label class="form-label" data-i18n="workspace_setup.members.email">Email address</label>
|
||||
<input class="form-input" id="invite-email" type="email" placeholder="colleague@company.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="width:140px; margin-bottom:0;">
|
||||
<label class="form-label" data-i18n="workspace_setup.members.role">Role</label>
|
||||
<select class="form-input" id="invite-role">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="sendInvite()" data-i18n="workspace_setup.members.send_invite">Send invite</button>
|
||||
<button class="btn-ghost-sm" type="button" style="padding:7px 12px;" onclick="toggleInviteForm()" data-i18n="workspace_setup.members.cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="invite-success" hidden style="margin-top:10px; font-size:13px; color: #3fb950;" data-i18n="workspace_setup.members.invite_sent">✓ Invite sent.</div>
|
||||
</div>
|
||||
|
||||
<!-- Members list -->
|
||||
<div style="padding: 4px 24px 8px;" id="members-list">
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#0d9488,#6366f1);">AT</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Crank <span style="font-size:11px;font-weight:400;color:var(--text-muted);" data-i18n="workspace_setup.members.you">(you)</span></div>
|
||||
<div class="member-email">operator@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">Today</div>
|
||||
<span class="member-role-badge role-owner" data-i18n="workspace_setup.role.owner">Owner</span>
|
||||
</div>
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#7c3aed,#4f46e5);">BS</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Blake Smith</div>
|
||||
<div class="member-email">blake@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">2 days ago</div>
|
||||
<select class="member-role-select" onchange="updateRole(this, 'blake@acme.com')">
|
||||
<option value="admin" selected data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="member-remove-btn" onclick="removeMember(this, 'Blake Smith')" data-i18n-title="workspace_setup.members.remove" 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>
|
||||
</div>
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#d29922,#f59e0b);">CL</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Carol Lane</div>
|
||||
<div class="member-email">carol@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">1 week ago</div>
|
||||
<select class="member-role-select" onchange="updateRole(this, 'carol@acme.com')">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="member-remove-btn" onclick="removeMember(this, 'Carol Lane')" data-i18n-title="workspace_setup.members.remove" 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>
|
||||
</div>
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#0891b2,#06b6d4);">DM</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Dan Morgan</div>
|
||||
<div class="member-email">dan@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">Never</div>
|
||||
<select class="member-role-select" onchange="updateRole(this, 'dan@acme.com')">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" selected data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="member-remove-btn" onclick="removeMember(this, 'Dan Morgan')" data-i18n-title="workspace_setup.members.remove" 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending invites -->
|
||||
<div id="pending-invites" hidden style="border-top: 1px solid var(--border-subtle); padding: 12px 24px 16px;">
|
||||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">
|
||||
<span data-i18n="workspace_setup.members.pending">Pending invites</span> <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">1</span>
|
||||
</div>
|
||||
<div class="member-row" id="invite-john">
|
||||
<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">john@startup.com</div>
|
||||
<div class="member-email">Invited 3 days ago · not yet accepted</div>
|
||||
</div>
|
||||
<div class="member-last-active">—</div>
|
||||
<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);" data-i18n="workspace_setup.role.operator">Developer</span>
|
||||
<button class="member-remove-btn" onclick="revokeInvite(this)" data-i18n-title="workspace_setup.members.revoke" title="Revoke invite">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles reference -->
|
||||
<div style="border-top:1px solid var(--border-subtle);padding:16px 24px 20px;">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:14px;" data-i18n="workspace_setup.roles.title">Roles & permissions</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-owner" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.owner">Owner</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.owner_body">Full workspace control, billing access, can transfer or delete the workspace.</div>
|
||||
</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-admin" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.admin">Admin</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.admin_body">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
|
||||
</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-developer" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.operator">Developer</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.developer_body">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
|
||||
</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-viewer" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.viewer">Viewer</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.viewer_body">Read-only access to operations, agents, and logs.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Invite team members (create mode only) ── -->
|
||||
<div id="section-invite" class="ws-form-section" hidden>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
||||
<div class="ws-form-section-title" style="margin-bottom:0;"><span data-i18n="workspace_setup.invite.title">Invite team members</span> <span style="font-size:12px;font-weight:400;color:var(--text-muted);" data-i18n="workspace_setup.optional">(optional)</span></div>
|
||||
<button class="btn-ghost-sm" type="button" onclick="addInviteRow()" style="font-size:12px;padding:4px 10px;" data-i18n="workspace_setup.invite.add_person">+ Add person</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom:14px;" data-i18n="workspace_setup.invite.later">You can invite more people later from Workspace settings.</div>
|
||||
<div class="invite-rows" id="invite-rows">
|
||||
<div class="invite-row">
|
||||
<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">
|
||||
<select class="form-input" style="width:130px;margin-bottom:0;">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="invite-row-remove" onclick="removeInviteRow(this)" data-i18n-title="workspace_setup.members.remove" title="Remove">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ── -->
|
||||
<div class="ws-setup-actions">
|
||||
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a>
|
||||
@@ -244,7 +86,7 @@
|
||||
</div>
|
||||
|
||||
<div class="ws-setup-footer-note" id="footer-note" hidden>
|
||||
<span data-i18n="workspace_setup.create.footer">You'll be the Owner of this workspace. You can invite additional members after creation.</span>
|
||||
<span data-i18n="workspace_setup.create.footer">This Community installation uses one workspace.</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Danger zone (edit mode only) ── -->
|
||||
@@ -256,17 +98,10 @@
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title" data-i18n="workspace_setup.danger.export_title">Export all data</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.export_body">Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.export_body">Download a JSON snapshot of workspace settings, operations, agents, secrets, usage data and agent access keys.</div>
|
||||
</div>
|
||||
<button class="btn-danger" id="export-workspace-btn" type="button" data-i18n="workspace_setup.export">Export</button>
|
||||
</div>
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title" data-i18n="workspace_setup.danger.delete_title">Delete workspace</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.delete_body">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
|
||||
</div>
|
||||
<button class="btn-danger" id="delete-workspace-btn" type="button" data-i18n="workspace_setup.delete">Delete workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
+10
-116
@@ -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': 'Сейчас не удается войти. Попробуйте еще раз.',
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
limits: {
|
||||
max_workspaces: 1,
|
||||
max_users_per_workspace: 1,
|
||||
max_agents_per_workspace: 1,
|
||||
max_agents_per_workspace: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+18
-506
@@ -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');
|
||||
|
||||
+10
-23
@@ -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) {
|
||||
|
||||
@@ -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', 'Настройки аккаунта'));
|
||||
|
||||
Reference in New Issue
Block a user