ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: профиль, редиректы и визуальные статусы задач

This commit is contained in:
DCCONSTRUCTIONS 2026-04-25 14:41:31 +03:00
parent c4032e3040
commit 4ed63cac4e
16 changed files with 271 additions and 21 deletions

View File

@ -48,9 +48,9 @@ x-live-env: &live-env
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
WEB_URL: ${WEB_URL:-http://localhost:8090}
DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8090}
GUNICORN_WORKERS: 1
POSTHOG_API_KEY: ${POSTHOG_API_KEY:-}
POSTHOG_HOST: ${POSTHOG_HOST:-}

View File

@ -44,6 +44,11 @@ urlpatterns = [
UserEndpoint.as_view({"patch": "update_email"}),
name="user-email-update",
),
path(
"users/me/email/direct/",
UserEndpoint.as_view({"patch": "update_email_without_verification"}),
name="user-email-direct-update",
),
# Profile
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
# End profile

View File

@ -3,6 +3,7 @@
# See the LICENSE file for details.
# Python imports
import os
import uuid
import json
import logging
@ -50,6 +51,7 @@ from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
from plane.utils.host import base_host
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
from plane.authentication.rate_limit import EmailVerificationThrottle
from plane.license.utils.instance_value import get_configuration_value
logger = logging.getLogger("plane")
@ -133,6 +135,10 @@ class UserEndpoint(BaseViewSet):
return None
def _is_smtp_configured(self):
(email_host,) = get_configuration_value([{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}])
return bool(email_host)
def generate_email_verification_code(self, request):
"""
Generate and send a magic code to the new email address for verification.
@ -248,6 +254,33 @@ class UserEndpoint(BaseViewSet):
serialized_data = UserMeSerializer(user).data
return Response(serialized_data, status=status.HTTP_200_OK)
def update_email_without_verification(self, request):
"""
Update the current user's email when the instance has no SMTP configured.
Verified SMTP-backed installations must continue using the magic-code flow.
"""
if self._is_smtp_configured():
return Response(
{"error": "Email verification is required when SMTP is configured"},
status=status.HTTP_400_BAD_REQUEST,
)
user = self.get_object()
new_email = request.data.get("email", "").strip().lower()
validation_error = self._validate_new_email(user, new_email)
if validation_error:
return validation_error
user.email = new_email
user.is_email_verified = False
user.save()
logout(request)
serialized_data = UserMeSerializer(user).data
return Response(serialized_data, status=status.HTTP_200_OK)
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()

View File

@ -219,6 +219,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
<div className="group/kanban-block relative mb-2">
<div
data-active={isActive}
data-priority={issue.priority ?? "none"}
className="nodedc-external-card relative flex min-h-[220px] w-full cursor-pointer flex-col p-4 transition-all hover:bg-white/5"
role="button"
tabIndex={0}

View File

@ -59,6 +59,7 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
>
<div
data-active={isActive}
data-priority={issue.priority ?? "none"}
className={cn(
"nodedc-external-card relative flex min-h-[15rem] cursor-pointer flex-col gap-5 px-6 py-5 transition-all hover:bg-white/5",
{ "ring-0": isActive }

View File

@ -22,7 +22,7 @@ import { useUser } from "@/hooks/store/user";
import { AuthService } from "@/services/auth.service";
import userService from "@/services/user.service";
type Props = { isOpen: boolean; onClose: () => void };
type Props = { isOpen: boolean; onClose: () => void; isSMTPConfigured?: boolean };
type TModalStep = "EMAIL" | "UNIQUE_CODE";
type TUniqueCodeValuesForm = { email: string; code: string };
@ -33,7 +33,7 @@ const defaultValues: TUniqueCodeValuesForm = { email: "", code: "" };
const authService = new AuthService();
export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props) {
const { isOpen, onClose } = props;
const { isOpen, onClose, isSMTPConfigured = true } = props;
// states
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
// store hooks
@ -107,6 +107,20 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
return;
}
if (!isSMTPConfigured) {
await userService.updateEmailDirect({ email: formData.email });
setToast({
type: TOAST_TYPE.SUCCESS,
title: changeEmailT("toasts.success_title"),
message: changeEmailT("toasts.success_message"),
});
await handleSignOut();
handleClose();
return;
}
// Generate verification code and send to new email
await userService.generateEmailCode({ email: formData.email });
@ -135,7 +149,9 @@ export const ChangeEmailModal = observer(function ChangeEmailModal(props: Props)
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="space-y-0 px-4 py-4">
<h3 className="text-16 leading-6 font-medium text-primary">{changeEmailT("title")}</h3>
<p className="my-4 text-13 text-secondary">{changeEmailT("description")}</p>
<p className="my-4 text-13 text-secondary">
{isSMTPConfigured ? changeEmailT("description") : changeEmailT("direct_description")}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 px-4" noValidate>
<div className="flex flex-col gap-1">

View File

@ -225,6 +225,7 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar
>
<NodedcWorkItemCard
isActive={isActive}
priority={issue.priority}
surfaceClassName={cn(
"nodedc-home-task-card-surface px-0",
compact && "!rounded-[24px]",
@ -276,6 +277,7 @@ const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCar
>
<div
data-active={isActive}
data-priority={issue.priority ?? "none"}
className={cn(
"nodedc-external-card nodedc-home-task-card-surface relative flex w-full flex-col",
compact ? "min-h-[168px] rounded-[24px] p-3" : "min-h-[220px] p-4",

View File

@ -74,6 +74,7 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In
>
<NodedcWorkItemCard
isActive={isActive}
priority={issue.priority}
surfaceClassName="transition-transform duration-200 hover:-translate-y-0.5"
header={
<div className="flex items-start justify-between gap-3">

View File

@ -215,6 +215,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
return (
<NodedcWorkItemCard
isActive={isActive}
priority={issue.priority}
surfaceClassName="!rounded-[24px] !p-0"
contentClassName="min-h-[220px]"
header={header}

View File

@ -5,10 +5,12 @@
*/
import type { ReactNode } from "react";
import type { TIssuePriorities } from "@plane/types";
import { cn } from "@plane/utils";
type TNodedcWorkItemCardProps = {
isActive: boolean;
priority?: TIssuePriorities | null;
header: ReactNode;
title: ReactNode;
footer: ReactNode;
@ -37,6 +39,7 @@ export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
export const NodedcWorkItemCard = ({
isActive,
priority,
header,
title,
footer,
@ -52,6 +55,8 @@ export const NodedcWorkItemCard = ({
return (
<div
data-active={isActive}
data-priority={priority ?? "none"}
className={cn(
"nodedc-work-item-card rounded-[28px] border-0 p-4 shadow-none ring-0 outline-none",
appearance.surfaceClassName,

View File

@ -191,7 +191,11 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
return (
<>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
<ChangeEmailModal
isOpen={isChangeEmailModalOpen}
onClose={() => setIsChangeEmailModalOpen(false)}
isSMTPConfigured={isSMTPConfigured}
/>
<Controller
control={control}
name="avatar_url"
@ -371,23 +375,19 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin
ref={ref}
hasError={Boolean(errors.email)}
placeholder={t("profile_general.email_placeholder")}
className={`nodedc-settings-input w-full cursor-not-allowed !bg-white/4 ${
errors.email ? "border-danger-strong" : ""
}`}
className={`nodedc-settings-input w-full !bg-white/4 ${errors.email ? "border-danger-strong" : ""}`}
autoComplete="on"
disabled
/>
)}
/>
{isSMTPConfigured && (
<button
type="button"
className="nodedc-settings-chip flex w-fit items-center gap-2 px-3.5 py-1.5 text-12 font-medium text-primary"
onClick={() => setIsChangeEmailModalOpen(true)}
>
{t("account_settings.profile.change_email_modal.title")}
</button>
)}
<button
type="button"
className="nodedc-settings-chip flex w-fit items-center gap-2 px-3.5 py-1.5 text-12 font-medium text-primary"
onClick={() => setIsChangeEmailModalOpen(true)}
>
{t("account_settings.profile.change_email_modal.title")}
</button>
</div>
</div>
</div>

View File

@ -85,7 +85,10 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
() => fetchProjectDetails(workspaceSlug, projectId)
);
// fetching user project member information
useSWR(PROJECT_ME_INFORMATION(workspaceSlug, projectId), () => fetchUserProjectInfo(workspaceSlug, projectId));
const { isLoading: isProjectMemberLoading, error: projectMemberError } = useSWR(
PROJECT_ME_INFORMATION(workspaceSlug, projectId),
() => fetchUserProjectInfo(workspaceSlug, projectId)
);
// fetching project member preferences
useSWR(
currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(projectId, currentProjectRole) : null,
@ -142,14 +145,17 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
joinProject(workspaceSlug, projectId).finally(() => setIsJoiningProject(false));
};
const isProjectLoading = (isParentLoading || isProjectDetailsLoading) && !projectDetailsError;
const isProjectLoading =
(isParentLoading || isProjectDetailsLoading || isProjectMemberLoading) && !projectDetailsError && !projectMemberError;
const accessErrorStatus =
projectDetailsError?.status ?? (projectMemberError?.status === 404 ? 403 : projectMemberError?.status);
if (isProjectLoading) return null;
if (!isProjectLoading && hasPermissionToCurrentProject === false) {
return (
<ProjectAccessRestriction
errorStatusCode={projectDetailsError?.status}
errorStatusCode={accessErrorStatus}
isWorkspaceAdmin={isWorkspaceAdmin}
handleJoinProject={handleJoinProject}
isJoinButtonDisabled={isJoiningProject}

View File

@ -297,6 +297,14 @@ export class UserService extends APIService {
throw error?.response?.data;
});
}
async updateEmailDirect(data: { email: string }): Promise<IUser> {
return this.patch("/api/users/me/email/direct/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const userService = new UserService();

View File

@ -33,6 +33,16 @@
--nodedc-on-card-passive-rgb: 245 247 251;
--nodedc-card-active-rgb: 195 255 102;
--nodedc-on-card-active-rgb: 11 17 23;
--nodedc-priority-none-rgb: 124 128 138;
--nodedc-priority-none-mix-rgb: 255 255 255;
--nodedc-priority-low-rgb: 51 163 255;
--nodedc-priority-low-mix-rgb: 255 199 95;
--nodedc-priority-medium-rgb: 255 213 79;
--nodedc-priority-medium-mix-rgb: 80 126 255;
--nodedc-priority-high-rgb: 255 136 48;
--nodedc-priority-high-mix-rgb: 58 190 255;
--nodedc-priority-urgent-rgb: 255 68 82;
--nodedc-priority-urgent-mix-rgb: 48 214 188;
--nodedc-bottom-dock-height: 2.75rem;
--nodedc-bottom-dock-offset: 2.75rem;
--nodedc-bottom-dock-visual-overlap: 0.625rem;
@ -215,6 +225,144 @@
animation: nodedcDropFillHighlight 1.6s ease-out;
}
.nodedc-work-item-card,
.nodedc-external-card {
--nodedc-priority-card-rgb: var(--nodedc-priority-none-rgb);
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-none-mix-rgb);
--nodedc-priority-card-border-opacity: 0.08;
--nodedc-priority-card-fill-opacity: 0;
--nodedc-priority-card-glow-opacity: 0;
position: relative;
overflow: hidden;
isolation: isolate;
box-shadow:
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
0 12px 28px rgba(0, 0, 0, 0.2),
0 0 18px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
}
.nodedc-work-item-card[data-priority="low"],
.nodedc-external-card[data-priority="low"] {
--nodedc-priority-card-rgb: var(--nodedc-priority-low-rgb);
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-low-mix-rgb);
--nodedc-priority-card-border-opacity: 0.42;
--nodedc-priority-card-fill-opacity: 0.46;
--nodedc-priority-card-glow-opacity: 0.12;
}
.nodedc-work-item-card[data-priority="medium"],
.nodedc-external-card[data-priority="medium"] {
--nodedc-priority-card-rgb: var(--nodedc-priority-medium-rgb);
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-medium-mix-rgb);
--nodedc-priority-card-border-opacity: 0.46;
--nodedc-priority-card-fill-opacity: 0.5;
--nodedc-priority-card-glow-opacity: 0.13;
}
.nodedc-work-item-card[data-priority="high"],
.nodedc-external-card[data-priority="high"] {
--nodedc-priority-card-rgb: var(--nodedc-priority-high-rgb);
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-high-mix-rgb);
--nodedc-priority-card-border-opacity: 0.5;
--nodedc-priority-card-fill-opacity: 0.54;
--nodedc-priority-card-glow-opacity: 0.15;
}
.nodedc-work-item-card[data-priority="urgent"],
.nodedc-external-card[data-priority="urgent"] {
--nodedc-priority-card-rgb: var(--nodedc-priority-urgent-rgb);
--nodedc-priority-card-mix-rgb: var(--nodedc-priority-urgent-mix-rgb);
--nodedc-priority-card-border-opacity: 0.62;
--nodedc-priority-card-fill-opacity: 0.62;
--nodedc-priority-card-glow-opacity: 0.2;
}
.nodedc-work-item-card[data-active="true"],
.nodedc-external-card[data-active="true"] {
box-shadow:
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
0 14px 32px rgba(0, 0, 0, 0.22),
0 0 20px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
}
.nodedc-work-item-card::before,
.nodedc-external-card::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: inherit;
background:
radial-gradient(
120% 90% at 50% 118%,
rgb(var(--nodedc-priority-card-rgb) / 0.74) 0%,
rgb(var(--nodedc-priority-card-rgb) / 0.28) 30%,
transparent 64%
),
radial-gradient(
105% 78% at 50% -26%,
rgb(var(--nodedc-priority-card-mix-rgb) / 0.3) 0%,
transparent 62%
),
radial-gradient(
62% 70% at -18% 50%,
rgb(var(--nodedc-priority-card-rgb) / 0.24) 0%,
transparent 68%
),
radial-gradient(
62% 70% at 118% 50%,
rgb(var(--nodedc-priority-card-mix-rgb) / 0.22) 0%,
transparent 68%
);
opacity: var(--nodedc-priority-card-fill-opacity);
mask: radial-gradient(72% 58% at 50% 50%, transparent 0%, transparent 52%, rgba(0, 0, 0, 0.42) 68%, #000 100%);
-webkit-mask: radial-gradient(
72% 58% at 50% 50%,
transparent 0%,
transparent 52%,
rgba(0, 0, 0, 0.42) 68%,
#000 100%
);
}
.nodedc-work-item-card::after,
.nodedc-external-card::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
padding: 1px;
pointer-events: none;
border-radius: inherit;
background:
linear-gradient(
145deg,
rgb(var(--nodedc-priority-card-rgb) / 0.82) 0%,
rgb(var(--nodedc-priority-card-mix-rgb) / 0.44) 32%,
rgba(255, 255, 255, 0.18) 48%,
rgb(var(--nodedc-priority-card-rgb) / 0.34) 100%
);
opacity: var(--nodedc-priority-card-border-opacity);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.nodedc-work-item-card > *,
.nodedc-external-card > * {
position: relative;
z-index: 1;
}
/* Progress Bar Styles */
:root {
--bprogress-color: var(--background-color-accent-primary);
@ -1384,6 +1532,25 @@
color: rgba(11, 17, 23, 0.72) !important;
}
.nodedc-work-item-card,
.nodedc-external-card {
box-shadow:
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
0 12px 28px rgba(0, 0, 0, 0.2),
0 0 18px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
}
.nodedc-work-item-card[data-active="true"],
.nodedc-external-card[data-active="true"] {
box-shadow:
0 0 0 1px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-border-opacity)),
0 14px 32px rgba(0, 0, 0, 0.22),
0 0 20px rgb(var(--nodedc-priority-card-rgb) / var(--nodedc-priority-card-glow-opacity)),
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.06) !important;
}
.nodedc-external-content-shell {
border: 0 !important;
outline: none !important;

View File

@ -1569,6 +1569,8 @@ export default {
change_email_modal: {
title: "Change email",
description: "Enter a new email address to receive a verification link.",
direct_description:
"Enter a new email address. SMTP is not configured, so the address will be changed directly and you will need to sign in again.",
toasts: {
success_title: "Success!",
success_message: "Email updated successfully. Please sign in again.",

View File

@ -1733,6 +1733,8 @@ export default {
change_email_modal: {
title: "Изменить email",
description: "Введите новый адрес электронной почты, чтобы получить ссылку для подтверждения.",
direct_description:
"Введите новый адрес электронной почты. SMTP не настроен, поэтому адрес будет изменен напрямую, после сохранения потребуется войти снова.",
toasts: {
success_title: "Успех!",
success_message: "Email успешно обновлён. Пожалуйста, войдите снова.",