Compare commits
6 Commits
9fa05d60b6
...
fa4a533d29
| Author | SHA1 | Date |
|---|---|---|
|
|
fa4a533d29 | |
|
|
882b409d1c | |
|
|
3afa15d326 | |
|
|
a5a347e839 | |
|
|
8ec762f790 | |
|
|
e009915e34 |
|
|
@ -72,8 +72,11 @@ x-app-env: &app-env
|
|||
PLANE_NODEDC_ACCESS_CACHE_SECONDS: ${PLANE_NODEDC_ACCESS_CACHE_SECONDS:-0}
|
||||
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL: ${PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL:-http://launcher.local.nodedc/}
|
||||
PLANE_NODEDC_GLOBAL_LOGOUT_URL: ${PLANE_NODEDC_GLOBAL_LOGOUT_URL:-http://launcher.local.nodedc/auth/logout?global=1&returnTo=/}
|
||||
PLANE_NODEDC_LAUNCHER_PUBLIC_URL: ${PLANE_NODEDC_LAUNCHER_PUBLIC_URL:-http://launcher.local.nodedc}
|
||||
PLANE_NODEDC_HANDOFF_URL: ${PLANE_NODEDC_HANDOFF_URL:-http://launcher.local.nodedc/api/internal/handoff/consume}
|
||||
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3}
|
||||
PLANE_NODEDC_WORKSPACE_POLICY_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check}
|
||||
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS: ${PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS:-3}
|
||||
GUNICORN_WORKERS: 1
|
||||
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
||||
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
||||
|
|
|
|||
|
|
@ -111,5 +111,8 @@ PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
|
|||
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
|
||||
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/
|
||||
PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/
|
||||
PLANE_NODEDC_LAUNCHER_PUBLIC_URL=http://launcher.local.nodedc
|
||||
PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consume
|
||||
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
|
||||
PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check
|
||||
PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS=3
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.urls import path
|
|||
|
||||
from plane.app.views import (
|
||||
UserWorkspaceInvitationsViewSet,
|
||||
NodeDCWorkspaceCreationPolicyEndpoint,
|
||||
WorkSpaceViewSet,
|
||||
WorkspaceJoinEndpoint,
|
||||
WorkSpaceMemberViewSet,
|
||||
|
|
@ -49,6 +50,11 @@ urlpatterns = [
|
|||
WorkSpaceAvailabilityCheckEndpoint.as_view(),
|
||||
name="workspace-availability",
|
||||
),
|
||||
path(
|
||||
"nodedc/workspace-policy/",
|
||||
NodeDCWorkspaceCreationPolicyEndpoint.as_view(),
|
||||
name="nodedc-workspace-policy",
|
||||
),
|
||||
path(
|
||||
"workspaces/",
|
||||
WorkSpaceViewSet.as_view({"get": "list", "post": "create"}),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from .base import BaseAPIView, BaseViewSet
|
|||
from .workspace.base import (
|
||||
WorkSpaceViewSet,
|
||||
UserWorkSpacesEndpoint,
|
||||
NodeDCWorkspaceCreationPolicyEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from plane.app.permissions import (
|
|||
# Module imports
|
||||
from plane.app.serializers import WorkSpaceSerializer, WorkspaceThemeSerializer
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.authentication.nodedc_workspace_policy import get_nodedc_workspace_creation_policy
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
|
|
@ -83,6 +84,17 @@ class WorkSpaceViewSet(BaseViewSet):
|
|||
|
||||
def create(self, request):
|
||||
try:
|
||||
workspace_policy = get_nodedc_workspace_creation_policy(request.user)
|
||||
if not workspace_policy["can_create_workspace"]:
|
||||
return Response(
|
||||
{
|
||||
"error": "nodedc_workspace_creation_denied",
|
||||
"reason": workspace_policy["reason"],
|
||||
"workspace_policy": workspace_policy,
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
|
|
@ -243,6 +255,11 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
|||
return Response(workspaces, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class NodeDCWorkspaceCreationPolicyEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
return Response(get_nodedc_workspace_creation_policy(request.user), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
slug = request.GET.get("slug", False)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import os
|
||||
|
||||
import requests
|
||||
|
||||
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
|
||||
from plane.db.models import ExternalIdentityLink
|
||||
|
||||
|
||||
OIDC_PROVIDER = "authentik"
|
||||
|
||||
|
||||
def get_nodedc_workspace_creation_policy(user):
|
||||
check_url = (
|
||||
os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_URL", "").strip()
|
||||
or os.environ.get("PLANE_NODEDC_ACCESS_CHECK_URL", "").strip()
|
||||
)
|
||||
token = get_nodedc_internal_token()
|
||||
|
||||
if not check_url or not token:
|
||||
return {
|
||||
"enabled": False,
|
||||
"can_create_workspace": True,
|
||||
"mode": "standalone",
|
||||
"reason": "NODE.DC workspace policy is not configured.",
|
||||
}
|
||||
|
||||
link = ExternalIdentityLink.objects.filter(
|
||||
provider=OIDC_PROVIDER,
|
||||
user=user,
|
||||
status=ExternalIdentityLink.Status.ACTIVE,
|
||||
).first()
|
||||
|
||||
if link is None:
|
||||
enforce_unlinked = is_truthy(os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_ENFORCE_UNLINKED", "0"))
|
||||
return {
|
||||
"enabled": True,
|
||||
"can_create_workspace": not enforce_unlinked,
|
||||
"mode": "unlinked",
|
||||
"reason": "NODE.DC identity is not linked." if enforce_unlinked else "Standalone user without NODE.DC identity.",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
check_url,
|
||||
json={
|
||||
"serviceSlug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager"),
|
||||
"subject": link.subject,
|
||||
"email": link.email or user.email,
|
||||
"userId": None,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=float(os.environ.get("PLANE_NODEDC_WORKSPACE_POLICY_TIMEOUT_SECONDS", "3") or "3"),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except (ValueError, requests.RequestException):
|
||||
return {
|
||||
"enabled": True,
|
||||
"can_create_workspace": False,
|
||||
"mode": "unavailable",
|
||||
"reason": "NODE.DC workspace policy is unavailable.",
|
||||
}
|
||||
|
||||
workspace_policy = payload.get("workspacePolicy") if isinstance(payload.get("workspacePolicy"), dict) else {}
|
||||
access_allowed = bool(payload.get("allowed"))
|
||||
if not workspace_policy:
|
||||
return {
|
||||
"enabled": True,
|
||||
"can_create_workspace": access_allowed,
|
||||
"mode": "legacy_access_check",
|
||||
"reason": payload.get("reason") or "NODE.DC access check does not expose workspace policy.",
|
||||
}
|
||||
|
||||
can_create_workspace = access_allowed and bool(workspace_policy.get("canCreateWorkspace"))
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"can_create_workspace": can_create_workspace,
|
||||
"mode": workspace_policy.get("mode") or "unknown",
|
||||
"reason": workspace_policy.get("reason") or payload.get("reason") or "NODE.DC workspace policy decision.",
|
||||
}
|
||||
|
||||
|
||||
def is_truthy(value):
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
|
@ -4,6 +4,7 @@ import logging
|
|||
import os
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
|
|
@ -363,7 +364,7 @@ def get_or_create_oidc_user(email, claims):
|
|||
display_name = first_string_claim(claims, "name", "preferred_username") or User.get_display_name(email)
|
||||
given_name = first_string_claim(claims, "given_name") or ""
|
||||
family_name = first_string_claim(claims, "family_name") or ""
|
||||
avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar") or ""
|
||||
avatar_url = normalize_nodedc_avatar_url(first_string_claim(claims, "picture", "avatar_url", "avatar") or "")
|
||||
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
|
|
@ -437,7 +438,7 @@ def sync_user_profile_from_claims(user, claims):
|
|||
display_name = first_string_claim(claims, "name", "preferred_username")
|
||||
given_name = first_string_claim(claims, "given_name")
|
||||
family_name = first_string_claim(claims, "family_name")
|
||||
avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar")
|
||||
avatar_url = normalize_nodedc_avatar_url(first_string_claim(claims, "picture", "avatar_url", "avatar"))
|
||||
|
||||
if display_name and user.display_name != display_name:
|
||||
user.display_name = display_name
|
||||
|
|
@ -463,6 +464,45 @@ def sync_user_profile_from_claims(user, claims):
|
|||
return updated_fields
|
||||
|
||||
|
||||
def normalize_nodedc_avatar_url(value):
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
|
||||
avatar_url = value.strip()
|
||||
if not avatar_url:
|
||||
return ""
|
||||
|
||||
if avatar_url.startswith(("http://", "https://", "data:")):
|
||||
return avatar_url
|
||||
|
||||
if avatar_url.startswith(("/storage/", "/uploads/")):
|
||||
launcher_origin = resolve_nodedc_launcher_origin()
|
||||
return f"{launcher_origin}{avatar_url}" if launcher_origin else avatar_url
|
||||
|
||||
return avatar_url
|
||||
|
||||
|
||||
def resolve_nodedc_launcher_origin():
|
||||
explicit_origin = os.environ.get("PLANE_NODEDC_LAUNCHER_PUBLIC_URL", "").strip()
|
||||
if explicit_origin:
|
||||
return explicit_origin.rstrip("/")
|
||||
|
||||
for env_key in [
|
||||
"PLANE_NODEDC_HANDOFF_URL",
|
||||
"PLANE_NODEDC_ACCESS_CHECK_URL",
|
||||
"PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL",
|
||||
"PLANE_NODEDC_GLOBAL_LOGOUT_URL",
|
||||
]:
|
||||
configured_url = os.environ.get(env_key, "").strip()
|
||||
if not configured_url:
|
||||
continue
|
||||
parsed_url = urlparse(configured_url)
|
||||
if parsed_url.scheme and parsed_url.netloc:
|
||||
return f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def first_string_claim(claims, *keys):
|
||||
for key in keys:
|
||||
value = claims.get(key)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,550 @@
|
|||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
|
||||
from plane.db.models import ExternalIdentityLink, Profile, Project, ProjectMember, User, Workspace, WorkspaceMember
|
||||
|
||||
|
||||
OIDC_PROVIDER = "authentik"
|
||||
ADMIN_ROLE = 20
|
||||
ROLE_VALUES = {
|
||||
"guest": 5,
|
||||
"viewer": 5,
|
||||
"member": 15,
|
||||
"admin": ADMIN_ROLE,
|
||||
"owner": ADMIN_ROLE,
|
||||
5: 5,
|
||||
15: 15,
|
||||
ADMIN_ROLE: ADMIN_ROLE,
|
||||
}
|
||||
PROJECT_ROLE_LABELS = {
|
||||
5: "guest",
|
||||
15: "member",
|
||||
ADMIN_ROLE: "admin",
|
||||
}
|
||||
|
||||
|
||||
def internal_unauthorized_response():
|
||||
return JsonResponse({"ok": False, "error": "internal_access_unauthorized"}, status=401)
|
||||
|
||||
|
||||
def parse_json_body(request):
|
||||
if not request.body:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(request.body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_email(value):
|
||||
return value.strip().lower() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def resolve_workspace(payload):
|
||||
workspace_id = payload.get("workspaceId") or payload.get("workspace_id")
|
||||
workspace_slug = payload.get("workspaceSlug") or payload.get("workspace_slug") or payload.get("slug")
|
||||
|
||||
queryset = Workspace.objects.filter(deleted_at__isnull=True)
|
||||
if workspace_id:
|
||||
return queryset.filter(id=workspace_id).first()
|
||||
if workspace_slug:
|
||||
return queryset.filter(slug=workspace_slug).first()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_project(payload, workspace=None):
|
||||
project_id = payload.get("projectId") or payload.get("project_id")
|
||||
project_identifier = payload.get("projectIdentifier") or payload.get("project_identifier") or payload.get("identifier")
|
||||
|
||||
queryset = Project.objects.filter(deleted_at__isnull=True).select_related("workspace")
|
||||
if workspace is not None:
|
||||
queryset = queryset.filter(workspace=workspace)
|
||||
|
||||
if project_id:
|
||||
return queryset.filter(id=project_id).first()
|
||||
if project_identifier and workspace is not None:
|
||||
return queryset.filter(identifier__iexact=project_identifier).first()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_user(payload):
|
||||
plane_user_id = payload.get("planeUserId") or payload.get("plane_user_id")
|
||||
subject = payload.get("subject")
|
||||
email = normalize_email(payload.get("email"))
|
||||
|
||||
if plane_user_id:
|
||||
user = User.objects.filter(id=plane_user_id, is_bot=False).first()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if subject:
|
||||
link = ExternalIdentityLink.objects.filter(
|
||||
provider=OIDC_PROVIDER,
|
||||
subject=subject,
|
||||
status=ExternalIdentityLink.Status.ACTIVE,
|
||||
).select_related("user").first()
|
||||
if link:
|
||||
return link.user
|
||||
|
||||
if email:
|
||||
link = ExternalIdentityLink.objects.filter(
|
||||
provider=OIDC_PROVIDER,
|
||||
email__iexact=email,
|
||||
status=ExternalIdentityLink.Status.ACTIVE,
|
||||
).select_related("user").first()
|
||||
if link:
|
||||
return link.user
|
||||
return User.objects.filter(email__iexact=email, is_bot=False).first()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def normalize_role(value):
|
||||
return ROLE_VALUES.get(value, 15)
|
||||
|
||||
|
||||
def project_role_slug(value):
|
||||
return PROJECT_ROLE_LABELS.get(value, "member")
|
||||
|
||||
|
||||
def first_payload_string(payload, *keys):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_nodedc_avatar_url(value):
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
|
||||
avatar_url = value.strip()
|
||||
if not avatar_url:
|
||||
return ""
|
||||
|
||||
if avatar_url.startswith(("http://", "https://", "data:")):
|
||||
return avatar_url
|
||||
|
||||
if avatar_url.startswith(("/storage/", "/uploads/")):
|
||||
launcher_origin = resolve_nodedc_launcher_origin()
|
||||
return f"{launcher_origin}{avatar_url}" if launcher_origin else avatar_url
|
||||
|
||||
return avatar_url
|
||||
|
||||
|
||||
def resolve_nodedc_launcher_origin():
|
||||
explicit_origin = os.environ.get("PLANE_NODEDC_LAUNCHER_PUBLIC_URL", "").strip()
|
||||
if explicit_origin:
|
||||
return explicit_origin.rstrip("/")
|
||||
|
||||
for env_key in [
|
||||
"PLANE_NODEDC_HANDOFF_URL",
|
||||
"PLANE_NODEDC_ACCESS_CHECK_URL",
|
||||
"PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL",
|
||||
"PLANE_NODEDC_GLOBAL_LOGOUT_URL",
|
||||
]:
|
||||
configured_url = os.environ.get(env_key, "").strip()
|
||||
if not configured_url:
|
||||
continue
|
||||
parsed_url = urlparse(configured_url)
|
||||
if parsed_url.scheme and parsed_url.netloc:
|
||||
return f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def sync_user_avatar_from_payload(user, payload):
|
||||
avatar_url = normalize_nodedc_avatar_url(first_payload_string(payload, "avatarUrl", "avatar_url", "avatar"))
|
||||
|
||||
if avatar_url and user.avatar != avatar_url:
|
||||
user.avatar = avatar_url
|
||||
user.save(update_fields=["avatar", "updated_at"])
|
||||
|
||||
|
||||
def serialize_project(project):
|
||||
return {
|
||||
"id": str(project.id),
|
||||
"workspaceSlug": project.workspace.slug,
|
||||
"name": project.name,
|
||||
"identifier": project.identifier,
|
||||
"memberCount": ProjectMember.objects.filter(
|
||||
project=project,
|
||||
deleted_at__isnull=True,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
).count(),
|
||||
}
|
||||
|
||||
|
||||
def serialize_workspace(workspace, projects=None):
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"slug": workspace.slug,
|
||||
"name": workspace.name,
|
||||
"ownerEmail": workspace.owner.email if workspace.owner_id else None,
|
||||
"memberCount": WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
deleted_at__isnull=True,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
).count(),
|
||||
"projects": [serialize_project(project) for project in projects] if projects is not None else [],
|
||||
}
|
||||
|
||||
|
||||
def serialize_membership(membership, created):
|
||||
return {
|
||||
"created": created,
|
||||
"workspace": serialize_workspace(membership.workspace),
|
||||
"member": {
|
||||
"id": str(membership.member.id),
|
||||
"email": membership.member.email,
|
||||
"displayName": membership.member.display_name,
|
||||
},
|
||||
"role": membership.role,
|
||||
"isActive": membership.is_active,
|
||||
"isBanned": membership.is_banned,
|
||||
}
|
||||
|
||||
|
||||
def serialize_project_membership(project_member, created):
|
||||
return {
|
||||
"created": created,
|
||||
"workspace": serialize_workspace(project_member.workspace),
|
||||
"project": serialize_project(project_member.project),
|
||||
"member": {
|
||||
"id": str(project_member.member.id),
|
||||
"email": project_member.member.email,
|
||||
"displayName": project_member.member.display_name,
|
||||
},
|
||||
"role": project_member.role,
|
||||
"roleSlug": project_role_slug(project_member.role),
|
||||
"isActive": project_member.is_active,
|
||||
}
|
||||
|
||||
|
||||
def restore_admin_project_memberships(workspace, user):
|
||||
restored = 0
|
||||
for project_member in ProjectMember.objects.filter(
|
||||
project__workspace=workspace,
|
||||
member=user,
|
||||
deleted_at__isnull=True,
|
||||
):
|
||||
update_fields = []
|
||||
if project_member.role != ADMIN_ROLE:
|
||||
project_member.role = ADMIN_ROLE
|
||||
update_fields.append("role")
|
||||
if not project_member.is_active:
|
||||
project_member.is_active = True
|
||||
update_fields.append("is_active")
|
||||
if update_fields:
|
||||
update_fields.append("updated_at")
|
||||
project_member.save(update_fields=update_fields)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NodeDCInternalWorkspaceListEndpoint(View):
|
||||
def get(self, request):
|
||||
if not is_internal_logout_request_authorized(request):
|
||||
return internal_unauthorized_response()
|
||||
|
||||
workspaces = list(Workspace.objects.filter(deleted_at__isnull=True).select_related("owner").order_by("name"))
|
||||
projects_by_workspace = {workspace.id: [] for workspace in workspaces}
|
||||
for project in Project.objects.filter(
|
||||
workspace__in=workspaces,
|
||||
deleted_at__isnull=True,
|
||||
).select_related("workspace").order_by("workspace_id", "name"):
|
||||
projects_by_workspace.setdefault(project.workspace_id, []).append(project)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"workspaces": [
|
||||
serialize_workspace(workspace, projects_by_workspace.get(workspace.id, [])) for workspace in workspaces
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
|
||||
def post(self, request):
|
||||
if not is_internal_logout_request_authorized(request):
|
||||
return internal_unauthorized_response()
|
||||
|
||||
payload = parse_json_body(request)
|
||||
if payload is None:
|
||||
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
workspace = resolve_workspace(payload)
|
||||
if workspace is None:
|
||||
return JsonResponse({"ok": False, "error": "workspace_not_found"}, status=404)
|
||||
|
||||
user = resolve_user(payload)
|
||||
if user is None:
|
||||
return JsonResponse({"ok": False, "error": "user_not_found"}, status=404)
|
||||
|
||||
role = normalize_role(payload.get("role"))
|
||||
company_role = payload.get("companyRole") or payload.get("company_role")
|
||||
set_last_workspace = payload.get("setLastWorkspace", True) is not False
|
||||
|
||||
with transaction.atomic():
|
||||
sync_user_avatar_from_payload(user, payload)
|
||||
|
||||
membership = WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
created = membership is None
|
||||
|
||||
if membership is None:
|
||||
membership = WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
role=role,
|
||||
company_role=company_role if isinstance(company_role, str) else None,
|
||||
is_active=True,
|
||||
is_banned=False,
|
||||
)
|
||||
else:
|
||||
membership.role = role
|
||||
if isinstance(company_role, str):
|
||||
membership.company_role = company_role
|
||||
membership.is_active = True
|
||||
membership.is_banned = False
|
||||
membership.banned_at = None
|
||||
membership.banned_until = None
|
||||
membership.save(update_fields=["role", "company_role", "is_active", "is_banned", "banned_at", "banned_until", "updated_at"])
|
||||
|
||||
if role == ADMIN_ROLE:
|
||||
restore_admin_project_memberships(workspace, user)
|
||||
|
||||
if set_last_workspace:
|
||||
profile, _ = Profile.objects.get_or_create(user=user)
|
||||
profile.last_workspace_id = workspace.id
|
||||
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
||||
|
||||
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)})
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
|
||||
def post(self, request):
|
||||
if not is_internal_logout_request_authorized(request):
|
||||
return internal_unauthorized_response()
|
||||
|
||||
payload = parse_json_body(request)
|
||||
if payload is None:
|
||||
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
workspace = resolve_workspace(payload)
|
||||
if workspace is None:
|
||||
return JsonResponse({"ok": False, "error": "workspace_not_found"}, status=404)
|
||||
|
||||
user = resolve_user(payload)
|
||||
if user is None:
|
||||
return JsonResponse({"ok": False, "error": "user_not_found"}, status=404)
|
||||
|
||||
membership = WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
|
||||
if membership is None or not membership.is_active:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"removed": False,
|
||||
"workspace": serialize_workspace(workspace),
|
||||
"member": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"displayName": user.display_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
ProjectMember.objects.filter(
|
||||
project__workspace=workspace,
|
||||
member=user,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
membership.is_active = False
|
||||
membership.save(update_fields=["is_active", "updated_at"])
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"removed": True,
|
||||
"workspace": serialize_workspace(workspace),
|
||||
"member": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"displayName": user.display_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NodeDCInternalProjectMembershipEnsureEndpoint(View):
|
||||
def post(self, request):
|
||||
if not is_internal_logout_request_authorized(request):
|
||||
return internal_unauthorized_response()
|
||||
|
||||
payload = parse_json_body(request)
|
||||
if payload is None:
|
||||
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
workspace = resolve_workspace(payload)
|
||||
project = resolve_project(payload, workspace)
|
||||
if project is None:
|
||||
return JsonResponse({"ok": False, "error": "project_not_found"}, status=404)
|
||||
if workspace is None:
|
||||
workspace = project.workspace
|
||||
if project.workspace_id != workspace.id:
|
||||
return JsonResponse({"ok": False, "error": "project_workspace_mismatch"}, status=400)
|
||||
|
||||
user = resolve_user(payload)
|
||||
if user is None:
|
||||
return JsonResponse({"ok": False, "error": "user_not_found"}, status=404)
|
||||
|
||||
role = normalize_role(payload.get("role"))
|
||||
fallback_workspace_role = 5 if role == 5 else 15
|
||||
|
||||
with transaction.atomic():
|
||||
sync_user_avatar_from_payload(user, payload)
|
||||
|
||||
workspace_membership = WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
if workspace_membership is None:
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
role=fallback_workspace_role,
|
||||
company_role=None,
|
||||
is_active=True,
|
||||
is_banned=False,
|
||||
)
|
||||
else:
|
||||
update_fields = []
|
||||
if not workspace_membership.is_active:
|
||||
workspace_membership.is_active = True
|
||||
update_fields.append("is_active")
|
||||
if workspace_membership.is_banned:
|
||||
workspace_membership.is_banned = False
|
||||
workspace_membership.banned_at = None
|
||||
workspace_membership.banned_until = None
|
||||
update_fields.extend(["is_banned", "banned_at", "banned_until"])
|
||||
if workspace_membership.role < fallback_workspace_role:
|
||||
workspace_membership.role = fallback_workspace_role
|
||||
update_fields.append("role")
|
||||
if update_fields:
|
||||
update_fields.append("updated_at")
|
||||
workspace_membership.save(update_fields=update_fields)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
created = project_member is None
|
||||
if project_member is None:
|
||||
project_member = ProjectMember.objects.create(
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
else:
|
||||
project_member.role = role
|
||||
project_member.is_active = True
|
||||
project_member.save(update_fields=["role", "is_active", "updated_at"])
|
||||
|
||||
if payload.get("setLastWorkspace", False) is True:
|
||||
profile, _ = Profile.objects.get_or_create(user=user)
|
||||
profile.last_workspace_id = workspace.id
|
||||
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
||||
|
||||
return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)})
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NodeDCInternalProjectMembershipRemoveEndpoint(View):
|
||||
def post(self, request):
|
||||
if not is_internal_logout_request_authorized(request):
|
||||
return internal_unauthorized_response()
|
||||
|
||||
payload = parse_json_body(request)
|
||||
if payload is None:
|
||||
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
workspace = resolve_workspace(payload)
|
||||
project = resolve_project(payload, workspace)
|
||||
if project is None:
|
||||
return JsonResponse({"ok": False, "error": "project_not_found"}, status=404)
|
||||
if workspace is None:
|
||||
workspace = project.workspace
|
||||
if project.workspace_id != workspace.id:
|
||||
return JsonResponse({"ok": False, "error": "project_workspace_mismatch"}, status=400)
|
||||
|
||||
user = resolve_user(payload)
|
||||
if user is None:
|
||||
return JsonResponse({"ok": False, "error": "user_not_found"}, status=404)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
|
||||
if project_member is None or not project_member.is_active:
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"removed": False,
|
||||
"workspace": serialize_workspace(workspace),
|
||||
"project": serialize_project(project),
|
||||
"member": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"displayName": user.display_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
project_member.is_active = False
|
||||
project_member.save(update_fields=["is_active", "updated_at"])
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"removed": True,
|
||||
"workspace": serialize_workspace(workspace),
|
||||
"project": serialize_project(project),
|
||||
"member": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"displayName": user.display_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -15,6 +15,13 @@ from plane.authentication.views.nodedc_logout import (
|
|||
NodeDCFrontChannelLogoutEndpoint,
|
||||
NodeDCInternalSessionLogoutEndpoint,
|
||||
)
|
||||
from plane.authentication.views.nodedc_workspace_adapter import (
|
||||
NodeDCInternalProjectMembershipEnsureEndpoint,
|
||||
NodeDCInternalProjectMembershipRemoveEndpoint,
|
||||
NodeDCInternalWorkspaceListEndpoint,
|
||||
NodeDCInternalWorkspaceMembershipEnsureEndpoint,
|
||||
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
|
||||
)
|
||||
|
||||
handler404 = "plane.app.views.error_404.custom_404_view"
|
||||
|
||||
|
|
@ -24,6 +31,31 @@ urlpatterns = [
|
|||
NodeDCInternalSessionLogoutEndpoint.as_view(),
|
||||
name="nodedc-internal-session-logout",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspaces/",
|
||||
NodeDCInternalWorkspaceListEndpoint.as_view(),
|
||||
name="nodedc-internal-workspaces",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspace-memberships/ensure/",
|
||||
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),
|
||||
name="nodedc-internal-workspace-membership-ensure",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/workspace-memberships/remove/",
|
||||
NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(),
|
||||
name="nodedc-internal-workspace-membership-remove",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/project-memberships/ensure/",
|
||||
NodeDCInternalProjectMembershipEnsureEndpoint.as_view(),
|
||||
name="nodedc-internal-project-membership-ensure",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/project-memberships/remove/",
|
||||
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
|
||||
name="nodedc-internal-project-membership-remove",
|
||||
),
|
||||
path("api/", include("plane.app.urls")),
|
||||
path("api/public/", include("plane.space.urls")),
|
||||
path("api/instances/", include("plane.license.urls")),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
|
@ -21,6 +21,9 @@ import { useInstance } from "@/hooks/store/use-instance";
|
|||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// wrappers
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
import { WorkspaceService, type NodeDCWorkspacePolicy } from "@/services/workspace.service";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -36,8 +39,39 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
|||
slug: "",
|
||||
organization_size: "",
|
||||
});
|
||||
const [workspacePolicy, setWorkspacePolicy] = useState<NodeDCWorkspacePolicy | null>(null);
|
||||
const [workspacePolicyLoading, setWorkspacePolicyLoading] = useState(true);
|
||||
// derived values
|
||||
const isWorkspaceCreationDisabled = config?.is_workspace_creation_disabled ?? false;
|
||||
const isWorkspaceCreationDeniedByNodeDC = Boolean(workspacePolicy?.enabled && !workspacePolicy.can_create_workspace);
|
||||
const shouldBlockWorkspaceCreation = isWorkspaceCreationDisabled || isWorkspaceCreationDeniedByNodeDC;
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
workspaceService
|
||||
.getNodeDCWorkspacePolicy()
|
||||
.then((policy) => {
|
||||
if (mounted) setWorkspacePolicy(policy);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setWorkspacePolicy({
|
||||
enabled: false,
|
||||
can_create_workspace: true,
|
||||
mode: "unavailable",
|
||||
reason: "NODE.DC workspace policy is unavailable.",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setWorkspacePolicyLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// methods
|
||||
const getMailtoHref = () => {
|
||||
|
|
@ -60,7 +94,17 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
|||
<div className="relative z-10 flex min-h-screen w-screen flex-col overflow-hidden overflow-y-auto bg-canvas px-8 pt-10 pb-12">
|
||||
<AuthHeaderBase pageTitle={t("workspace_creation.heading")} />
|
||||
<main className="grid flex-1 place-items-center py-8">
|
||||
{isWorkspaceCreationDisabled ? (
|
||||
{workspacePolicyLoading ? (
|
||||
<section className="nodedc-auth-shell flex flex-col items-start justify-center gap-4">
|
||||
<div className="space-y-3">
|
||||
<h1 className="m-0 text-30 font-semibold leading-tight text-primary">Работайте во всех измерениях.</h1>
|
||||
<p className="m-0 text-28 font-semibold leading-tight text-secondary">Проверяем доступ к workspace.</p>
|
||||
</div>
|
||||
<p className="m-0 text-14 leading-6 text-secondary">
|
||||
Сверяем платформенную policy NODE.DC перед созданием рабочего пространства.
|
||||
</p>
|
||||
</section>
|
||||
) : shouldBlockWorkspaceCreation ? (
|
||||
<section className="nodedc-auth-shell flex flex-col items-center justify-center gap-4 text-center">
|
||||
<img
|
||||
src={WorkspaceCreationDisabled}
|
||||
|
|
@ -68,10 +112,14 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
|||
alt="Workspace creation disabled"
|
||||
/>
|
||||
<h1 className="m-0 text-24 font-semibold text-primary">
|
||||
{t("workspace_creation.errors.creation_disabled.title")}
|
||||
{isWorkspaceCreationDeniedByNodeDC
|
||||
? "Workspace создаёт администратор."
|
||||
: t("workspace_creation.errors.creation_disabled.title")}
|
||||
</h1>
|
||||
<p className="m-0 text-14 leading-6 text-secondary">
|
||||
{t("workspace_creation.errors.creation_disabled.description")}
|
||||
{isWorkspaceCreationDeniedByNodeDC
|
||||
? workspacePolicy?.reason || "Дождитесь назначения в рабочее пространство администратором NODE.DC."
|
||||
: t("workspace_creation.errors.creation_disabled.description")}
|
||||
</p>
|
||||
<div className="mt-6 flex w-full flex-col gap-3">
|
||||
<Button variant="primary" className="nodedc-auth-primary-button" onClick={() => router.back()}>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
|||
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
|
||||
const hasCheckerProgress = checkerBlocksTotal > 0;
|
||||
const assigneeIds = issue.assignee_ids ?? [];
|
||||
const visibleAssigneeIds = assigneeIds.slice(0, 3);
|
||||
const assigneeCount = assigneeIds.length;
|
||||
const assigneeStackWidthClass =
|
||||
assigneeCount > 3 ? "w-[68px]" : assigneeCount === 3 ? "w-[62px]" : assigneeCount === 2 ? "w-[44px]" : "w-7";
|
||||
const cornerControlClasses = cn(
|
||||
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none",
|
||||
isActive
|
||||
|
|
@ -252,17 +256,55 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
|||
disabled={isReadOnly || !updateIssue}
|
||||
multiple
|
||||
buttonVariant="transparent-without-text"
|
||||
className="h-7 w-7"
|
||||
buttonContainerClassName="h-7 w-7"
|
||||
className={cn("h-7 min-w-7", assigneeStackWidthClass)}
|
||||
buttonContainerClassName={cn("h-7 min-w-7", assigneeStackWidthClass)}
|
||||
button={
|
||||
<div
|
||||
data-control-link-ignore="true"
|
||||
className={cn(
|
||||
"flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors [&_.bg-accent-subtle]:!bg-transparent [&_.border-subtle-1]:!border-0",
|
||||
"relative flex h-7 min-w-7 items-center justify-start overflow-visible rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors [&_.border-subtle-1]:!border-0",
|
||||
assigneeStackWidthClass,
|
||||
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
|
||||
)}
|
||||
>
|
||||
<ButtonAvatars showTooltip={false} userIds={assigneeIds} size={26} />
|
||||
{assigneeCount > 0 ? (
|
||||
<div className={cn("relative h-[26px] overflow-visible", assigneeStackWidthClass)}>
|
||||
{visibleAssigneeIds.map((userId, index) => {
|
||||
const userDetails = getUserDetails(userId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="absolute top-0 h-[26px] w-[26px] rounded-full border border-black/10"
|
||||
style={{ left: index * 18, zIndex: 10 + index }}
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(userDetails?.avatar_url ?? "")}
|
||||
name={userDetails?.display_name}
|
||||
size={26}
|
||||
showTooltip={false}
|
||||
className="border-0 shadow-none ring-0 outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{assigneeCount > 3 && (
|
||||
<span
|
||||
className="absolute -top-2 z-50 font-semibold"
|
||||
style={{
|
||||
color: isActive ? "rgba(var(--nodedc-on-card-active-rgb), 0.7)" : "#B3B3B8",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
right: "-4px",
|
||||
}}
|
||||
>
|
||||
{assigneeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ButtonAvatars showTooltip={false} userIds={assigneeIds} size={26} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={40}
|
||||
shape="circle"
|
||||
className="text-18 font-medium"
|
||||
className="object-cover text-18 font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
|
|
@ -151,6 +151,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={isExpandedToolbarVariant ? 48 : 18}
|
||||
shape="circle"
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : isSidebarUtilityVariant ? (
|
||||
|
|
@ -164,6 +165,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={18}
|
||||
shape="circle"
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -180,6 +182,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={20}
|
||||
shape="circle"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ import type {
|
|||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export interface NodeDCWorkspacePolicy {
|
||||
enabled: boolean;
|
||||
can_create_workspace: boolean;
|
||||
mode: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class WorkspaceService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
|
|
@ -95,6 +102,14 @@ export class WorkspaceService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async getNodeDCWorkspacePolicy(): Promise<NodeDCWorkspacePolicy> {
|
||||
return this.get("/api/nodedc/workspace-policy/")
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(workspaceSlug: string, data: Partial<IWorkspace>): Promise<IWorkspace> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/`, data)
|
||||
.then((response) => response?.data)
|
||||
|
|
|
|||
Loading…
Reference in New Issue