Compare commits

...

3 Commits

17 changed files with 365 additions and 82 deletions

View File

@ -61,7 +61,9 @@ x-app-env: &app-env
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_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_NODEDC_SKIP_PROFILE_ONBOARDING: ${PLANE_NODEDC_SKIP_PROFILE_ONBOARDING:-0}
PLANE_NODEDC_ACCESS_ENFORCEMENT: ${PLANE_NODEDC_ACCESS_ENFORCEMENT:-0}
PLANE_NODEDC_ACCESS_CHECK_URL: ${PLANE_NODEDC_ACCESS_CHECK_URL:-}
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_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_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
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -101,6 +101,8 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
PLANE_OIDC_SCOPE=openid email profile groups
PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user
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_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check
PLANE_NODEDC_ACCESS_TOKEN=
@ -109,3 +111,5 @@ 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_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consume
PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3

View File

@ -21,6 +21,7 @@ from .views import (
MagicGenerateEndpoint,
MagicSignInEndpoint,
MagicSignUpEndpoint,
NodeDCHandoffEndpoint,
NodeDCOIDCCallbackEndpoint,
NodeDCOIDCInitiateEndpoint,
SignInAuthEndpoint,
@ -56,6 +57,7 @@ urlpatterns = [
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-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-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"),
# signout

View File

@ -12,7 +12,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
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

View File

@ -1,5 +1,6 @@
import base64
import hashlib
import logging
import os
import secrets
from urllib.parse import urlencode
@ -8,19 +9,22 @@ import jwt
import requests
from django.core.cache import cache
from django.http import HttpResponseRedirect
from django.utils.crypto import get_random_string
from django.utils import timezone
from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
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
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
logger = logging.getLogger(__name__)
class NodeDCOIDCInitiateEndpoint(View):
@ -98,7 +102,9 @@ class NodeDCOIDCCallbackEndpoint(View):
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:
@ -111,6 +117,53 @@ class NodeDCOIDCCallbackEndpoint(View):
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():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
@ -127,10 +180,44 @@ def get_oidc_config():
"redirect_uri": redirect_uri,
"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_create_user": os.environ.get("PLANE_OIDC_AUTO_CREATE_USER", "0") == "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):
response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10)
response.raise_for_status()
@ -192,19 +279,26 @@ def has_required_group(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 "")
email = str(claims.get("email") or "").strip().lower()
if not subject:
if not subject or not email:
return None
link = ExternalIdentityLink.objects.select_related("user").filter(
provider=OIDC_PROVIDER,
subject=subject,
status=ExternalIdentityLink.Status.ACTIVE,
).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:
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user:
@ -214,7 +308,28 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile):
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:
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
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))
user.save(update_fields=list(dict.fromkeys(update_fields)))
if skip_profile_onboarding:
ensure_nodedc_profile_onboarded(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):
updated_fields = []
display_name = first_string_claim(claims, "name", "preferred_username")
@ -273,6 +471,16 @@ def first_string_claim(claims, *keys):
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):
return value.strip().lower() if isinstance(value, str) else ""

View File

@ -6,14 +6,13 @@
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Button, getButtonStyling } from "@plane/propel/button";
import { PlaneLogo } from "@plane/propel/icons";
import type { IWorkspace } from "@plane/types";
// assets
import WorkspaceCreationDisabled from "@/app/assets/workspace/workspace-creation-disabled.png?url";
import { AuthHeaderBase } from "@/components/auth-screens/header";
// components
import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form";
// hooks
@ -58,55 +57,46 @@ const CreateWorkspacePage = observer(function CreateWorkspacePage() {
return (
<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 h-1/6 flex-shrink-0 sm:w-2/12 md:w-3/12 lg:w-1/5">
<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" />
<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">
<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">
<AuthHeaderBase pageTitle={t("workspace_creation.heading")} />
<main className="grid flex-1 place-items-center py-8">
{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
src={WorkspaceCreationDisabled}
className="mb-4 h-full w-full object-contain"
className="max-h-56 w-full object-contain"
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")}
</div>
<p className="text-center text-13 break-words text-tertiary">
</h1>
<p className="m-0 text-14 leading-6 text-secondary">
{t("workspace_creation.errors.creation_disabled.description")}
</p>
<div className="mt-6 flex gap-4">
<Button variant="primary" onClick={() => router.back()}>
<div className="mt-6 flex w-full flex-col gap-3">
<Button variant="primary" className="nodedc-auth-primary-button" onClick={() => router.back()}>
{t("common.go_back")}
</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")}
</a>
</div>
</div>
</section>
) : (
<div className="w-full space-y-7 sm:space-y-10">
<h4 className="text-20 font-semibold">{t("workspace_creation.heading")}</h4>
<div className="sm:w-3/4 md:w-2/5">
<CreateWorkspaceForm
onSubmit={onSubmit}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues}
/>
<section className="nodedc-auth-shell nodedc-create-workspace-card space-y-7">
<div className="space-y-3">
<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>
</div>
<CreateWorkspaceForm
variant="nodedc-auth"
onSubmit={onSubmit}
defaultValues={defaultValues}
setDefaultValues={setDefaultValues}
/>
</section>
)}
</div>
</main>
</div>
</AuthenticationWrapper>
);

View File

@ -215,7 +215,7 @@ export const SubIssuesListItemProperties = observer(function SubIssuesListItemPr
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
value={issue.assignee_ids ?? []}
projectId={issue.project_id ?? undefined}
onChange={(val) =>
issue.project_id &&

View File

@ -81,6 +81,7 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());
const assigneeIds = issue.assignee_ids ?? [];
return (
<>
@ -105,17 +106,17 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
<SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
value={assigneeIds}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!isEditable}
projectId={projectId?.toString() ?? ""}
placeholder={t("issue.add.assignee")}
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"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-regular justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-placeholder"}`}
hideIcon={issue.assignee_ids?.length === 0}
buttonClassName={`text-body-xs-regular justify-between ${assigneeIds.length > 0 ? "" : "text-placeholder"}`}
hideIcon={assigneeIds.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>

View File

@ -52,6 +52,10 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
const { issues } = useIssues(storeType);
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString());
if (!issues) {
return <ActiveLoader layout={layout} />;
}
const issueCount = issues.getGroupIssueCount(undefined, undefined, false);
if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) {

View File

@ -268,6 +268,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
if (!issues) {
return null;
}
return (
<>
<DeleteIssueModal

View File

@ -86,6 +86,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const checkerItemsTotal = issue.checker_items_count ?? 0;
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
const hasCheckerProgress = checkerBlocksTotal > 0;
const assigneeIds = issue.assignee_ids ?? [];
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",
isActive
@ -246,7 +247,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
<>
<MemberDropdown
projectId={issue.project_id ?? undefined}
value={issue.assignee_ids}
value={assigneeIds}
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
disabled={isReadOnly || !updateIssue}
multiple
@ -261,7 +262,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
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>
}
/>

View File

@ -191,6 +191,7 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
);
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const assigneeIds = issue.assignee_ids ?? [];
const minDate = getDate(issue.start_date);
const maxDate = getDate(issue.target_date);
@ -365,13 +366,13 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr
>
<MemberDropdown
projectId={issue?.project_id}
value={issue?.assignee_ids}
value={assigneeIds}
onChange={handleAssignee}
disabled={isReadOnly}
multiple
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={issue?.assignee_ids?.length === 0}
buttonVariant={assigneeIds.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={assigneeIds.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={assigneeIds.length === 0}
placeholder={t("common.assignees")}
optionsClassName="z-10"
tooltipContent=""

View File

@ -77,6 +77,7 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
const assigneeIds = issue.assignee_ids ?? [];
return (
<div>
@ -99,17 +100,17 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p
<SidebarPropertyListItem icon={MembersPropertyIcon} label={t("common.assignees")}>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
value={assigneeIds}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={disabled}
projectId={projectId}
placeholder={t("issue.add.assignee")}
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"
buttonContainerClassName="w-full text-left h-7.5"
buttonClassName={`text-body-xs-medium justify-between ${issue?.assignee_ids?.length > 0 ? "" : "text-placeholder"}`}
hideIcon={issue.assignee_ids?.length === 0}
buttonClassName={`text-body-xs-medium justify-between ${assigneeIds.length > 0 ? "" : "text-placeholder"}`}
hideIcon={assigneeIds.length === 0}
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>

View File

@ -81,7 +81,7 @@ export const RelationIssueProperty = observer(function RelationIssueProperty(pro
<div className="h-5 flex-shrink-0">
<MemberDropdown
value={issue.assignee_ids}
value={issue.assignee_ids ?? []}
projectId={issue.project_id ?? undefined}
onChange={handleAssigneeChange}
disabled={disabled}

View File

@ -125,6 +125,7 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
if (!issue.project_id) return null;
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const assigneeIds = issue.assignee_ids ?? [];
const minDate = getDate(issue.start_date);
minDate?.setDate(minDate.getDate());
@ -222,12 +223,12 @@ export const DraftIssueProperties = observer(function DraftIssueProperties(props
<div className="h-5" onClick={handleEventPropagation}>
<MemberDropdown
projectId={issue?.project_id}
value={issue?.assignee_ids}
value={assigneeIds}
onChange={handleAssignee}
multiple
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={issue?.assignee_ids?.length === 0}
buttonVariant={assigneeIds.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={assigneeIds.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={assigneeIds.length === 0}
placeholder={t("assignees")}
optionsClassName="z-10"
tooltipContent=""

View File

@ -36,6 +36,7 @@ type Props = {
loading: string;
default: string;
};
variant?: "default" | "nodedc-auth";
};
const workspaceService = new WorkspaceService();
@ -51,7 +52,25 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
loading: "workspace_creation.button.loading",
default: "workspace_creation.button.default",
},
variant = "default",
} = 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
const [slugError, setSlugError] = useState(false);
const [invalidSlug, setInvalidSlug] = useState(false);
@ -111,18 +130,18 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
return (
<form
className="space-y-6 sm:space-y-9"
className={formClassName}
onSubmit={(e) => {
void handleSubmit(handleCreateWorkspace)(e);
}}
>
<div className="space-y-6 sm:space-y-7">
<div className="flex flex-col gap-2 text-13">
<div className={fieldsClassName}>
<div className={fieldClassName}>
<label htmlFor="workspaceName">
{t("workspace_creation.form.name.label")}
<span className="ml-0.5 text-danger-primary">*</span>
{requiredMark}
</label>
<div className="flex flex-col gap-1">
<div className={inputShellClassName} data-error={Boolean(errors.name)}>
<Controller
control={control}
name="name"
@ -149,20 +168,20 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
ref={ref}
hasError={Boolean(errors.name)}
placeholder={t("workspace_creation.form.name.placeholder")}
className="w-full"
className={inputClassName}
/>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
<span className={errorClassName}>{errors?.name?.message}</span>
</div>
<div className="flex flex-col gap-2 text-13">
<div className={fieldClassName}>
<label htmlFor="workspaceUrl">
{t("workspace_creation.form.url.label")}
<span className="ml-0.5 text-danger-primary">*</span>
{requiredMark}
</label>
<div className="flex w-full items-center rounded-md border border-subtle bg-layer-2 px-3">
<span className="text-12 whitespace-nowrap text-secondary">{window && window.location.host}/</span>
<div className={urlShellClassName} data-error={Boolean(errors.slug) || slugError || invalidSlug}>
<span className="mr-1 text-12 whitespace-nowrap text-secondary">{window && window.location.host}/</span>
<Controller
control={control}
name="slug"
@ -187,25 +206,25 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
ref={ref}
hasError={Boolean(errors.slug)}
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>
{slugError && (
<p className="-mt-3 text-13 text-danger-primary">
<p className={errorClassName}>
{t("workspace_creation.errors.validation.url_already_taken")}
</p>
)}
{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 className="flex flex-col gap-2 text-13">
<div className={fieldClassName}>
<span>
{t("workspace_creation.form.organization_size.label")}
<span className="ml-0.5 text-danger-primary">*</span>
{requiredMark}
</span>
<div className="w-full">
<Controller
@ -227,23 +246,36 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
</span>
)
}
menuButtonWrapperClassName="rounded-md border border-subtle bg-layer-2 px-3 py-2 text-13 shadow-none"
menuButtonWrapperClassName={dropdownClassName}
/>
)}
/>
{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 className="flex items-center gap-4">
<div className={isNodeDCAuth ? "flex flex-col gap-3" : "flex items-center gap-4"}>
{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)}
</Button>
{!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")}
</Button>
)}

View File

@ -2585,6 +2585,36 @@
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 {
width: 100%;
max-width: 36rem;