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_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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue