Compare commits
3 Commits
a111574039
...
9fa05d60b6
| Author | SHA1 | Date |
|---|---|---|
|
|
9fa05d60b6 | |
|
|
11951c1eef | |
|
|
73f0e41e79 |
|
|
@ -61,7 +61,9 @@ x-app-env: &app-env
|
||||||
PLANE_OIDC_SCOPE: ${PLANE_OIDC_SCOPE:-openid email profile groups}
|
PLANE_OIDC_SCOPE: ${PLANE_OIDC_SCOPE:-openid email profile groups}
|
||||||
PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user}
|
PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user}
|
||||||
PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0}
|
PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0}
|
||||||
|
PLANE_OIDC_AUTO_CREATE_USER: ${PLANE_OIDC_AUTO_CREATE_USER:-0}
|
||||||
PLANE_OIDC_SYNC_PROFILE: ${PLANE_OIDC_SYNC_PROFILE:-1}
|
PLANE_OIDC_SYNC_PROFILE: ${PLANE_OIDC_SYNC_PROFILE:-1}
|
||||||
|
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING: ${PLANE_NODEDC_SKIP_PROFILE_ONBOARDING:-0}
|
||||||
PLANE_NODEDC_ACCESS_ENFORCEMENT: ${PLANE_NODEDC_ACCESS_ENFORCEMENT:-0}
|
PLANE_NODEDC_ACCESS_ENFORCEMENT: ${PLANE_NODEDC_ACCESS_ENFORCEMENT:-0}
|
||||||
PLANE_NODEDC_ACCESS_CHECK_URL: ${PLANE_NODEDC_ACCESS_CHECK_URL:-}
|
PLANE_NODEDC_ACCESS_CHECK_URL: ${PLANE_NODEDC_ACCESS_CHECK_URL:-}
|
||||||
PLANE_NODEDC_ACCESS_TOKEN: ${PLANE_NODEDC_ACCESS_TOKEN:-}
|
PLANE_NODEDC_ACCESS_TOKEN: ${PLANE_NODEDC_ACCESS_TOKEN:-}
|
||||||
|
|
@ -70,6 +72,8 @@ 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_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}
|
||||||
GUNICORN_WORKERS: 1
|
GUNICORN_WORKERS: 1
|
||||||
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
|
||||||
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
POSTHOG_HOST: ${POSTHOG_HOST:-}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
|
||||||
PLANE_OIDC_SCOPE=openid email profile groups
|
PLANE_OIDC_SCOPE=openid email profile groups
|
||||||
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
|
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
|
||||||
PLANE_OIDC_AUTO_LINK_EMAIL=1
|
PLANE_OIDC_AUTO_LINK_EMAIL=1
|
||||||
|
PLANE_OIDC_AUTO_CREATE_USER=1
|
||||||
|
PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1
|
||||||
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
|
PLANE_NODEDC_ACCESS_ENFORCEMENT=1
|
||||||
PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check
|
PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check
|
||||||
PLANE_NODEDC_ACCESS_TOKEN=
|
PLANE_NODEDC_ACCESS_TOKEN=
|
||||||
|
|
@ -109,3 +111,5 @@ 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_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consume
|
||||||
|
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from .views import (
|
||||||
MagicGenerateEndpoint,
|
MagicGenerateEndpoint,
|
||||||
MagicSignInEndpoint,
|
MagicSignInEndpoint,
|
||||||
MagicSignUpEndpoint,
|
MagicSignUpEndpoint,
|
||||||
|
NodeDCHandoffEndpoint,
|
||||||
NodeDCOIDCCallbackEndpoint,
|
NodeDCOIDCCallbackEndpoint,
|
||||||
NodeDCOIDCInitiateEndpoint,
|
NodeDCOIDCInitiateEndpoint,
|
||||||
SignInAuthEndpoint,
|
SignInAuthEndpoint,
|
||||||
|
|
@ -56,6 +57,7 @@ urlpatterns = [
|
||||||
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
|
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
|
||||||
path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"),
|
path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"),
|
||||||
path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"),
|
path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"),
|
||||||
|
path("nodedc/handoff/", NodeDCHandoffEndpoint.as_view(), name="nodedc-handoff"),
|
||||||
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
|
path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"),
|
||||||
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
|
path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
|
||||||
# signout
|
# signout
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
|
||||||
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
|
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
|
||||||
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
|
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
|
||||||
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
|
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
|
||||||
from .app.oidc import NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint
|
from .app.oidc import NodeDCHandoffEndpoint, NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint
|
||||||
|
|
||||||
from .app.signout import SignOutAuthEndpoint
|
from .app.signout import SignOutAuthEndpoint
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
@ -8,19 +9,22 @@ import jwt
|
||||||
import requests
|
import requests
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
from plane.authentication.utils.host import base_host
|
from plane.authentication.utils.host import base_host
|
||||||
from plane.authentication.utils.login import user_login
|
from plane.authentication.utils.login import user_login
|
||||||
from plane.authentication.utils.redirection_path import get_redirection_path
|
from plane.authentication.utils.redirection_path import get_redirection_path
|
||||||
from plane.db.models import ExternalIdentityLink, User
|
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
|
||||||
|
from plane.db.models import ExternalIdentityLink, Profile, User
|
||||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
|
||||||
|
|
||||||
|
|
||||||
OIDC_SESSION_KEY = "nodedc_oidc"
|
OIDC_SESSION_KEY = "nodedc_oidc"
|
||||||
OIDC_PROVIDER = "authentik"
|
OIDC_PROVIDER = "authentik"
|
||||||
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
|
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NodeDCOIDCInitiateEndpoint(View):
|
class NodeDCOIDCInitiateEndpoint(View):
|
||||||
|
|
@ -98,7 +102,9 @@ class NodeDCOIDCCallbackEndpoint(View):
|
||||||
claims=claims,
|
claims=claims,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
auto_link=config["auto_link_email"],
|
auto_link=config["auto_link_email"],
|
||||||
|
auto_create=config["auto_create_user"],
|
||||||
sync_profile=config["sync_profile"],
|
sync_profile=config["sync_profile"],
|
||||||
|
skip_profile_onboarding=config["skip_profile_onboarding"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
|
|
@ -111,6 +117,53 @@ class NodeDCOIDCCallbackEndpoint(View):
|
||||||
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
|
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
|
||||||
|
|
||||||
|
|
||||||
|
class NodeDCHandoffEndpoint(View):
|
||||||
|
def get(self, request):
|
||||||
|
config = get_oidc_config()
|
||||||
|
base_url = base_host(request=request, is_app=True)
|
||||||
|
next_path = validate_next_path(request.GET.get("next_path", ""))
|
||||||
|
token = request.GET.get("token", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
handoff = consume_launcher_handoff(token)
|
||||||
|
except (RuntimeError, requests.RequestException, ValueError):
|
||||||
|
logger.warning("NODEDC handoff failed", exc_info=True)
|
||||||
|
return oidc_login_redirect(base_url, next_path)
|
||||||
|
|
||||||
|
handoff_user = handoff.get("user") or {}
|
||||||
|
groups = normalize_groups(handoff_user.get("groups"))
|
||||||
|
|
||||||
|
if not has_required_group(groups):
|
||||||
|
return oidc_error_redirect(base_url, next_path, "handoff_access_denied")
|
||||||
|
|
||||||
|
claims = {
|
||||||
|
"sub": str(handoff_user.get("subject") or handoff_user.get("authentikUserId") or handoff_user.get("id") or ""),
|
||||||
|
"email": str(handoff_user.get("email") or "").strip().lower(),
|
||||||
|
"name": handoff_user.get("name"),
|
||||||
|
"preferred_username": handoff_user.get("email"),
|
||||||
|
"picture": handoff_user.get("avatarUrl"),
|
||||||
|
"groups": groups,
|
||||||
|
"email_verified": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
user = resolve_linked_user(
|
||||||
|
claims=claims,
|
||||||
|
groups=groups,
|
||||||
|
auto_link=config["auto_link_email"],
|
||||||
|
auto_create=config["auto_create_user"],
|
||||||
|
sync_profile=config["sync_profile"],
|
||||||
|
skip_profile_onboarding=config["skip_profile_onboarding"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
return oidc_error_redirect(base_url, next_path, "handoff_user_not_linked")
|
||||||
|
|
||||||
|
user_login(request=request, user=user, is_app=True)
|
||||||
|
|
||||||
|
path = next_path or get_redirection_path(user=user)
|
||||||
|
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
|
||||||
|
|
||||||
|
|
||||||
def get_oidc_config():
|
def get_oidc_config():
|
||||||
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
|
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
|
||||||
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
|
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
|
||||||
|
|
@ -127,10 +180,44 @@ def get_oidc_config():
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"scope": os.environ.get("PLANE_OIDC_SCOPE", "openid email profile groups"),
|
"scope": os.environ.get("PLANE_OIDC_SCOPE", "openid email profile groups"),
|
||||||
"auto_link_email": os.environ.get("PLANE_OIDC_AUTO_LINK_EMAIL", "0") == "1",
|
"auto_link_email": os.environ.get("PLANE_OIDC_AUTO_LINK_EMAIL", "0") == "1",
|
||||||
|
"auto_create_user": os.environ.get("PLANE_OIDC_AUTO_CREATE_USER", "0") == "1",
|
||||||
"sync_profile": os.environ.get("PLANE_OIDC_SYNC_PROFILE", "1") == "1",
|
"sync_profile": os.environ.get("PLANE_OIDC_SYNC_PROFILE", "1") == "1",
|
||||||
|
"skip_profile_onboarding": os.environ.get("PLANE_NODEDC_SKIP_PROFILE_ONBOARDING", "0") == "1",
|
||||||
|
"handoff_url": os.environ.get(
|
||||||
|
"PLANE_NODEDC_HANDOFF_URL",
|
||||||
|
"http://launcher.local.nodedc/api/internal/handoff/consume",
|
||||||
|
).strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def consume_launcher_handoff(token):
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("NODEDC handoff token is missing")
|
||||||
|
|
||||||
|
config = get_oidc_config()
|
||||||
|
internal_token = get_nodedc_internal_token()
|
||||||
|
|
||||||
|
if not config["handoff_url"] or not internal_token:
|
||||||
|
raise RuntimeError("NODEDC handoff is not configured")
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
config["handoff_url"],
|
||||||
|
json={
|
||||||
|
"token": token,
|
||||||
|
"serviceSlug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager"),
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {internal_token}"},
|
||||||
|
timeout=float(os.environ.get("PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS", "3")),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
if not payload.get("ok"):
|
||||||
|
raise RuntimeError("NODEDC handoff was rejected")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def load_discovery(issuer):
|
def load_discovery(issuer):
|
||||||
response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10)
|
response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -192,19 +279,26 @@ def has_required_group(groups):
|
||||||
return bool(required_groups.intersection(set(groups)))
|
return bool(required_groups.intersection(set(groups)))
|
||||||
|
|
||||||
|
|
||||||
def resolve_linked_user(claims, groups, auto_link, sync_profile):
|
def resolve_linked_user(claims, groups, auto_link, auto_create, sync_profile, skip_profile_onboarding):
|
||||||
subject = str(claims.get("sub") or "")
|
subject = str(claims.get("sub") or "")
|
||||||
email = str(claims.get("email") or "").strip().lower()
|
email = str(claims.get("email") or "").strip().lower()
|
||||||
|
|
||||||
if not subject:
|
if not subject or not email:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
link = ExternalIdentityLink.objects.select_related("user").filter(
|
link = ExternalIdentityLink.objects.select_related("user").filter(
|
||||||
provider=OIDC_PROVIDER,
|
provider=OIDC_PROVIDER,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
status=ExternalIdentityLink.Status.ACTIVE,
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
if link and link.status != ExternalIdentityLink.Status.ACTIVE:
|
||||||
|
logger.warning(
|
||||||
|
"NODEDC OIDC denied disabled external identity link: provider=%s subject_hash=%s",
|
||||||
|
OIDC_PROVIDER,
|
||||||
|
hash_subject(subject),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
if link is None and auto_link and email:
|
if link is None and auto_link and email:
|
||||||
user = User.objects.filter(email__iexact=email, is_active=True).first()
|
user = User.objects.filter(email__iexact=email, is_active=True).first()
|
||||||
if user:
|
if user:
|
||||||
|
|
@ -214,7 +308,28 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile):
|
||||||
defaults={"user": user, "email": email, "groups": groups},
|
defaults={"user": user, "email": email, "groups": groups},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if link is None and auto_create and email:
|
||||||
|
user, user_created = get_or_create_oidc_user(email=email, claims=claims)
|
||||||
|
link, _ = ExternalIdentityLink.objects.get_or_create(
|
||||||
|
provider=OIDC_PROVIDER,
|
||||||
|
subject=subject,
|
||||||
|
defaults={"user": user, "email": email, "groups": groups},
|
||||||
|
)
|
||||||
|
if user_created:
|
||||||
|
logger.info(
|
||||||
|
"NODEDC OIDC provisioned Tasker user: user_id=%s email_hash=%s subject_hash=%s",
|
||||||
|
user.id,
|
||||||
|
hash_email(email),
|
||||||
|
hash_subject(subject),
|
||||||
|
)
|
||||||
|
|
||||||
if link is None:
|
if link is None:
|
||||||
|
logger.warning(
|
||||||
|
"NODEDC OIDC user is not linked: provider=%s email_hash=%s subject_hash=%s",
|
||||||
|
OIDC_PROVIDER,
|
||||||
|
hash_email(email),
|
||||||
|
hash_subject(subject),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
link.email = email or link.email
|
link.email = email or link.email
|
||||||
|
|
@ -231,9 +346,92 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile):
|
||||||
update_fields.extend(sync_user_profile_from_claims(user, claims))
|
update_fields.extend(sync_user_profile_from_claims(user, claims))
|
||||||
|
|
||||||
user.save(update_fields=list(dict.fromkeys(update_fields)))
|
user.save(update_fields=list(dict.fromkeys(update_fields)))
|
||||||
|
|
||||||
|
if skip_profile_onboarding:
|
||||||
|
ensure_nodedc_profile_onboarded(user)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_oidc_user(email, claims):
|
||||||
|
user = User.objects.filter(email__iexact=email).first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return user, False
|
||||||
|
|
||||||
|
username = build_unique_username(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 ""
|
||||||
|
family_name = first_string_claim(claims, "family_name") or ""
|
||||||
|
avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar") or ""
|
||||||
|
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email=email,
|
||||||
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
|
first_name=given_name,
|
||||||
|
last_name=family_name,
|
||||||
|
avatar=avatar_url,
|
||||||
|
is_active=True,
|
||||||
|
is_managed=True,
|
||||||
|
is_password_autoset=True,
|
||||||
|
is_email_verified=bool(claims.get("email_verified", False)),
|
||||||
|
last_login_medium=OIDC_PROVIDER,
|
||||||
|
last_login_time=timezone.now(),
|
||||||
|
)
|
||||||
|
return user, True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_nodedc_profile_onboarded(user):
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=user)
|
||||||
|
onboarding_step = dict(profile.onboarding_step or {})
|
||||||
|
required_onboarding_step = {
|
||||||
|
"profile_complete": True,
|
||||||
|
"workspace_create": True,
|
||||||
|
"workspace_invite": True,
|
||||||
|
"workspace_join": True,
|
||||||
|
}
|
||||||
|
next_onboarding_step = {**onboarding_step, **required_onboarding_step}
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
if not profile.is_onboarded:
|
||||||
|
profile.is_onboarded = True
|
||||||
|
update_fields.append("is_onboarded")
|
||||||
|
|
||||||
|
if not profile.is_tour_completed:
|
||||||
|
profile.is_tour_completed = True
|
||||||
|
update_fields.append("is_tour_completed")
|
||||||
|
|
||||||
|
if profile.onboarding_step != next_onboarding_step:
|
||||||
|
profile.onboarding_step = next_onboarding_step
|
||||||
|
update_fields.append("onboarding_step")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
profile.save(update_fields=list(dict.fromkeys(update_fields)))
|
||||||
|
|
||||||
|
|
||||||
|
def build_unique_username(email):
|
||||||
|
base_username = email.split("@", 1)[0].strip().lower() or "nodedc-user"
|
||||||
|
username = normalize_username(base_username)
|
||||||
|
|
||||||
|
if not User.objects.filter(username=username).exists():
|
||||||
|
return username
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
candidate = f"{username}-{get_random_string(6).lower()}"
|
||||||
|
if not User.objects.filter(username=candidate).exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return f"{username}-{get_random_string(12).lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_username(value):
|
||||||
|
normalized = "".join(char if char.isalnum() or char in {"_", "-", "."} else "-" for char in value)
|
||||||
|
normalized = normalized.strip("-._")
|
||||||
|
return normalized[:96] or "nodedc-user"
|
||||||
|
|
||||||
|
|
||||||
def sync_user_profile_from_claims(user, claims):
|
def sync_user_profile_from_claims(user, claims):
|
||||||
updated_fields = []
|
updated_fields = []
|
||||||
display_name = first_string_claim(claims, "name", "preferred_username")
|
display_name = first_string_claim(claims, "name", "preferred_username")
|
||||||
|
|
@ -273,6 +471,16 @@ def first_string_claim(claims, *keys):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hash_email(email):
|
||||||
|
normalized_email = str(email or "").strip().lower()
|
||||||
|
return hashlib.sha256(normalized_email.encode()).hexdigest()[:12] if normalized_email else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hash_subject(subject):
|
||||||
|
normalized_subject = str(subject or "").strip()
|
||||||
|
return hashlib.sha256(normalized_subject.encode()).hexdigest()[:12] if normalized_subject else ""
|
||||||
|
|
||||||
|
|
||||||
def normalize_logout_guard_value(value):
|
def normalize_logout_guard_value(value):
|
||||||
return value.strip().lower() if isinstance(value, str) else ""
|
return value.strip().lower() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||||
import { PlaneLogo } from "@plane/propel/icons";
|
|
||||||
import type { IWorkspace } from "@plane/types";
|
import type { IWorkspace } from "@plane/types";
|
||||||
// assets
|
// assets
|
||||||
import WorkspaceCreationDisabled from "@/app/assets/workspace/workspace-creation-disabled.png?url";
|
import WorkspaceCreationDisabled from "@/app/assets/workspace/workspace-creation-disabled.png?url";
|
||||||
|
import { AuthHeaderBase } from "@/components/auth-screens/header";
|
||||||
// components
|
// components
|
||||||
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
|
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -58,55 +57,46 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticationWrapper>
|
<AuthenticationWrapper>
|
||||||
<div className="flex h-full flex-col gap-y-2 overflow-hidden bg-surface-1 sm:flex-row sm:gap-y-0">
|
<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">
|
||||||
<div className="relative h-1/6 flex-shrink-0 sm:w-2/12 md:w-3/12 lg:w-1/5">
|
<AuthHeaderBase pageTitle={t("workspace_creation.heading")} />
|
||||||
<div className="absolute top-1/2 left-0 h-[0.5px] w-full -translate-y-1/2 border-b-[0.5px] border-subtle sm:top-0 sm:left-1/2 sm:h-screen sm:w-[0.5px] sm:-translate-x-1/2 sm:translate-y-0 sm:border-r-[0.5px] md:left-1/3" />
|
<main className="grid flex-1 place-items-center py-8">
|
||||||
<Link
|
|
||||||
className="absolute top-1/2 left-5 grid -translate-y-1/2 place-items-center px-3 sm:top-12 sm:left-1/2 sm:-translate-x-[15px] sm:translate-y-0 sm:px-0 sm:py-5 md:left-1/3"
|
|
||||||
href="/"
|
|
||||||
>
|
|
||||||
<PlaneLogo className="h-9 w-auto text-primary" />
|
|
||||||
</Link>
|
|
||||||
<div className="absolute top-1/4 right-4 -translate-y-1/2 text-13 text-primary sm:fixed sm:top-12 sm:right-16 sm:translate-y-0 sm:py-5">
|
|
||||||
{currentUser?.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex h-full justify-center px-8 pb-8 sm:w-10/12 sm:items-center sm:justify-start sm:p-0 sm:pr-[8.33%] md:w-9/12 lg:w-4/5">
|
|
||||||
{isWorkspaceCreationDisabled ? (
|
{isWorkspaceCreationDisabled ? (
|
||||||
<div className="flex h-full w-4/5 flex-col items-center justify-center gap-1 text-16 font-medium">
|
<section className="nodedc-auth-shell flex flex-col items-center justify-center gap-4 text-center">
|
||||||
<img
|
<img
|
||||||
src={WorkspaceCreationDisabled}
|
src={WorkspaceCreationDisabled}
|
||||||
className="mb-4 h-full w-full object-contain"
|
className="max-h-56 w-full object-contain"
|
||||||
alt="Workspace creation disabled"
|
alt="Workspace creation disabled"
|
||||||
/>
|
/>
|
||||||
<div className="text-center text-16 font-medium">
|
<h1 className="m-0 text-24 font-semibold text-primary">
|
||||||
{t("workspace_creation.errors.creation_disabled.title")}
|
{t("workspace_creation.errors.creation_disabled.title")}
|
||||||
</div>
|
</h1>
|
||||||
<p className="text-center text-13 break-words text-tertiary">
|
<p className="m-0 text-14 leading-6 text-secondary">
|
||||||
{t("workspace_creation.errors.creation_disabled.description")}
|
{t("workspace_creation.errors.creation_disabled.description")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex gap-4">
|
<div className="mt-6 flex w-full flex-col gap-3">
|
||||||
<Button variant="primary" onClick={() => router.back()}>
|
<Button variant="primary" className="nodedc-auth-primary-button" onClick={() => router.back()}>
|
||||||
{t("common.go_back")}
|
{t("common.go_back")}
|
||||||
</Button>
|
</Button>
|
||||||
<a href={getMailtoHref()} className={getButtonStyling("secondary", "base")}>
|
<a href={getMailtoHref()} className={getButtonStyling("secondary", "base") + " nodedc-auth-secondary-button"}>
|
||||||
{t("workspace_creation.errors.creation_disabled.request_button")}
|
{t("workspace_creation.errors.creation_disabled.request_button")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full space-y-7 sm:space-y-10">
|
<section className="nodedc-auth-shell nodedc-create-workspace-card space-y-7">
|
||||||
<h4 className="text-20 font-semibold">{t("workspace_creation.heading")}</h4>
|
<div className="space-y-3">
|
||||||
<div className="sm:w-3/4 md:w-2/5">
|
<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">Создайте рабочее пространство.</p>
|
||||||
|
</div>
|
||||||
<CreateWorkspaceForm
|
<CreateWorkspaceForm
|
||||||
|
variant="nodedc-auth"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
setDefaultValues={setDefaultValues}
|
setDefaultValues={setDefaultValues}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticationWrapper>
|
</AuthenticationWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ export const SubIssuesListItemProperties = observer(function SubIssuesListItemPr
|
||||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||||
<div className="h-5 flex-shrink-0">
|
<div className="h-5 flex-shrink-0">
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
value={issue.assignee_ids}
|
value={issue.assignee_ids ?? []}
|
||||||
projectId={issue.project_id ?? undefined}
|
projectId={issue.project_id ?? undefined}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
issue.project_id &&
|
issue.project_id &&
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
|
||||||
|
|
||||||
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
|
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
const assigneeIds = issue.assignee_ids ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -105,17 +106,17 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
|
||||||
|
|
||||||
<SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
|
<SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
value={issue?.assignee_ids ?? undefined}
|
value={assigneeIds}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
projectId={projectId?.toString() ?? ""}
|
projectId={projectId?.toString() ?? ""}
|
||||||
placeholder={t("issue.add.assignee")}
|
placeholder={t("issue.add.assignee")}
|
||||||
multiple
|
multiple
|
||||||
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
|
buttonVariant={assigneeIds.length > 1 ? "transparent-without-text" : "transparent-with-text"}
|
||||||
className="group w-full grow"
|
className="group w-full grow"
|
||||||
buttonContainerClassName="w-full text-left h-7.5"
|
buttonContainerClassName="w-full text-left h-7.5"
|
||||||
buttonClassName={`text-body-xs-regular justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-placeholder"}`}
|
buttonClassName={`text-body-xs-regular justify-between ${assigneeIds.length > 0 ? "" : "text-placeholder"}`}
|
||||||
hideIcon={issue.assignee_ids?.length === 0}
|
hideIcon={assigneeIds.length === 0}
|
||||||
dropdownArrow
|
dropdownArrow
|
||||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
|
||||||
const { issues } = useIssues(storeType);
|
const { issues } = useIssues(storeType);
|
||||||
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString());
|
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString());
|
||||||
|
|
||||||
|
if (!issues) {
|
||||||
|
return <ActiveLoader layout={layout} />;
|
||||||
|
}
|
||||||
|
|
||||||
const issueCount = issues.getGroupIssueCount(undefined, undefined, false);
|
const issueCount = issues.getGroupIssueCount(undefined, undefined, false);
|
||||||
|
|
||||||
if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) {
|
if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
|
||||||
|
|
||||||
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
|
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
|
||||||
|
|
||||||
|
if (!issues) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeleteIssueModal
|
<DeleteIssueModal
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
const checkerItemsTotal = issue.checker_items_count ?? 0;
|
const checkerItemsTotal = issue.checker_items_count ?? 0;
|
||||||
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
|
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
|
||||||
const hasCheckerProgress = checkerBlocksTotal > 0;
|
const hasCheckerProgress = checkerBlocksTotal > 0;
|
||||||
|
const assigneeIds = issue.assignee_ids ?? [];
|
||||||
const cornerControlClasses = cn(
|
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",
|
"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
|
isActive
|
||||||
|
|
@ -246,7 +247,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
<>
|
<>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
projectId={issue.project_id ?? undefined}
|
projectId={issue.project_id ?? undefined}
|
||||||
value={issue.assignee_ids}
|
value={assigneeIds}
|
||||||
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
|
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
|
||||||
disabled={isReadOnly || !updateIssue}
|
disabled={isReadOnly || !updateIssue}
|
||||||
multiple
|
multiple
|
||||||
|
|
@ -261,7 +262,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
|
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids} size={26} />
|
<ButtonAvatars showTooltip={false} userIds={assigneeIds} size={26} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
||||||
|
const assigneeIds = issue.assignee_ids ?? [];
|
||||||
|
|
||||||
const minDate = getDate(issue.start_date);
|
const minDate = getDate(issue.start_date);
|
||||||
const maxDate = getDate(issue.target_date);
|
const maxDate = getDate(issue.target_date);
|
||||||
|
|
@ -365,13 +366,13 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
|
||||||
>
|
>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
projectId={issue?.project_id}
|
projectId={issue?.project_id}
|
||||||
value={issue?.assignee_ids}
|
value={assigneeIds}
|
||||||
onChange={handleAssignee}
|
onChange={handleAssignee}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
multiple
|
multiple
|
||||||
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
|
buttonVariant={assigneeIds.length > 0 ? "transparent-without-text" : "border-without-text"}
|
||||||
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
buttonClassName={assigneeIds.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||||
showTooltip={issue?.assignee_ids?.length === 0}
|
showTooltip={assigneeIds.length === 0}
|
||||||
placeholder={t("common.assignees")}
|
placeholder={t("common.assignees")}
|
||||||
optionsClassName="z-10"
|
optionsClassName="z-10"
|
||||||
tooltipContent=""
|
tooltipContent=""
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
|
||||||
|
|
||||||
const maxDate = getDate(issue.target_date);
|
const maxDate = getDate(issue.target_date);
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
const assigneeIds = issue.assignee_ids ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -99,17 +100,17 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
|
||||||
|
|
||||||
<SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
|
<SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
value={issue?.assignee_ids ?? undefined}
|
value={assigneeIds}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
placeholder={t("issue.add.assignee")}
|
placeholder={t("issue.add.assignee")}
|
||||||
multiple
|
multiple
|
||||||
buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"}
|
buttonVariant={assigneeIds.length > 1 ? "transparent-without-text" : "transparent-with-text"}
|
||||||
className="group w-full grow"
|
className="group w-full grow"
|
||||||
buttonContainerClassName="w-full text-left h-7.5"
|
buttonContainerClassName="w-full text-left h-7.5"
|
||||||
buttonClassName={`text-body-xs-medium justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-placeholder"}`}
|
buttonClassName={`text-body-xs-medium justify-between ${assigneeIds.length > 0 ? "" : "text-placeholder"}`}
|
||||||
hideIcon={issue.assignee_ids?.length === 0}
|
hideIcon={assigneeIds.length === 0}
|
||||||
dropdownArrow
|
dropdownArrow
|
||||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export const RelationIssueProperty = observer(function RelationIssueProperty(pro
|
||||||
|
|
||||||
<div className="h-5 flex-shrink-0">
|
<div className="h-5 flex-shrink-0">
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
value={issue.assignee_ids}
|
value={issue.assignee_ids ?? []}
|
||||||
projectId={issue.project_id ?? undefined}
|
projectId={issue.project_id ?? undefined}
|
||||||
onChange={handleAssigneeChange}
|
onChange={handleAssigneeChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
|
||||||
if (!issue.project_id) return null;
|
if (!issue.project_id) return null;
|
||||||
|
|
||||||
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
||||||
|
const assigneeIds = issue.assignee_ids ?? [];
|
||||||
|
|
||||||
const minDate = getDate(issue.start_date);
|
const minDate = getDate(issue.start_date);
|
||||||
minDate?.setDate(minDate.getDate());
|
minDate?.setDate(minDate.getDate());
|
||||||
|
|
@ -222,12 +223,12 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
|
||||||
<div className="h-5" onClick={handleEventPropagation}>
|
<div className="h-5" onClick={handleEventPropagation}>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
projectId={issue?.project_id}
|
projectId={issue?.project_id}
|
||||||
value={issue?.assignee_ids}
|
value={assigneeIds}
|
||||||
onChange={handleAssignee}
|
onChange={handleAssignee}
|
||||||
multiple
|
multiple
|
||||||
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
|
buttonVariant={assigneeIds.length > 0 ? "transparent-without-text" : "border-without-text"}
|
||||||
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
buttonClassName={assigneeIds.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||||
showTooltip={issue?.assignee_ids?.length === 0}
|
showTooltip={assigneeIds.length === 0}
|
||||||
placeholder={t("assignees")}
|
placeholder={t("assignees")}
|
||||||
optionsClassName="z-10"
|
optionsClassName="z-10"
|
||||||
tooltipContent=""
|
tooltipContent=""
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Props = {
|
||||||
loading: string;
|
loading: string;
|
||||||
default: string;
|
default: string;
|
||||||
};
|
};
|
||||||
|
variant?: "default" | "nodedc-auth";
|
||||||
};
|
};
|
||||||
|
|
||||||
const workspaceService = new WorkspaceService();
|
const workspaceService = new WorkspaceService();
|
||||||
|
|
@ -51,7 +52,25 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
|
||||||
loading: "workspace_creation.button.loading",
|
loading: "workspace_creation.button.loading",
|
||||||
default: "workspace_creation.button.default",
|
default: "workspace_creation.button.default",
|
||||||
},
|
},
|
||||||
|
variant = "default",
|
||||||
} = props;
|
} = props;
|
||||||
|
const isNodeDCAuth = variant === "nodedc-auth";
|
||||||
|
const formClassName = isNodeDCAuth ? "space-y-7" : "space-y-6 sm:space-y-9";
|
||||||
|
const fieldsClassName = isNodeDCAuth ? "space-y-5" : "space-y-6 sm:space-y-7";
|
||||||
|
const fieldClassName = isNodeDCAuth ? "flex flex-col gap-2 text-12 text-secondary" : "flex flex-col gap-2 text-13";
|
||||||
|
const inputShellClassName = isNodeDCAuth ? "nodedc-auth-input-shell flex w-full items-center px-4" : "flex flex-col gap-1";
|
||||||
|
const inputClassName = isNodeDCAuth ? "nodedc-auth-input h-12 w-full px-0 py-0 text-14" : "w-full";
|
||||||
|
const urlShellClassName = isNodeDCAuth
|
||||||
|
? "nodedc-auth-input-shell flex w-full items-center px-4"
|
||||||
|
: "flex w-full items-center rounded-md border border-subtle bg-layer-2 px-3";
|
||||||
|
const urlInputClassName = isNodeDCAuth
|
||||||
|
? "nodedc-auth-input block h-12 w-full border-none bg-transparent !px-0 py-0 text-14"
|
||||||
|
: "block w-full rounded-md border-none bg-transparent !px-0 py-2 text-12";
|
||||||
|
const dropdownClassName = isNodeDCAuth
|
||||||
|
? "nodedc-auth-input-shell flex min-h-12 items-center px-4 text-14 shadow-none"
|
||||||
|
: "rounded-md border border-subtle bg-layer-2 px-3 py-2 text-13 shadow-none";
|
||||||
|
const errorClassName = isNodeDCAuth ? "text-12 text-[rgb(var(--nodedc-accent-rgb))]" : "text-13 text-danger-primary";
|
||||||
|
const requiredMark = isNodeDCAuth ? null : <span className="ml-0.5 text-danger-primary">*</span>;
|
||||||
// states
|
// states
|
||||||
const [slugError, setSlugError] = useState(false);
|
const [slugError, setSlugError] = useState(false);
|
||||||
const [invalidSlug, setInvalidSlug] = useState(false);
|
const [invalidSlug, setInvalidSlug] = useState(false);
|
||||||
|
|
@ -111,18 +130,18 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="space-y-6 sm:space-y-9"
|
className={formClassName}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
void handleSubmit(handleCreateWorkspace)(e);
|
void handleSubmit(handleCreateWorkspace)(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-6 sm:space-y-7">
|
<div className={fieldsClassName}>
|
||||||
<div className="flex flex-col gap-2 text-13">
|
<div className={fieldClassName}>
|
||||||
<label htmlFor="workspaceName">
|
<label htmlFor="workspaceName">
|
||||||
{t("workspace_creation.form.name.label")}
|
{t("workspace_creation.form.name.label")}
|
||||||
<span className="ml-0.5 text-danger-primary">*</span>
|
{requiredMark}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={inputShellClassName} data-error={Boolean(errors.name)}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|
@ -149,20 +168,20 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder={t("workspace_creation.form.name.placeholder")}
|
placeholder={t("workspace_creation.form.name.placeholder")}
|
||||||
className="w-full"
|
className={inputClassName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className={errorClassName}>{errors?.name?.message}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 text-13">
|
<div className={fieldClassName}>
|
||||||
<label htmlFor="workspaceUrl">
|
<label htmlFor="workspaceUrl">
|
||||||
{t("workspace_creation.form.url.label")}
|
{t("workspace_creation.form.url.label")}
|
||||||
<span className="ml-0.5 text-danger-primary">*</span>
|
{requiredMark}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex w-full items-center rounded-md border border-subtle bg-layer-2 px-3">
|
<div className={urlShellClassName} data-error={Boolean(errors.slug) || slugError || invalidSlug}>
|
||||||
<span className="text-12 whitespace-nowrap text-secondary">{window && window.location.host}/</span>
|
<span className="mr-1 text-12 whitespace-nowrap text-secondary">{window && window.location.host}/</span>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="slug"
|
name="slug"
|
||||||
|
|
@ -187,25 +206,25 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.slug)}
|
hasError={Boolean(errors.slug)}
|
||||||
placeholder={t("workspace_creation.form.url.placeholder")}
|
placeholder={t("workspace_creation.form.url.placeholder")}
|
||||||
className="block w-full rounded-md border-none bg-transparent !px-0 py-2 text-12"
|
className={urlInputClassName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{slugError && (
|
{slugError && (
|
||||||
<p className="-mt-3 text-13 text-danger-primary">
|
<p className={errorClassName}>
|
||||||
{t("workspace_creation.errors.validation.url_already_taken")}
|
{t("workspace_creation.errors.validation.url_already_taken")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{invalidSlug && (
|
{invalidSlug && (
|
||||||
<p className="text-13 text-danger-primary">{t("workspace_creation.errors.validation.url_alphanumeric")}</p>
|
<p className={errorClassName}>{t("workspace_creation.errors.validation.url_alphanumeric")}</p>
|
||||||
)}
|
)}
|
||||||
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
|
{errors.slug && <span className={errorClassName}>{errors.slug.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 text-13">
|
<div className={fieldClassName}>
|
||||||
<span>
|
<span>
|
||||||
{t("workspace_creation.form.organization_size.label")}
|
{t("workspace_creation.form.organization_size.label")}
|
||||||
<span className="ml-0.5 text-danger-primary">*</span>
|
{requiredMark}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Controller
|
<Controller
|
||||||
|
|
@ -227,23 +246,36 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
menuButtonWrapperClassName="rounded-md border border-subtle bg-layer-2 px-3 py-2 text-13 shadow-none"
|
menuButtonWrapperClassName={dropdownClassName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.organization_size && (
|
{errors.organization_size && (
|
||||||
<span className="text-13 text-danger-primary">{errors.organization_size.message}</span>
|
<span className={errorClassName}>{errors.organization_size.message}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className={isNodeDCAuth ? "flex flex-col gap-3" : "flex items-center gap-4"}>
|
||||||
{secondaryButton}
|
{secondaryButton}
|
||||||
<Button variant="primary" type="submit" size="xl" disabled={!isValid} loading={isSubmitting}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
size="xl"
|
||||||
|
disabled={!isValid}
|
||||||
|
loading={isSubmitting}
|
||||||
|
className={isNodeDCAuth ? "nodedc-auth-primary-button" : undefined}
|
||||||
|
>
|
||||||
{isSubmitting ? t(primaryButtonText.loading) : t(primaryButtonText.default)}
|
{isSubmitting ? t(primaryButtonText.loading) : t(primaryButtonText.default)}
|
||||||
</Button>
|
</Button>
|
||||||
{!secondaryButton && (
|
{!secondaryButton && (
|
||||||
<Button variant="secondary" type="button" size="xl" onClick={() => router.back()}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
size="xl"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className={isNodeDCAuth ? "nodedc-auth-secondary-button" : undefined}
|
||||||
|
>
|
||||||
{t("common.go_back")}
|
{t("common.go_back")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2585,6 +2585,36 @@
|
||||||
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-secondary-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: 1.15rem !important;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
|
||||||
|
color: var(--text-color-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-secondary-button:hover {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: var(--text-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-create-workspace-card {
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-create-workspace-card input:-webkit-autofill,
|
||||||
|
.nodedc-create-workspace-card input:-webkit-autofill:hover,
|
||||||
|
.nodedc-create-workspace-card input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: var(--text-color-primary);
|
||||||
|
box-shadow: 0 0 0 1000px transparent inset !important;
|
||||||
|
transition: background-color 9999s ease-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-error-shell {
|
.nodedc-error-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue