diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 9337d76..a84126e 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -72,6 +72,7 @@ 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} diff --git a/plane-app/plane.env b/plane-app/plane.env index 1d6148c..82b36d6 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -111,6 +111,7 @@ 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 diff --git a/plane-src/apps/api/plane/authentication/views/app/oidc.py b/plane-src/apps/api/plane/authentication/views/app/oidc.py index 0b5fb9e..02056e2 100644 --- a/plane-src/apps/api/plane/authentication/views/app/oidc.py +++ b/plane-src/apps/api/plane/authentication/views/app/oidc.py @@ -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) diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py index dcc1db1..0067490 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py @@ -1,4 +1,6 @@ import json +import os +from urllib.parse import urlparse from django.db import transaction from django.http import JsonResponse @@ -7,19 +9,20 @@ 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, User, Workspace, WorkspaceMember +from plane.db.models import ExternalIdentityLink, Profile, ProjectMember, User, Workspace, WorkspaceMember OIDC_PROVIDER = "authentik" +ADMIN_ROLE = 20 ROLE_VALUES = { "guest": 5, "viewer": 5, "member": 15, - "admin": 20, - "owner": 20, + "admin": ADMIN_ROLE, + "owner": ADMIN_ROLE, 5: 5, 15: 15, - 20: 20, + ADMIN_ROLE: ADMIN_ROLE, } @@ -89,6 +92,61 @@ def normalize_role(value): return ROLE_VALUES.get(value, 15) +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_workspace(workspace): return { "id": str(workspace.id), @@ -119,6 +177,27 @@ def serialize_membership(membership, created): } +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): @@ -152,6 +231,8 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View): 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, @@ -178,9 +259,73 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View): 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, + }, + } + ) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index d8ee12c..83ac333 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -18,6 +18,7 @@ from plane.authentication.views.nodedc_logout import ( from plane.authentication.views.nodedc_workspace_adapter import ( NodeDCInternalWorkspaceListEndpoint, NodeDCInternalWorkspaceMembershipEnsureEndpoint, + NodeDCInternalWorkspaceMembershipRemoveEndpoint, ) handler404 = "plane.app.views.error_404.custom_404_view" @@ -38,6 +39,11 @@ urlpatterns = [ 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/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")), diff --git a/plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx b/plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx index c67782e..215bc32 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/user-menu-root.tsx @@ -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" />