Compare commits

..
Author SHA1 Message Date
Aaryan Khandelwal e689f00fb2 chore: export comment display component props 2025-11-25 19:07:06 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ef99f8bbca chore(deps): bump @sentry/node (#8169)
Bumps the npm_and_yarn group with 1 update in the / directory: [@sentry/node](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/node` from 10.24.0 to 10.27.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.24.0...10.27.0)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  dependency-version: 10.27.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-25 13:35:01 +05:30
2804987fe8 [WEB-5358] fix: analytics enhancements (#8150)
* refactor: enhance analytics page tab functionality and UI

- Introduced state management for selected tabs using useState and useEffect hooks.
- Updated tab handling to improve user experience with onValueChange for Tabs component.
- Refactored AnalyticsFilterActions to remove unused duration selection.
- Added Tooltip to project name in ActiveProjectItem for better visibility.
- Minor styling adjustments in Tabs component for improved layout consistency.

* refactor: improve tab layout in analytics page by adding w-fit

* fix: update styling for ActiveProjectItem component

* refactor: simplify analytics page and introduce useAnalyticsTabs hook

- Removed unused imports and optimized the analytics page component.
- Replaced getAnalyticsTabs with a new custom hook useAnalyticsTabs for better modularity.
- Added useAnalyticsTabs hook to manage analytics tab logic.
- Created new files for useAnalyticsTabs and analytics tabs in the 'ce' and 'ee' directories.

* fix: ensure consistent export in use-analytics-tabs file

* style: format code for better readability in AnalyticsPage component

* fix: add missing newline at end of file in use-analytics-tabs

* chore: remove unused analytics tab components

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:29:34 +05:30
09f4e3d4ae [WIKI-774] refactor: space app mentions #8152
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:26:48 +05:30
7c8cbc4ead [WEB-4428] fix: duplicate labels with case insensitive (#7388)
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:22:17 +05:30
ce6299937f [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>
2025-11-24 21:21:52 +05:30
Dheeraj Kumar KetireddyandGitHub d6fce114d6 [SILO-663] chore: enhance error handling in ComplexFilterBackend with DRFValidationError (#8090)
* [SILO-663] chore: enhance error handling in ComplexFilterBackend with DRFValidationError

* Log the exception and re-raise it
2025-11-24 21:14:14 +05:30
46 changed files with 3365 additions and 110 deletions
+13
View File
@@ -364,6 +364,19 @@ class LabelSerializer(BaseSerializer):
]
read_only_fields = ["workspace", "project"]
def validate_name(self, value):
project_id = self.context.get("project_id")
label = Label.objects.filter(project_id=project_id, name__iexact=value)
if self.instance:
label = label.exclude(id=self.instance.pk)
if label.exists():
raise serializers.ValidationError(detail="LABEL_NAME_ALREADY_EXISTS")
return value
class LabelLiteSerializer(BaseSerializer):
class Meta:
+10
View File
@@ -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 -5
View File
@@ -1,4 +1,3 @@
from dateutil.relativedelta import relativedelta
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, List, Any
@@ -220,7 +219,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
)
workspace = Workspace.objects.get(slug=self._workspace_slug)
workspace_start_date = workspace.created_at.date().replace(day=1)
start_date = workspace.created_at.date().replace(day=1)
# Apply date range filter if available
if self.filters["chart_period_range"]:
@@ -251,9 +250,6 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
data = []
# include the current date at the end
end_date = timezone.now().date()
twelve_months_ago = end_date.replace(day=1) - relativedelta(months=12)
start_date = max(workspace_start_date, twelve_months_ago)
last_month = end_date.replace(day=1)
current_month = start_date
+16 -3
View File
@@ -39,7 +39,9 @@ class LabelViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
serializer = LabelSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -64,8 +66,18 @@ class LabelViewSet(BaseViewSet):
{"error": "Label with the same name already exists in the project"},
status=status.HTTP_400_BAD_REQUEST,
)
# call the parent method to perform the update
return super().partial_update(request, *args, **kwargs)
serializer = LabelSerializer(
instance=self.get_object(),
data=request.data,
context={"project_id": kwargs["project_id"]},
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
@@ -77,6 +89,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def post(self, request, slug, project_id):
label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id)
labels = Label.objects.bulk_create(
+186 -3
View File
@@ -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()
+20 -1
View File
@@ -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)
@@ -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
@@ -0,0 +1,43 @@
import pytest
from plane.app.serializers import LabelSerializer
from plane.db.models import Project, Label
@pytest.mark.unit
class TestLabelSerializer:
"""Test the LabelSerializer"""
@pytest.mark.django_db
def test_label_serializer_create_valid_data(self, db, workspace):
"""Test creating a label with valid data"""
project = Project.objects.create(
name="Test Project", identifier="TEST", workspace=workspace
)
serializer = LabelSerializer(
data={"name": "Test Label"},
context={"project_id": project.id},
)
assert serializer.is_valid()
assert serializer.errors == {}
serializer.save(project_id=project.id)
label = Label.objects.all().first()
assert label.name == "Test Label"
assert label.project == project
assert label
@pytest.mark.django_db
def test_label_serializer_create_duplicate_name(self, db, workspace):
"""Test creating a label with a duplicate name"""
project = Project.objects.create(
name="Test Project", identifier="TEST", workspace=workspace
)
Label.objects.create(name="Test Label", project=project)
serializer = LabelSerializer(
data={"name": "Test Label"}, context={"project_id": project.id}
)
assert not serializer.is_valid()
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
+169 -27
View File
@@ -1,10 +1,16 @@
# Python imports
import json
from django.core.exceptions import ValidationError
# Django imports
from django.db.models import Q
from django.http import QueryDict
# Third party imports
from django_filters.utils import translate_validation
from rest_framework import filters
from rest_framework.exceptions import ValidationError as DRFValidationError
from plane.utils.exception_logger import log_exception
class ComplexFilterBackend(filters.BaseFilterBackend):
@@ -35,12 +41,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
normalized = self._normalize_filter_data(filter_string, "filter")
return self._apply_json_filter(queryset, normalized, view)
except ValidationError:
except DRFValidationError:
# Propagate validation errors unchanged
raise
except Exception as e:
# Convert unexpected errors to ValidationError to keep response consistent
raise ValidationError(f"Filter error: {str(e)}")
log_exception(e)
raise
def _normalize_filter_data(self, raw_filter, source_label):
"""Return a dict from raw filter input or raise a ValidationError.
@@ -53,9 +59,19 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
return json.loads(raw_filter)
if isinstance(raw_filter, dict):
return raw_filter
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
raise DRFValidationError(
{
"message": f"'{source_label}' must be a dict or a JSON string.",
"code": "invalid_filter_type",
}
)
except json.JSONDecodeError:
raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.")
raise DRFValidationError(
{
"message": (f"Invalid JSON for '{source_label}'. Expected a valid JSON object."),
"code": "invalid_json",
}
)
def _apply_json_filter(self, queryset, filter_data, view):
"""Process a JSON filter structure using Q object composition."""
@@ -83,7 +99,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
allowed_fields = set(filterset_class.base_filters.keys()) if filterset_class else None
if not allowed_fields:
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
raise ValidationError("Filtering is not enabled for this endpoint (missing filterset_class)")
raise DRFValidationError(
{
"message": ("Filtering is not enabled for this endpoint (missing filterset_class)"),
"code": "filtering_not_enabled",
}
)
# Extract field names from the filter data
fields = self._extract_field_names(filter_data)
@@ -94,7 +115,25 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Example: 'sequence_id__gte' should be declared in base_filters
# Special-case __range: require the '<base>__range' filter itself
if field not in allowed_fields:
raise ValidationError(f"Filtering on field '{field}' is not allowed")
raise DRFValidationError(
{
"message": f"Filtering on field '{field}' is not allowed",
"code": "invalid_filter_field",
}
)
def _transform_field_name_for_validation(self, field_name):
"""Hook: Transform a field name before validation.
Override this in subclasses to handle special field naming conventions.
Args:
field_name: The original field name from the filter data
Returns:
The transformed field name to validate against the FilterSet
"""
return field_name
def _extract_field_names(self, filter_data):
"""Extract all field names from a nested filter structure"""
@@ -112,8 +151,9 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
for item in value:
fields.extend(self._extract_field_names(item))
else:
# This is a field name
fields.append(key)
# This is a field name - apply transformation hook
transformed_field = self._transform_field_name_for_validation(key)
fields.append(transformed_field)
return fields
return []
@@ -171,6 +211,23 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Leaf dict: evaluate via FilterSet to get a Q object
return self._build_leaf_q(node, view, queryset)
def _preprocess_leaf_conditions(self, leaf_conditions, view, queryset):
"""Hook: Preprocess leaf conditions before building Q object.
Override this in subclasses to transform filter keys/values.
For example, custom property filters might need to be transformed
from 'customproperty_<id>__<lookup>' to 'customproperty_value__<lookup>'.
Args:
leaf_conditions: Dict of field filters
view: The view instance
queryset: The queryset being filtered
Returns:
Dict of transformed field filters
"""
return leaf_conditions
def _build_leaf_q(self, leaf_conditions, view, queryset):
"""Build a Q object from leaf filter conditions using the view's FilterSet.
@@ -186,11 +243,19 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Get the filterset class from the view
filterset_class = getattr(view, "filterset_class", None)
if not filterset_class:
raise ValidationError("Filtering requires a filterset_class to be defined on the view")
raise DRFValidationError(
{
"message": ("Filtering requires a filterset_class to be defined on the view"),
"code": "filterset_missing",
}
)
# Apply preprocessing hook
processed_conditions = self._preprocess_leaf_conditions(leaf_conditions, view, queryset)
# Build a QueryDict from the leaf conditions
qd = QueryDict(mutable=True)
for key, value in leaf_conditions.items():
for key, value in processed_conditions.items():
# Default serialization to string; QueryDict expects strings
if isinstance(value, list):
# Repeat key for list values (e.g., __in)
@@ -206,11 +271,23 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
fs = filterset_class(data=qd, queryset=queryset)
if not fs.is_valid():
raise translate_validation(fs.errors)
ve = translate_validation(fs.errors)
raise DRFValidationError(
{
"message": "Invalid filter parameters",
"code": "invalid_filterset",
"errors": ve.detail,
}
)
# Build and return the combined Q object
if not hasattr(fs, "build_combined_q"):
raise ValidationError("FilterSet must have build_combined_q method for complex filtering")
raise DRFValidationError(
{
"message": ("FilterSet must have build_combined_q method for complex filtering"),
"code": "missing_build_combined_q",
}
)
return fs.build_combined_q()
@@ -243,34 +320,69 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
- Depth must not exceed max_depth
"""
if current_depth > max_depth:
raise ValidationError(f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}")
raise DRFValidationError(
{
"message": (f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}"),
"code": "max_depth_exceeded",
}
)
if not isinstance(node, dict):
raise ValidationError("Each filter node must be a JSON object")
raise DRFValidationError(
{
"message": "Each filter node must be a JSON object",
"code": "invalid_filter_node",
}
)
if not node:
raise ValidationError("Filter objects must not be empty")
raise DRFValidationError(
{
"message": "Filter objects must not be empty",
"code": "empty_filter_object",
}
)
logical_keys = [k for k in node.keys() if isinstance(k, str) and k.lower() in ("or", "and", "not")]
if len(logical_keys) > 1:
raise ValidationError("A filter object cannot contain multiple logical operators at the same level")
raise DRFValidationError(
{
"message": ("A filter object cannot contain multiple logical operators at the same level"),
"code": "multiple_logical_operators",
}
)
if len(logical_keys) == 1:
op_key = logical_keys[0]
# must not mix operator with other keys
if len(node) != 1:
raise ValidationError(f"Cannot mix logical operator '{op_key}' with field keys at the same level")
raise DRFValidationError(
{
"message": (f"Cannot mix logical operator '{op_key}' with field keys at the same level"),
"code": "mixed_operator_and_fields",
}
)
op = op_key.lower()
value = node[op_key]
if op in ("or", "and"):
if not isinstance(value, list) or len(value) == 0:
raise ValidationError(f"'{op}' must be a non-empty list of filter objects")
raise DRFValidationError(
{
"message": f"'{op}' must be a non-empty list of filter objects",
"code": "invalid_operator_children",
}
)
for child in value:
if not isinstance(child, dict):
raise ValidationError(f"All children of '{op}' must be JSON objects")
raise DRFValidationError(
{
"message": f"All children of '{op}' must be JSON objects",
"code": "invalid_operator_child_type",
}
)
self._validate_structure(
child,
max_depth=max_depth,
@@ -280,7 +392,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
if op == "not":
if not isinstance(value, dict):
raise ValidationError("'not' must be a single JSON object")
raise DRFValidationError(
{
"message": "'not' must be a single JSON object",
"code": "invalid_not_child",
}
)
self._validate_structure(value, max_depth=max_depth, current_depth=current_depth + 1)
return
@@ -290,24 +407,49 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
def _validate_leaf(self, leaf):
"""Validate a leaf dict containing field lookups and values."""
if not isinstance(leaf, dict) or not leaf:
raise ValidationError("Leaf filter must be a non-empty JSON object")
raise DRFValidationError(
{
"message": "Leaf filter must be a non-empty JSON object",
"code": "invalid_leaf",
}
)
for key, value in leaf.items():
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
raise ValidationError("Logical operators cannot appear in a leaf filter object")
raise DRFValidationError(
{
"message": "Logical operators cannot appear in a leaf filter object",
"code": "operator_in_leaf",
}
)
# Lists/Tuples must contain only scalar values
if isinstance(value, (list, tuple)):
if len(value) == 0:
raise ValidationError(f"List value for '{key}' must not be empty")
raise DRFValidationError(
{
"message": f"List value for '{key}' must not be empty",
"code": "empty_list_value",
}
)
for item in value:
if not self._is_scalar(item):
raise ValidationError(f"List value for '{key}' must contain only scalar items")
raise DRFValidationError(
{
"message": f"List value for '{key}' must contain only scalar items",
"code": "non_scalar_list_item",
}
)
continue
# Scalars and None are allowed
if not self._is_scalar(value):
raise ValidationError(f"Value for '{key}' must be a scalar, null, or list/tuple of scalars")
raise DRFValidationError(
{
"message": (f"Value for '{key}' must be a scalar, null, or list/tuple of scalars"),
"code": "invalid_value_type",
}
)
def _is_scalar(self, value):
return value is None or isinstance(value, (str, int, float, bool))
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
export * from "./mentions";
@@ -1,6 +1,8 @@
// plane editor
import type { TCallbackMentionComponentProps } from "@plane/editor";
export function EditorAdditionalMentionsRoot(_props: TCallbackMentionComponentProps) {
export type TEditorMentionComponentProps = TCallbackMentionComponentProps;
export function EditorAdditionalMentionsRoot(_props: TEditorMentionComponentProps) {
return null;
}
-1
View File
@@ -1 +0,0 @@
export * from "./embeds";
@@ -1,11 +1,10 @@
// plane editor
import type { TCallbackMentionComponentProps } from "@plane/editor";
// plane web components
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
// plane web imports
import type { TEditorMentionComponentProps } from "@/plane-web/components/editor/embeds/mentions";
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor/embeds/mentions";
// local components
import { EditorUserMention } from "./user";
export function EditorMentionsRoot(props: TCallbackMentionComponentProps) {
export function EditorMentionsRoot(props: TEditorMentionComponentProps) {
const { entity_identifier, entity_name } = props;
switch (entity_name) {
@@ -1,31 +1,47 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { StateGroupIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TStateGroups } from "@plane/types";
// plane utils
import { cn } from "@plane/utils";
//hooks
import { useStates } from "@/hooks/store/use-state";
type Props = {
stateId: string | undefined;
shouldShowBorder?: boolean;
};
export const IssueBlockState = observer(function IssueBlockState({ stateId, shouldShowBorder = true }: Props) {
const { getStateById } = useStates();
} & (
| {
stateDetails: {
name: string;
group: TStateGroups;
};
}
| {
stateId: string;
}
);
const state = getStateById(stateId);
export const IssueBlockState: React.FC<Props> = observer((props) => {
const { shouldShowBorder = true } = props;
// store hooks
const { getStateById } = useStates();
// derived values
const state = "stateId" in props ? getStateById(props.stateId) : props.stateDetails;
if (!state) return null;
return (
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
<Tooltip tooltipHeading="State" tooltipContent={state.name}>
<div
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
"border-[0.5px] border-custom-border-300": shouldShowBorder,
})}
>
<div className="flex w-full items-center gap-1.5">
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
<div className="text-xs">{state?.name ?? "State"}</div>
<StateGroupIcon stateGroup={state.group} />
<div className="text-xs">{state.name}</div>
</div>
</div>
</Tooltip>
@@ -1,14 +1,15 @@
import { useMemo } from "react";
"use client";
import { useState, useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// plane package imports
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { AnalyticsTab } from "@plane/types";
import { Tabs } from "@plane/ui";
import type { TabItem } from "@plane/ui";
import { Tabs } from "@plane/propel/tabs";
// components
import { cn } from "@plane/utils";
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
import { PageHead } from "@/components/core/page-title";
// hooks
@@ -17,7 +18,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
import { useAnalyticsTabs } from "@/plane-web/components/analytics/use-analytics-tabs";
import type { Route } from "./+types/page";
function AnalyticsPage({ params }: Route.ComponentProps) {
@@ -35,31 +36,32 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// derived values
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
const ANALYTICS_TABS = useMemo<AnalyticsTab[]>(() => getAnalyticsTabs(t), [t]);
const tabs: TabItem[] = useMemo(
() =>
ANALYTICS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
content: <tab.content />,
onClick: () => {
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
},
disabled: tab.isDisabled,
})),
[ANALYTICS_TABS, router, currentWorkspace?.slug]
);
const defaultTab = tabId;
const workspaceSlug = params.workspaceSlug;
const ANALYTICS_TABS = useAnalyticsTabs(workspaceSlug.toString());
const [selectedTab, setSelectedTab] = useState(tabId || ANALYTICS_TABS[0]?.key);
useEffect(() => {
if (tabId) {
setSelectedTab(tabId);
}
}, [tabId]);
// Handle tab change
const handleTabChange = (value: string) => {
setSelectedTab(value);
router.push(`/${currentWorkspace?.slug}/analytics/${value}`);
};
return (
<>
@@ -67,19 +69,43 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
{workspaceProjectIds && (
<>
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
<Tabs
tabs={tabs}
storageKey={`analytics-page-${currentWorkspace?.id}`}
defaultTab={defaultTab}
size="md"
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
tabListClassName="my-2 w-auto"
tabClassName="px-3"
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
storeInLocalStorage={false}
actions={<AnalyticsFilterActions />}
/>
<div className="flex h-full overflow-hidden bg-custom-background-100 ">
<Tabs value={selectedTab} onValueChange={handleTabChange} className="w-full h-full">
<div className={"flex flex-col w-full h-full"}>
<div
className={cn(
"px-6 py-2 border-b border-custom-border-200 flex items-center gap-4 overflow-hidden w-full justify-between"
)}
>
<Tabs.List className={"my-2 overflow-x-auto flex w-fit"}>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Trigger
key={tab.key}
value={tab.key}
disabled={tab.isDisabled}
size="md"
className="px-3"
>
{tab.label}
</Tabs.Trigger>
))}
</Tabs.List>
<div className="flex-shrink-0">
<AnalyticsFilterActions />
</div>
</div>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Content
key={tab.key}
value={tab.key}
className={"h-full overflow-hidden overflow-y-auto px-2"}
>
<tab.content />
</Tabs.Content>
))}
</div>
</Tabs>
</div>
) : (
<EmptyStateDetailed
@@ -0,0 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { getAnalyticsTabs } from "./tabs";
export const useAnalyticsTabs = (workspaceSlug: string) => {
const { t } = useTranslation();
const analyticsTabs = useMemo(() => getAnalyticsTabs(t), [t]);
return analyticsTabs;
};
@@ -4,11 +4,10 @@ import { observer } from "mobx-react";
import { useAnalytics } from "@/hooks/store/use-analytics";
import { useProject } from "@/hooks/store/use-project";
// components
import DurationDropdown from "./select/duration";
import { ProjectSelect } from "./select/project";
const AnalyticsFilterActions = observer(function AnalyticsFilterActions() {
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalytics();
const { selectedProjects, updateSelectedProjects } = useAnalytics();
const { joinedProjectIds } = useProject();
return (
<div className="flex items-center justify-end gap-2">
@@ -1,6 +1,7 @@
// plane package imports
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ProjectIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// plane web hooks
import { useProject } from "@/hooks/store/use-project";
@@ -33,9 +34,9 @@ function ActiveProjectItem(props: Props) {
if (!projectDetails) return null;
return (
<div className="flex items-center justify-between gap-2 ">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80">
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 overflow-hidden">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80 shrink-0">
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
{projectDetails?.logo_props ? (
<Logo logo={projectDetails?.logo_props} size={16} />
@@ -46,13 +47,15 @@ function ActiveProjectItem(props: Props) {
)}
</span>
</div>
<p className="text-sm font-medium">{projectDetails?.name}</p>
<Tooltip tooltipContent={projectDetails?.name} position="top-start">
<p className="text-sm font-medium truncate">{projectDetails?.name}</p>
</Tooltip>
</div>
<CompletionPercentage
percentage={completed_issues && total_issues ? Math.round((completed_issues / total_issues) * 100) : 0}
/>
</div>
);
}
};
export default ActiveProjectItem;
@@ -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>
);
});
@@ -10,7 +10,7 @@ import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
export const useCommentOperations = (
export const useWorkItemCommentOperations = (
workspaceSlug: string | undefined,
projectId: string | undefined,
issueId: string | undefined
@@ -19,7 +19,7 @@ import { useUser, useUserPermissions } from "@/hooks/store/user";
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
import { IssueActivityCommentRoot } from "./activity-comment-root";
import { useCommentOperations } from "./helper";
import { useWorkItemCommentOperations } from "./helper";
import { ActivitySortRoot } from "./sort-root";
type TIssueActivity = {
@@ -77,11 +77,11 @@ export const IssueActivity = observer(function IssueActivity(props: TIssueActivi
};
const toggleSortOrder = () => {
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
setSortOrder(sortOrder || E_SORT_ORDER.ASC);
};
// helper hooks
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
const activityOperations = useWorkItemCommentOperations(workspaceSlug, projectId, issueId);
const project = getProjectById(projectId);
const renderCommentCreationBox = useMemo(
+16
View File
@@ -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>
+33
View File
@@ -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",
+39 -1
View File
@@ -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 daffichage 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 dadresse 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: "Le-mail est requis",
invalid: "Le-mail est invalide",
exists: "Cette adresse e-mail existe déjà. Utilisez-en une autre.",
validation_failed: "Échec de la validation de le-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 lespace 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: "Lemail è obbligatoria",
invalid: "Lemail non è valida",
exists: "Lemail esiste già. Usane unaltra.",
validation_failed: "La verifica dellemail 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} - 一般設定",
+1 -1
View File
@@ -41,7 +41,7 @@ const TabsList = React.forwardRef(function TabsList(
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative overflow-auto",
"flex w-full items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative overflow-auto",
className
)}
{...props}
+546 -4
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@ catalog:
"@react-router/dev": 7.9.5
"@react-router/node": 7.9.5
"@react-router/serve": 7.9.5
"@sentry/node": 10.24.0
"@sentry/node": 10.27.0
"@sentry/profiling-node": 10.24.0
"@sentry/react-router": 10.24.0
"@tiptap/core": ^2.22.3