From 4ed63cac4ed59ef62f24380e68d16d1f27018b20 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Apr 2026 14:41:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C,=20=D1=80=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plane-app/docker-compose.yaml | 4 +- plane-src/apps/api/plane/app/urls/user.py | 5 + .../apps/api/plane/app/views/user/base.py | 33 ++++ .../projects/external-contours/board-item.tsx | 1 + .../projects/external-contours/list-item.tsx | 1 + .../core/modals/change-email-modal.tsx | 22 ++- .../home/home-recent-issue-decks.tsx | 2 + .../inbox/sidebar/inbox-list-item.tsx | 1 + .../kanban/internal-contour-card.tsx | 1 + .../shared/nodedc-work-item-card.tsx | 5 + .../profile/content/pages/general/form.tsx | 26 +-- .../layouts/auth-layout/project-wrapper.tsx | 12 +- .../apps/web/core/services/user.service.ts | 8 + plane-src/apps/web/styles/globals.css | 167 ++++++++++++++++++ .../i18n/src/locales/en/translations.ts | 2 + .../i18n/src/locales/ru/translations.ts | 2 + 16 files changed, 271 insertions(+), 21 deletions(-) diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 60517af..6395033 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -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:-} diff --git a/plane-src/apps/api/plane/app/urls/user.py b/plane-src/apps/api/plane/app/urls/user.py index bc110a2..ebc2ea5 100644 --- a/plane-src/apps/api/plane/app/urls/user.py +++ b/plane-src/apps/api/plane/app/urls/user.py @@ -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 diff --git a/plane-src/apps/api/plane/app/views/user/base.py b/plane-src/apps/api/plane/app/views/user/base.py index 914dffb..cd58d74 100644 --- a/plane-src/apps/api/plane/app/views/user/base.py +++ b/plane-src/apps/api/plane/app/views/user/base.py @@ -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() diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index 84ba33b..4be5db9 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -219,6 +219,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
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("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)

{changeEmailT("title")}

-

{changeEmailT("description")}

+

+ {isSMTPConfigured ? changeEmailT("description") : changeEmailT("direct_description")} +

diff --git a/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx b/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx index 8ad5f1c..75e24ea 100644 --- a/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx +++ b/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx @@ -225,6 +225,7 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar >
diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx index 0b50c48..3ff9780 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx @@ -215,6 +215,7 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban return ( ({ export const NodedcWorkItemCard = ({ isActive, + priority, header, title, footer, @@ -52,6 +55,8 @@ export const NodedcWorkItemCard = ({ return (
setDeactivateAccountModal(false)} /> - setIsChangeEmailModalOpen(false)} /> + setIsChangeEmailModalOpen(false)} + isSMTPConfigured={isSMTPConfigured} + /> )} /> - {isSMTPConfigured && ( - - )} +
diff --git a/plane-src/apps/web/core/layouts/auth-layout/project-wrapper.tsx b/plane-src/apps/web/core/layouts/auth-layout/project-wrapper.tsx index 1c4fac1..707299e 100644 --- a/plane-src/apps/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/plane-src/apps/web/core/layouts/auth-layout/project-wrapper.tsx @@ -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 ( { + return this.patch("/api/users/me/email/direct/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } const userService = new UserService(); diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 17da84e..3751d7f 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -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; diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index 3ee5b66..00bb8c8 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -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.", diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index f372fc6..7234877 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -1733,6 +1733,8 @@ export default { change_email_modal: { title: "Изменить email", description: "Введите новый адрес электронной почты, чтобы получить ссылку для подтверждения.", + direct_description: + "Введите новый адрес электронной почты. SMTP не настроен, поэтому адрес будет изменен напрямую, после сохранения потребуется войти снова.", toasts: { success_title: "Успех!", success_message: "Email успешно обновлён. Пожалуйста, войдите снова.",