[WEB-5430] feat: allow users to change email (#8120)
* feat: change user email * chore: optimised the logic * feat: add email change functionality and related modals in profile form * refactor: format checkEmail method for improved readability * chore: added rate limit exceeded validation * feat: implement change email modal with localization support - Added translation support for the change email modal, including titles, descriptions, and error messages. - Integrated the useTranslation hook for dynamic text rendering. - Updated form validation messages to utilize localized strings. - Enhanced user feedback with localized success and error toast messages. - Updated button labels and placeholders to reflect localization changes. * chore: added extra validation in cache key * fix: format files --------- Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com> Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
d6fce114d6
commit
ce6299937f
27 changed files with 2457 additions and 5 deletions
|
|
@ -30,6 +30,16 @@ urlpatterns = [
|
|||
UserEndpoint.as_view({"get": "retrieve_user_settings"}),
|
||||
name="users",
|
||||
),
|
||||
path(
|
||||
"users/me/email/generate-code/",
|
||||
UserEndpoint.as_view({"post": "generate_email_verification_code"}),
|
||||
name="user-email-verify-code",
|
||||
),
|
||||
path(
|
||||
"users/me/email/",
|
||||
UserEndpoint.as_view({"patch": "update_email"}),
|
||||
name="user-email-update",
|
||||
),
|
||||
# Profile
|
||||
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
|
||||
# End profile
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
# Python imports
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Case, Count, IntegerField, Q, When
|
||||
from django.contrib.auth import logout
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from django.core.validators import validate_email
|
||||
from django.core.cache import cache
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
|
|
@ -36,9 +46,11 @@ from plane.utils.paginator import BasePaginator
|
|||
from plane.authentication.utils.host import user_ip
|
||||
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
|
||||
from plane.utils.host import base_host
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
|
||||
from plane.authentication.rate_limit import EmailVerificationThrottle
|
||||
|
||||
|
||||
logger = logging.getLogger("plane")
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
|
|
@ -49,6 +61,14 @@ class UserEndpoint(BaseViewSet):
|
|||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def get_throttles(self):
|
||||
"""
|
||||
Apply rate limiting to specific endpoints.
|
||||
"""
|
||||
if self.action == "generate_email_verification_code":
|
||||
return [EmailVerificationThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
@method_decorator(cache_control(private=True, max_age=12))
|
||||
@method_decorator(vary_on_cookie)
|
||||
def retrieve(self, request):
|
||||
|
|
@ -69,6 +89,169 @@ class UserEndpoint(BaseViewSet):
|
|||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def _validate_new_email(self, user, new_email):
|
||||
"""
|
||||
Validate the new email address.
|
||||
|
||||
Args:
|
||||
user: The User instance
|
||||
new_email: The new email address to validate
|
||||
|
||||
Returns:
|
||||
Response object with error if validation fails, None if validation passes
|
||||
"""
|
||||
if not new_email:
|
||||
return Response(
|
||||
{"error": "Email is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate email format
|
||||
try:
|
||||
validate_email(new_email)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Invalid email format"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email is the same as current email
|
||||
if new_email == user.email:
|
||||
return Response(
|
||||
{"error": "New email must be different from current email"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email already exists in the User model
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return Response(
|
||||
{"error": "An account with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def generate_email_verification_code(self, request):
|
||||
"""
|
||||
Generate and send a magic code to the new email address for verification.
|
||||
Rate limited to 3 requests per hour per user (enforced by EmailVerificationThrottle).
|
||||
Additional per-email cooldown of 60 seconds prevents rapid repeated requests.
|
||||
"""
|
||||
user = self.get_object()
|
||||
new_email = request.data.get("email", "").strip().lower()
|
||||
|
||||
# Validate the new email
|
||||
validation_error = self._validate_new_email(user, new_email)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
try:
|
||||
# Generate magic code for email verification
|
||||
# Use a special key prefix to distinguish from regular magic signin
|
||||
# Include user ID to bind the code to the specific user
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
)
|
||||
# Store in cache with 10 minute expiration
|
||||
cache_data = json.dumps({"token": token})
|
||||
cache.set(cache_key, cache_data, timeout=600)
|
||||
|
||||
# Send magic code to the new email
|
||||
send_email_update_magic_code.delay(new_email, token)
|
||||
|
||||
return Response(
|
||||
{"message": "Verification code sent to email"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate verification code: %s", str(e), exc_info=True)
|
||||
return Response(
|
||||
{"error": "Failed to generate verification code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def update_email(self, request):
|
||||
"""
|
||||
Verify the magic code and update the user's email address.
|
||||
This endpoint verifies the code and updates the existing user record
|
||||
without creating a new user, ensuring the user ID remains unchanged.
|
||||
"""
|
||||
user = self.get_object()
|
||||
new_email = request.data.get("email", "").strip().lower()
|
||||
code = request.data.get("code", "").strip()
|
||||
|
||||
# Validate the new email
|
||||
validation_error = self._validate_new_email(user, new_email)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"error": "Verification code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify the magic code
|
||||
try:
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
logger.warning("Cache key not found: %s. Code may have expired or was never generated.", cache_key)
|
||||
return Response(
|
||||
{"error": "Verification code has expired or is invalid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
data = json.loads(cached_data)
|
||||
stored_token = data.get("token")
|
||||
|
||||
if str(stored_token) != str(code):
|
||||
return Response(
|
||||
{"error": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": "Failed to verify code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Final check: ensure email is still available (might have been taken between code generation and update)
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return Response(
|
||||
{"error": "An account with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
old_email = user.email
|
||||
# Update the email - this updates the existing user record without creating a new user
|
||||
user.email = new_email
|
||||
# Reset email verification status when email is changed
|
||||
user.is_email_verified = False
|
||||
user.save()
|
||||
|
||||
# delete the cache
|
||||
cache.delete(cache_key)
|
||||
|
||||
# Logout the user
|
||||
logout(request)
|
||||
|
||||
# Send confirmation email to the new email address
|
||||
send_email_update_confirmation.delay(new_email)
|
||||
# send the email to the old email address
|
||||
send_email_update_confirmation.delay(old_email)
|
||||
|
||||
# Return updated user data
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Third party imports
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
|
@ -22,3 +22,22 @@ class AuthenticationThrottle(AnonRateThrottle):
|
|||
)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
|
||||
class EmailVerificationThrottle(UserRateThrottle):
|
||||
"""
|
||||
Throttle for email verification code generation.
|
||||
Limits to 3 requests per hour per user to prevent abuse.
|
||||
"""
|
||||
|
||||
rate = "3/hour"
|
||||
scope = "email_verification"
|
||||
|
||||
def throttle_failure_view(self, request, *args, **kwargs):
|
||||
try:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
|
||||
error_message="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
|
|
|||
110
apps/api/plane/bgtasks/user_email_update_task.py
Normal file
110
apps/api/plane/bgtasks/user_email_update_task.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_magic_code(email, token):
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the mail
|
||||
subject = "Verify your new email address"
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_confirmation(email):
|
||||
"""
|
||||
Send a confirmation email to the user after their email address has been successfully updated.
|
||||
|
||||
Args:
|
||||
email: The new email address that was successfully updated
|
||||
"""
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the confirmation email
|
||||
subject = "Plane email address successfully updated"
|
||||
context = {"email": email}
|
||||
|
||||
html_content = render_to_string("emails/user/email_updated.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info(f"Email update confirmation sent successfully to {email}.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
1058
apps/api/templates/emails/user/email_updated.html
Normal file
1058
apps/api/templates/emails/user/email_updated.html
Normal file
File diff suppressed because it is too large
Load diff
247
apps/web/core/components/core/modals/change-email-modal.tsx
Normal file
247
apps/web/core/components/core/modals/change-email-modal.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import userService from "@/services/user.service";
|
||||
|
||||
type Props = { isOpen: boolean; onClose: () => void };
|
||||
|
||||
type TModalStep = "EMAIL" | "UNIQUE_CODE";
|
||||
type TUniqueCodeValuesForm = { email: string; code: string };
|
||||
|
||||
const defaultValues: TUniqueCodeValuesForm = { email: "", code: "" };
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
export const ChangeEmailModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
|
||||
// store hooks
|
||||
const { signOut } = useUser();
|
||||
const { t } = useTranslation();
|
||||
const changeEmailT = (path: string) => t(`account_settings.profile.change_email_modal.${path}`);
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setError,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TUniqueCodeValuesForm>({ defaultValues });
|
||||
|
||||
const secondStep = currentStep === "UNIQUE_CODE";
|
||||
|
||||
const handleClose = () => {
|
||||
reset({ ...defaultValues });
|
||||
setCurrentStep("EMAIL");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
message: t("sign_out.toast.error.message"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: TUniqueCodeValuesForm) => {
|
||||
if (currentStep === "UNIQUE_CODE") {
|
||||
// Step 2: Verify the code and update email
|
||||
try {
|
||||
await userService.verifyEmailCode({ email: formData.email, code: formData.code });
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: changeEmailT("toasts.success_title"),
|
||||
message: changeEmailT("toasts.success_message"),
|
||||
});
|
||||
|
||||
// Sign out the user after successful email update
|
||||
await handleSignOut();
|
||||
handleClose();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
(error as { error?: string; message?: string })?.error ||
|
||||
(error as { error?: string; message?: string })?.message ||
|
||||
changeEmailT("form.code.errors.invalid");
|
||||
setError("code", { type: "custom", message: errorMessage });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Check email and generate verification code
|
||||
try {
|
||||
// Get CSRF token
|
||||
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
|
||||
if (!csrfToken) throw new Error("CSRF token not found");
|
||||
|
||||
// Check if email is available
|
||||
const emailCheckResponse = await userService.checkEmail(csrfToken, formData.email);
|
||||
|
||||
// Check if email already exists
|
||||
if (emailCheckResponse?.existing === true) {
|
||||
setError("email", { type: "custom", message: changeEmailT("form.email.errors.exists") });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate verification code and send to new email
|
||||
await userService.generateEmailCode({ email: formData.email });
|
||||
|
||||
// Move to verification code step
|
||||
setCurrentStep("UNIQUE_CODE");
|
||||
} catch (error: unknown) {
|
||||
// Extract error code and message from backend response
|
||||
const err = error as { error_code?: number | string; error_message?: string };
|
||||
const errorCode = err?.error_code?.toString();
|
||||
|
||||
// Use authErrorHandler to get user-friendly error message
|
||||
const errorInfo = errorCode ? authErrorHandler(errorCode as EAuthenticationErrorCodes) : undefined;
|
||||
|
||||
// Get error message from handler or fallback
|
||||
const errorMessage = errorInfo
|
||||
? typeof errorInfo.message === "string"
|
||||
? errorInfo.message
|
||||
: String(errorInfo.message)
|
||||
: err?.error_message || changeEmailT("form.email.errors.validation_failed");
|
||||
|
||||
setError("email", { type: "custom", message: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 transition-opacity bg-custom-backdrop" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="overflow-y-auto fixed inset-0 z-30">
|
||||
<div className="flex justify-center items-center p-4 min-h-full text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-4 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="py-4 space-y-0">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{changeEmailT("title")}
|
||||
</Dialog.Title>
|
||||
<p className="my-4 text-sm text-custom-text-200">{changeEmailT("description")}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="flex flex-col gap-1">
|
||||
{secondStep && (
|
||||
<h4 className="text-sm font-medium text-custom-text-200">{changeEmailT("form.email.label")}</h4>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: changeEmailT("form.email.errors.required"),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: changeEmailT("form.email.errors.invalid"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder={changeEmailT("form.email.placeholder")}
|
||||
className={cn(
|
||||
{ "border-red-500": errors.email },
|
||||
{ "cursor-not-allowed !bg-custom-background-90": secondStep }
|
||||
)}
|
||||
disabled={secondStep}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.email && <span className="text-xs text-red-500">{errors?.email?.message}</span>}
|
||||
</div>
|
||||
|
||||
{secondStep && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">{changeEmailT("form.code.label")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="code"
|
||||
rules={{ required: changeEmailT("form.code.errors.required") }}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder={changeEmailT("form.code.placeholder")}
|
||||
className={cn({ "border-red-500": errors.code })}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.code ? (
|
||||
<span className="text-xs text-red-500">{errors?.code?.message}</span>
|
||||
) : (
|
||||
<span className="text-xs text-green-700">{changeEmailT("form.code.helper_text")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 py-4">
|
||||
<Button type="button" variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{changeEmailT("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? changeEmailT("states.sending")
|
||||
: secondStep
|
||||
? changeEmailT("actions.confirm")
|
||||
: changeEmailT("actions.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
|
|
@ -17,10 +17,12 @@ import { cn, getFileURL } from "@plane/utils";
|
|||
// components
|
||||
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
|
||||
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
|
||||
// helpers
|
||||
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type TUserProfileForm = {
|
||||
|
|
@ -49,6 +51,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
|
||||
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
|
||||
const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false);
|
||||
// language support
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
|
|
@ -78,6 +81,9 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
|||
// store hooks
|
||||
const { data: currentUser, updateCurrentUser } = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { config } = useInstance();
|
||||
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
|
||||
const handleProfilePictureDelete = async (url: string | null | undefined) => {
|
||||
if (!url) return;
|
||||
|
|
@ -156,6 +162,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
|||
return (
|
||||
<>
|
||||
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
|
||||
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatar_url"
|
||||
|
|
@ -355,6 +362,15 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
{isSMTPConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline btn w-fit text-custom-text-200"
|
||||
onClick={() => setIsChangeEmailModalOpen(true)}
|
||||
>
|
||||
{t("change_email")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
IUserEmailNotificationSettings,
|
||||
TIssuesResponse,
|
||||
TUserProfile,
|
||||
IEmailCheckResponse,
|
||||
} from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
|
|
@ -258,6 +259,38 @@ export class UserService extends APIService {
|
|||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async checkEmail(token: string, email: string): Promise<IEmailCheckResponse> {
|
||||
return this.post(
|
||||
"/auth/email-check/",
|
||||
{ email },
|
||||
{
|
||||
headers: {
|
||||
"X-CSRFTOKEN": token,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async generateEmailCode(data: { email: string }): Promise<any> {
|
||||
return this.post("/api/users/me/email/generate-code/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmailCode(data: { email: string; code: string }): Promise<any> {
|
||||
return this.patch("/api/users/me/email/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userService = new UserService();
|
||||
|
|
|
|||
|
|
@ -1522,6 +1522,47 @@ export default {
|
|||
"Pokud potvrdíte, všechny možnosti řazení, filtrování a zobrazení + rozvržení, které jste vybrali pro tento pohled, budou trvale odstraněny a nelze je obnovit.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Změnit e-mail",
|
||||
description: "Zadejte novou e-mailovou adresu a obdržíte ověřovací odkaz.",
|
||||
toasts: {
|
||||
success_title: "Úspěch!",
|
||||
success_message: "E-mail byl úspěšně aktualizován. Přihlaste se znovu.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nový e-mail",
|
||||
placeholder: "Zadejte svůj e-mail",
|
||||
errors: {
|
||||
required: "E-mail je povinný",
|
||||
invalid: "E-mail je neplatný",
|
||||
exists: "E-mail již existuje. Použijte jiný.",
|
||||
validation_failed: "Ověření e-mailu se nezdařilo. Zkuste to znovu.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Ověřovací kód byl odeslán na váš nový e-mail.",
|
||||
errors: {
|
||||
required: "Jedinečný kód je povinný",
|
||||
invalid: "Neplatný ověřovací kód. Zkuste to znovu.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Pokračovat",
|
||||
confirm: "Potvrdit",
|
||||
cancel: "Zrušit",
|
||||
},
|
||||
states: {
|
||||
sending: "Odesílání…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Nastavení pracovního prostoru",
|
||||
page_label: "{workspace} - Obecná nastavení",
|
||||
|
|
|
|||
|
|
@ -1540,6 +1540,47 @@ export default {
|
|||
"Wenn Sie bestätigen, werden alle Sortier-, Filter- und Anzeigeoptionen + das Layout, das Sie für diese Ansicht gewählt haben, dauerhaft gelöscht und können nicht wiederhergestellt werden.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "E-Mail ändern",
|
||||
description: "Gib eine neue E-Mail-Adresse ein, um einen Verifizierungslink zu erhalten.",
|
||||
toasts: {
|
||||
success_title: "Erfolg!",
|
||||
success_message: "E-Mail erfolgreich aktualisiert. Bitte melde dich erneut an.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Neue E-Mail",
|
||||
placeholder: "Gib deine E-Mail ein",
|
||||
errors: {
|
||||
required: "E-Mail ist erforderlich",
|
||||
invalid: "E-Mail ist ungültig",
|
||||
exists: "E-Mail existiert bereits. Bitte nutze eine andere.",
|
||||
validation_failed: "E-Mail-Verifizierung fehlgeschlagen. Bitte versuche es erneut.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Einmaliger Code",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Verifizierungscode wurde an deine neue E-Mail gesendet.",
|
||||
errors: {
|
||||
required: "Einmaliger Code ist erforderlich",
|
||||
invalid: "Ungültiger Verifizierungscode. Bitte versuche es erneut.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Weiter",
|
||||
confirm: "Bestätigen",
|
||||
cancel: "Abbrechen",
|
||||
},
|
||||
states: {
|
||||
sending: "Wird gesendet…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Arbeitsbereich-Einstellungen",
|
||||
page_label: "{workspace} - Allgemeine Einstellungen",
|
||||
|
|
|
|||
|
|
@ -1357,7 +1357,45 @@ export default {
|
|||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {},
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Change email",
|
||||
description: "Enter a new email address to receive a verification link.",
|
||||
toasts: {
|
||||
success_title: "Success!",
|
||||
success_message: "Email updated successfully. Please sign in again.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "New email",
|
||||
placeholder: "Enter your email",
|
||||
errors: {
|
||||
required: "Email is required",
|
||||
invalid: "Email is invalid",
|
||||
exists: "Email already exists. Please use a different one.",
|
||||
validation_failed: "Email validation failed. Please try again.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Unique code",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Verification code sent to your new email.",
|
||||
errors: {
|
||||
required: "Unique code is required",
|
||||
invalid: "Invalid verification code. Please try again.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continue",
|
||||
confirm: "Confirm",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
states: {
|
||||
sending: "Sending",
|
||||
},
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
heading: "Preferences",
|
||||
description: "Customize your app experience the way you work",
|
||||
|
|
|
|||
|
|
@ -1544,6 +1544,47 @@ export default {
|
|||
"Si confirmas, todas las opciones de ordenación, filtro y visualización + el diseño que has elegido para esta vista se eliminarán permanentemente sin posibilidad de restaurarlas.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Cambiar correo electrónico",
|
||||
description: "Introduce una nueva dirección de correo electrónico para recibir un enlace de verificación.",
|
||||
toasts: {
|
||||
success_title: "¡Éxito!",
|
||||
success_message: "Correo electrónico actualizado correctamente. Inicia sesión de nuevo.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nuevo correo electrónico",
|
||||
placeholder: "Introduce tu correo electrónico",
|
||||
errors: {
|
||||
required: "El correo electrónico es obligatorio",
|
||||
invalid: "El correo electrónico no es válido",
|
||||
exists: "El correo electrónico ya existe. Usa uno diferente.",
|
||||
validation_failed: "La validación del correo electrónico falló. Inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Código de verificación enviado a tu nuevo correo electrónico.",
|
||||
errors: {
|
||||
required: "El código único es obligatorio",
|
||||
invalid: "Código de verificación inválido. Inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuar",
|
||||
confirm: "Confirmar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
states: {
|
||||
sending: "Enviando…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Configuración del espacio de trabajo",
|
||||
page_label: "{workspace} - Configuración general",
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,47 @@ export default {
|
|||
"Si vous confirmez, toutes les options de tri, de filtrage et d’affichage et la mise en page que vous avez choisie pour cette vue seront définitivement supprimées sans possibilité de les restaurer.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Changer d’adresse e-mail",
|
||||
description: "Saisissez une nouvelle adresse e-mail pour recevoir un lien de vérification.",
|
||||
toasts: {
|
||||
success_title: "Succès !",
|
||||
success_message: "Adresse e-mail mise à jour. Veuillez vous reconnecter.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nouvelle adresse e-mail",
|
||||
placeholder: "Saisissez votre e-mail",
|
||||
errors: {
|
||||
required: "L’e-mail est requis",
|
||||
invalid: "L’e-mail est invalide",
|
||||
exists: "Cette adresse e-mail existe déjà. Utilisez-en une autre.",
|
||||
validation_failed: "Échec de la validation de l’e-mail. Veuillez réessayer.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Code unique",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Code de vérification envoyé à votre nouvel e-mail.",
|
||||
errors: {
|
||||
required: "Le code unique est requis",
|
||||
invalid: "Code de vérification invalide. Veuillez réessayer.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuer",
|
||||
confirm: "Confirmer",
|
||||
cancel: "Annuler",
|
||||
},
|
||||
states: {
|
||||
sending: "Envoi…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Paramètres de l’espace de travail",
|
||||
page_label: "{workspace} - Paramètres généraux",
|
||||
|
|
|
|||
|
|
@ -1530,6 +1530,47 @@ export default {
|
|||
"Jika Anda mengonfirmasi, semua opsi pengurutan, filter, dan tampilan + tata letak yang telah Anda pilih untuk tampilan ini akan dihapus secara permanen tanpa cara untuk memulihkannya.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Ubah email",
|
||||
description: "Masukkan alamat email baru untuk menerima tautan verifikasi.",
|
||||
toasts: {
|
||||
success_title: "Berhasil!",
|
||||
success_message: "Email berhasil diperbarui. Silakan masuk kembali.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Email baru",
|
||||
placeholder: "Masukkan email Anda",
|
||||
errors: {
|
||||
required: "Email wajib diisi",
|
||||
invalid: "Email tidak valid",
|
||||
exists: "Email sudah ada. Gunakan yang lain.",
|
||||
validation_failed: "Validasi email gagal. Coba lagi.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Kode unik",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Kode verifikasi dikirim ke email baru Anda.",
|
||||
errors: {
|
||||
required: "Kode unik wajib diisi",
|
||||
invalid: "Kode verifikasi tidak valid. Coba lagi.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Lanjutkan",
|
||||
confirm: "Konfirmasi",
|
||||
cancel: "Batal",
|
||||
},
|
||||
states: {
|
||||
sending: "Mengirim…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Pengaturan ruang kerja",
|
||||
page_label: "{workspace} - Pengaturan Umum",
|
||||
|
|
|
|||
|
|
@ -1534,6 +1534,47 @@ export default {
|
|||
"Se confermi, tutte le opzioni di ordinamento, filtro e visualizzazione + il layout che hai scelto per questa visualizzazione saranno eliminate permanentemente senza possibilità di ripristinarle.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Cambia email",
|
||||
description: "Inserisci un nuovo indirizzo email per ricevere un link di verifica.",
|
||||
toasts: {
|
||||
success_title: "Successo!",
|
||||
success_message: "Email aggiornata con successo. Accedi di nuovo.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nuova email",
|
||||
placeholder: "Inserisci la tua email",
|
||||
errors: {
|
||||
required: "L’email è obbligatoria",
|
||||
invalid: "L’email non è valida",
|
||||
exists: "L’email esiste già. Usane un’altra.",
|
||||
validation_failed: "La verifica dell’email non è riuscita. Riprova.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Codice univoco",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Codice di verifica inviato alla tua nuova email.",
|
||||
errors: {
|
||||
required: "Il codice univoco è obbligatorio",
|
||||
invalid: "Codice di verifica non valido. Riprova.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continua",
|
||||
confirm: "Conferma",
|
||||
cancel: "Annulla",
|
||||
},
|
||||
states: {
|
||||
sending: "Invio…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Impostazioni dello spazio di lavoro",
|
||||
page_label: "{workspace} - Impostazioni generali",
|
||||
|
|
|
|||
|
|
@ -1521,6 +1521,47 @@ export default {
|
|||
"確認すると、このビューに選択したすべてのソート、フィルター、表示オプション + レイアウトが復元不可能な形で完全に削除されます。",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "メールアドレスを変更",
|
||||
description: "確認リンクを受け取るには、新しいメールアドレスを入力してください。",
|
||||
toasts: {
|
||||
success_title: "成功",
|
||||
success_message: "メールアドレスを更新しました。再度サインインしてください。",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "新しいメールアドレス",
|
||||
placeholder: "メールアドレスを入力",
|
||||
errors: {
|
||||
required: "メールアドレスは必須です",
|
||||
invalid: "メールアドレスが無効です",
|
||||
exists: "メールアドレスは既に存在します。別のものを使用してください。",
|
||||
validation_failed: "メールアドレスの確認に失敗しました。もう一度お試しください。",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "認証コード",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "認証コードを新しいメールに送信しました。",
|
||||
errors: {
|
||||
required: "認証コードは必須です",
|
||||
invalid: "認証コードが無効です。もう一度お試しください。",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "続行",
|
||||
confirm: "確認",
|
||||
cancel: "キャンセル",
|
||||
},
|
||||
states: {
|
||||
sending: "送信中…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "ワークスペース設定",
|
||||
page_label: "{workspace} - 一般設定",
|
||||
|
|
|
|||
|
|
@ -1514,6 +1514,47 @@ export default {
|
|||
"확인하면 이 뷰에 대해 선택한 모든 정렬, 필터 및 표시 옵션 + 레이아웃이 복원할 수 없는 방식으로 영구적으로 삭제됩니다.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "이메일 변경",
|
||||
description: "확인 링크를 받으려면 새 이메일 주소를 입력하세요.",
|
||||
toasts: {
|
||||
success_title: "성공!",
|
||||
success_message: "이메일이 업데이트되었습니다. 다시 로그인하세요.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "새 이메일",
|
||||
placeholder: "이메일을 입력하세요",
|
||||
errors: {
|
||||
required: "이메일은 필수입니다",
|
||||
invalid: "유효하지 않은 이메일입니다",
|
||||
exists: "이미 존재하는 이메일입니다. 다른 주소를 사용하세요.",
|
||||
validation_failed: "이메일 확인에 실패했습니다. 다시 시도하세요.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "고유 코드",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "인증 코드가 새 이메일로 전송되었습니다.",
|
||||
errors: {
|
||||
required: "고유 코드는 필수입니다",
|
||||
invalid: "잘못된 인증 코드입니다. 다시 시도하세요.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "계속",
|
||||
confirm: "확인",
|
||||
cancel: "취소",
|
||||
},
|
||||
states: {
|
||||
sending: "전송 중…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "작업 공간 설정",
|
||||
page_label: "{workspace} - 일반 설정",
|
||||
|
|
|
|||
|
|
@ -1525,6 +1525,47 @@ export default {
|
|||
"Jeśli potwierdzisz, wszystkie opcje sortowania, filtrowania i wyświetlania + układ, który wybrałeś dla tego widoku, zostaną trwale usunięte bez możliwości przywrócenia.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Zmień e-mail",
|
||||
description: "Wpisz nowy adres e-mail, aby otrzymać link weryfikacyjny.",
|
||||
toasts: {
|
||||
success_title: "Sukces!",
|
||||
success_message: "E-mail zaktualizowano. Zaloguj się ponownie.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nowy e-mail",
|
||||
placeholder: "Wpisz swój e-mail",
|
||||
errors: {
|
||||
required: "E-mail jest wymagany",
|
||||
invalid: "E-mail jest nieprawidłowy",
|
||||
exists: "E-mail już istnieje. Użyj innego.",
|
||||
validation_failed: "Weryfikacja e-maila nie powiodła się. Spróbuj ponownie.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Unikalny kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Kod weryfikacyjny wysłano na nowy e-mail.",
|
||||
errors: {
|
||||
required: "Unikalny kod jest wymagany",
|
||||
invalid: "Nieprawidłowy kod weryfikacyjny. Spróbuj ponownie.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Kontynuuj",
|
||||
confirm: "Potwierdź",
|
||||
cancel: "Anuluj",
|
||||
},
|
||||
states: {
|
||||
sending: "Wysyłanie…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Ustawienia przestrzeni roboczej",
|
||||
page_label: "{workspace} - Ustawienia ogólne",
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,47 @@ export default {
|
|||
"Se você confirmar, todas as opções de classificação, filtro e exibição + o layout que você escolheu para esta visualização serão excluídos permanentemente sem nenhuma maneira de restaurá-los.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Alterar e-mail",
|
||||
description: "Digite um novo endereço de e-mail para receber um link de verificação.",
|
||||
toasts: {
|
||||
success_title: "Sucesso!",
|
||||
success_message: "E-mail atualizado com sucesso. Faça login novamente.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Novo e-mail",
|
||||
placeholder: "Digite seu e-mail",
|
||||
errors: {
|
||||
required: "O e-mail é obrigatório",
|
||||
invalid: "O e-mail é inválido",
|
||||
exists: "O e-mail já existe. Use outro.",
|
||||
validation_failed: "Falha na validação do e-mail. Tente novamente.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Código de verificação enviado para o novo e-mail.",
|
||||
errors: {
|
||||
required: "O código único é obrigatório",
|
||||
invalid: "Código de verificação inválido. Tente novamente.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuar",
|
||||
confirm: "Confirmar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
states: {
|
||||
sending: "Enviando…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Configurações do espaço de trabalho",
|
||||
page_label: "{workspace} - Configurações gerais",
|
||||
|
|
|
|||
|
|
@ -1534,6 +1534,47 @@ export default {
|
|||
"Dacă confirmați, toate opțiunile de sortare, filtrare și afișare + aspectul pe care l-ați ales pentru această vizualizare vor fi șterse permanent fără nicio modalitate de a le restaura.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Schimbă e-mailul",
|
||||
description: "Introduceți o nouă adresă de e-mail pentru a primi un link de verificare.",
|
||||
toasts: {
|
||||
success_title: "Succes!",
|
||||
success_message: "E-mail actualizat cu succes. Conectați-vă din nou.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "E-mail nou",
|
||||
placeholder: "Introduceți e-mailul",
|
||||
errors: {
|
||||
required: "E-mailul este obligatoriu",
|
||||
invalid: "E-mailul este invalid",
|
||||
exists: "E-mailul există deja. Folosiți altul.",
|
||||
validation_failed: "Validarea e-mailului a eșuat. Încercați din nou.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Cod unic",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Codul de verificare a fost trimis la noul e-mail.",
|
||||
errors: {
|
||||
required: "Codul unic este obligatoriu",
|
||||
invalid: "Cod de verificare invalid. Încercați din nou.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuă",
|
||||
confirm: "Confirmă",
|
||||
cancel: "Anulează",
|
||||
},
|
||||
states: {
|
||||
sending: "Se trimite…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Setări spațiu de lucru",
|
||||
page_label: "{workspace} - Setări generale",
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,47 @@ export default {
|
|||
"При подтверждении все параметры сортировки, фильтрации и отображения + макет, выбранный для этого представления, будут безвозвратно удалены без возможности восстановления.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Изменить email",
|
||||
description: "Введите новый адрес электронной почты, чтобы получить ссылку для подтверждения.",
|
||||
toasts: {
|
||||
success_title: "Успех!",
|
||||
success_message: "Email успешно обновлён. Пожалуйста, войдите снова.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Новый email",
|
||||
placeholder: "Введите свой email",
|
||||
errors: {
|
||||
required: "Email обязателен",
|
||||
invalid: "Email недействителен",
|
||||
exists: "Email уже существует. Используйте другой.",
|
||||
validation_failed: "Не удалось подтвердить email. Попробуйте ещё раз.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Уникальный код",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Код подтверждения отправлен на ваш новый email.",
|
||||
errors: {
|
||||
required: "Уникальный код обязателен",
|
||||
invalid: "Неверный код подтверждения. Попробуйте ещё раз.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Продолжить",
|
||||
confirm: "Подтвердить",
|
||||
cancel: "Отмена",
|
||||
},
|
||||
states: {
|
||||
sending: "Отправка…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Настройки пространства",
|
||||
page_label: "{workspace} - Основные настройки",
|
||||
|
|
|
|||
|
|
@ -1525,6 +1525,47 @@ export default {
|
|||
"Ak potvrdíte, všetky možnosti triedenia, filtrovania a zobrazenia + rozloženie, ktoré ste vybrali pre toto zobrazenie, budú natrvalo vymazané bez možnosti obnovenia.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Zmeniť e-mail",
|
||||
description: "Zadajte novú e-mailovú adresu, aby ste dostali overovací odkaz.",
|
||||
toasts: {
|
||||
success_title: "Úspech!",
|
||||
success_message: "E-mail bol úspešne aktualizovaný. Prihláste sa znova.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nový e-mail",
|
||||
placeholder: "Zadajte svoj e-mail",
|
||||
errors: {
|
||||
required: "E-mail je povinný",
|
||||
invalid: "E-mail je neplatný",
|
||||
exists: "E-mail už existuje. Použite iný.",
|
||||
validation_failed: "Overenie e-mailu zlyhalo. Skúste znova.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Overovací kód bol odoslaný na váš nový e-mail.",
|
||||
errors: {
|
||||
required: "Jedinečný kód je povinný",
|
||||
invalid: "Neplatný overovací kód. Skúste znova.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Pokračovať",
|
||||
confirm: "Potvrdiť",
|
||||
cancel: "Zrušiť",
|
||||
},
|
||||
states: {
|
||||
sending: "Odosielanie…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Nastavenia pracovného priestoru",
|
||||
page_label: "{workspace} - Všeobecné nastavenia",
|
||||
|
|
|
|||
|
|
@ -1529,6 +1529,47 @@ export default {
|
|||
"Onaylarsanız, bu görünüm için seçtiğiniz tüm sıralama, filtreleme ve görüntüleme seçenekleri + düzen kalıcı olarak silinecek ve geri yükleme imkanı olmayacaktır.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "E-postayı değiştir",
|
||||
description: "Doğrulama bağlantısı almak için yeni bir e-posta adresi girin.",
|
||||
toasts: {
|
||||
success_title: "Başarılı!",
|
||||
success_message: "E-posta başarıyla güncellendi. Lütfen tekrar giriş yapın.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Yeni e-posta",
|
||||
placeholder: "E-postanızı girin",
|
||||
errors: {
|
||||
required: "E-posta zorunludur",
|
||||
invalid: "E-posta geçersiz",
|
||||
exists: "E-posta zaten mevcut. Başka bir tane kullanın.",
|
||||
validation_failed: "E-posta doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Benzersiz kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Doğrulama kodu yeni e-postanıza gönderildi.",
|
||||
errors: {
|
||||
required: "Benzersiz kod zorunludur",
|
||||
invalid: "Geçersiz doğrulama kodu. Lütfen tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Devam et",
|
||||
confirm: "Onayla",
|
||||
cancel: "İptal et",
|
||||
},
|
||||
states: {
|
||||
sending: "Gönderiliyor…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Çalışma Alanı Ayarları",
|
||||
page_label: "{workspace} - Genel ayarlar",
|
||||
|
|
|
|||
|
|
@ -1529,6 +1529,47 @@ export default {
|
|||
"Якщо ви підтвердите, всі параметри сортування, фільтрації та відображення + макет, який ви обрали для цього подання, будуть безповоротно видалені без можливості відновлення.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Змінити email",
|
||||
description: "Введіть нову адресу електронної пошти, щоб отримати посилання для підтвердження.",
|
||||
toasts: {
|
||||
success_title: "Успіх!",
|
||||
success_message: "Email успішно оновлено. Увійдіть знову.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Новий email",
|
||||
placeholder: "Введіть свій email",
|
||||
errors: {
|
||||
required: "Email є обов’язковим",
|
||||
invalid: "Email недійсний",
|
||||
exists: "Email уже існує. Використайте інший.",
|
||||
validation_failed: "Не вдалося підтвердити email. Спробуйте ще раз.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Унікальний код",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Код підтвердження надіслано на ваш новий email.",
|
||||
errors: {
|
||||
required: "Унікальний код є обов’язковим",
|
||||
invalid: "Недійсний код підтвердження. Спробуйте ще раз.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Продовжити",
|
||||
confirm: "Підтвердити",
|
||||
cancel: "Скасувати",
|
||||
},
|
||||
states: {
|
||||
sending: "Надсилання…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Налаштування робочого простору",
|
||||
page_label: "{workspace} - Загальні налаштування",
|
||||
|
|
|
|||
|
|
@ -1531,6 +1531,47 @@ export default {
|
|||
"Nếu bạn xác nhận, tất cả các tùy chọn sắp xếp, lọc và hiển thị + bố cục mà bạn đã chọn cho chế độ xem này sẽ bị xóa vĩnh viễn mà không có cách nào khôi phục.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Đổi email",
|
||||
description: "Nhập địa chỉ email mới để nhận liên kết xác minh.",
|
||||
toasts: {
|
||||
success_title: "Thành công!",
|
||||
success_message: "Email đã được cập nhật. Vui lòng đăng nhập lại.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Email mới",
|
||||
placeholder: "Nhập email của bạn",
|
||||
errors: {
|
||||
required: "Email là bắt buộc",
|
||||
invalid: "Email không hợp lệ",
|
||||
exists: "Email đã tồn tại. Vui lòng dùng email khác.",
|
||||
validation_failed: "Xác thực email thất bại. Thử lại.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Mã duy nhất",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Mã xác minh đã được gửi tới email mới của bạn.",
|
||||
errors: {
|
||||
required: "Mã duy nhất là bắt buộc",
|
||||
invalid: "Mã xác minh không hợp lệ. Thử lại.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Tiếp tục",
|
||||
confirm: "Xác nhận",
|
||||
cancel: "Hủy",
|
||||
},
|
||||
states: {
|
||||
sending: "Đang gửi…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Cài đặt không gian làm việc",
|
||||
page_label: "{workspace} - Cài đặt chung",
|
||||
|
|
|
|||
|
|
@ -1505,6 +1505,47 @@ export default {
|
|||
content: "如果您确认,您为此视图选择的所有排序、筛选和显示选项 + 布局将被永久删除,无法恢复。",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "更改邮箱",
|
||||
description: "请输入新的邮箱地址以接收验证链接。",
|
||||
toasts: {
|
||||
success_title: "成功!",
|
||||
success_message: "邮箱已更新,请重新登录。",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "新邮箱",
|
||||
placeholder: "请输入邮箱",
|
||||
errors: {
|
||||
required: "邮箱为必填项",
|
||||
invalid: "邮箱格式无效",
|
||||
exists: "邮箱已存在,请使用其他邮箱。",
|
||||
validation_failed: "邮箱验证失败,请重试。",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "验证码",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "验证码已发送至你的新邮箱。",
|
||||
errors: {
|
||||
required: "验证码为必填项",
|
||||
invalid: "验证码无效,请重试。",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "继续",
|
||||
confirm: "确认",
|
||||
cancel: "取消",
|
||||
},
|
||||
states: {
|
||||
sending: "发送中…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "工作区设置",
|
||||
page_label: "{workspace} - 常规设置",
|
||||
|
|
|
|||
|
|
@ -1506,6 +1506,47 @@ export default {
|
|||
content: "如果您確認,您為此視圖選擇的所有排序、篩選和顯示選項 + 布局將被永久刪除,無法恢復。",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "變更電子郵件",
|
||||
description: "請輸入新的電子郵件地址以接收驗證連結。",
|
||||
toasts: {
|
||||
success_title: "成功!",
|
||||
success_message: "電子郵件已更新,請重新登入。",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "新電子郵件",
|
||||
placeholder: "請輸入電子郵件",
|
||||
errors: {
|
||||
required: "電子郵件為必填",
|
||||
invalid: "電子郵件格式無效",
|
||||
exists: "電子郵件已存在,請使用其他信箱。",
|
||||
validation_failed: "電子郵件驗證失敗,請再試一次。",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "驗證碼",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "驗證碼已傳送到你的新電子郵件。",
|
||||
errors: {
|
||||
required: "驗證碼為必填",
|
||||
invalid: "驗證碼無效,請再試一次。",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "繼續",
|
||||
confirm: "確認",
|
||||
cancel: "取消",
|
||||
},
|
||||
states: {
|
||||
sending: "傳送中…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "工作區設定",
|
||||
page_label: "{workspace} - 一般設定",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue