FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: СИНХРОНИЗАЦИЯ АВАТАРОВ TASKER

This commit is contained in:
DCCONSTRUCTIONS 2026-05-06 15:30:56 +03:00
parent a5a347e839
commit 3afa15d326
6 changed files with 203 additions and 7 deletions

View File

@ -72,6 +72,7 @@ x-app-env: &app-env
PLANE_NODEDC_ACCESS_CACHE_SECONDS: ${PLANE_NODEDC_ACCESS_CACHE_SECONDS:-0} 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_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_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_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_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_URL: ${PLANE_NODEDC_WORKSPACE_POLICY_URL:-http://launcher.local.nodedc/api/internal/access/check}

View File

@ -111,6 +111,7 @@ PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3
PLANE_NODEDC_ACCESS_CACHE_SECONDS=0 PLANE_NODEDC_ACCESS_CACHE_SECONDS=0
PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/ 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_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_URL=http://launcher.local.nodedc/api/internal/handoff/consume
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3 PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check PLANE_NODEDC_WORKSPACE_POLICY_URL=http://launcher.local.nodedc/api/internal/access/check

View File

@ -4,6 +4,7 @@ import logging
import os import os
import secrets import secrets
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.parse import urlparse
import jwt import jwt
import requests 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) display_name = first_string_claim(claims, "name", "preferred_username") or User.get_display_name(email)
given_name = first_string_claim(claims, "given_name") or "" given_name = first_string_claim(claims, "given_name") or ""
family_name = first_string_claim(claims, "family_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( user = User.objects.create_user(
email=email, email=email,
@ -437,7 +438,7 @@ def sync_user_profile_from_claims(user, claims):
display_name = first_string_claim(claims, "name", "preferred_username") display_name = first_string_claim(claims, "name", "preferred_username")
given_name = first_string_claim(claims, "given_name") given_name = first_string_claim(claims, "given_name")
family_name = first_string_claim(claims, "family_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: if display_name and user.display_name != display_name:
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 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): def first_string_claim(claims, *keys):
for key in keys: for key in keys:
value = claims.get(key) value = claims.get(key)

View File

@ -1,4 +1,6 @@
import json import json
import os
from urllib.parse import urlparse
from django.db import transaction from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
@ -7,19 +9,20 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized 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" OIDC_PROVIDER = "authentik"
ADMIN_ROLE = 20
ROLE_VALUES = { ROLE_VALUES = {
"guest": 5, "guest": 5,
"viewer": 5, "viewer": 5,
"member": 15, "member": 15,
"admin": 20, "admin": ADMIN_ROLE,
"owner": 20, "owner": ADMIN_ROLE,
5: 5, 5: 5,
15: 15, 15: 15,
20: 20, ADMIN_ROLE: ADMIN_ROLE,
} }
@ -89,6 +92,61 @@ def normalize_role(value):
return ROLE_VALUES.get(value, 15) 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): def serialize_workspace(workspace):
return { return {
"id": str(workspace.id), "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") @method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceListEndpoint(View): class NodeDCInternalWorkspaceListEndpoint(View):
def get(self, request): def get(self, request):
@ -152,6 +231,8 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
set_last_workspace = payload.get("setLastWorkspace", True) is not False set_last_workspace = payload.get("setLastWorkspace", True) is not False
with transaction.atomic(): with transaction.atomic():
sync_user_avatar_from_payload(user, payload)
membership = WorkspaceMember.objects.filter( membership = WorkspaceMember.objects.filter(
workspace=workspace, workspace=workspace,
member=user, member=user,
@ -178,9 +259,73 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
membership.banned_until = None membership.banned_until = None
membership.save(update_fields=["role", "company_role", "is_active", "is_banned", "banned_at", "banned_until", "updated_at"]) 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: if set_last_workspace:
profile, _ = Profile.objects.get_or_create(user=user) profile, _ = Profile.objects.get_or_create(user=user)
profile.last_workspace_id = workspace.id profile.last_workspace_id = workspace.id
profile.save(update_fields=["last_workspace_id", "updated_at"]) profile.save(update_fields=["last_workspace_id", "updated_at"])
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)}) 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,
},
}
)

View File

@ -18,6 +18,7 @@ from plane.authentication.views.nodedc_logout import (
from plane.authentication.views.nodedc_workspace_adapter import ( from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalWorkspaceListEndpoint, NodeDCInternalWorkspaceListEndpoint,
NodeDCInternalWorkspaceMembershipEnsureEndpoint, NodeDCInternalWorkspaceMembershipEnsureEndpoint,
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
) )
handler404 = "plane.app.views.error_404.custom_404_view" handler404 = "plane.app.views.error_404.custom_404_view"
@ -38,6 +39,11 @@ urlpatterns = [
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(), NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),
name="nodedc-internal-workspace-membership-ensure", 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/", include("plane.app.urls")),
path("api/public/", include("plane.space.urls")), path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")), path("api/instances/", include("plane.license.urls")),

View File

@ -79,7 +79,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
src={getFileURL(currentUser?.avatar_url ?? "")} src={getFileURL(currentUser?.avatar_url ?? "")}
size={40} size={40}
shape="circle" shape="circle"
className="text-18 font-medium" className="object-cover text-18 font-medium"
/> />
</div> </div>
<div className="text-center"> <div className="text-center">
@ -151,6 +151,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
src={getFileURL(currentUser?.avatar_url ?? "")} src={getFileURL(currentUser?.avatar_url ?? "")}
size={isExpandedToolbarVariant ? 48 : 18} size={isExpandedToolbarVariant ? 48 : 18}
shape="circle" shape="circle"
className="object-cover"
/> />
</button> </button>
) : isSidebarUtilityVariant ? ( ) : isSidebarUtilityVariant ? (
@ -164,6 +165,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
src={getFileURL(currentUser?.avatar_url ?? "")} src={getFileURL(currentUser?.avatar_url ?? "")}
size={18} size={18}
shape="circle" shape="circle"
className="object-cover"
/> />
</button> </button>
) : ( ) : (
@ -180,6 +182,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
src={getFileURL(currentUser?.avatar_url ?? "")} src={getFileURL(currentUser?.avatar_url ?? "")}
size={20} size={20}
shape="circle" shape="circle"
className="object-cover"
/> />
</div> </div>
</button> </button>