FIX - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: СИНХРОНИЗАЦИЯ АВАТАРОВ TASKER
This commit is contained in:
parent
a5a347e839
commit
3afa15d326
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue