Compare commits

..

8 Commits

Author SHA1 Message Date
d0a57372ba fix(web): fix web app description missing 2026-05-15 14:35:32 +08:00
28153df4d3 chore: enchance copywriting in none education plan warning modal (#36201)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-15 05:08:06 +00:00
3bc3386535 refactor(install): improve layout and scrolling behavior for plugin installation step (#36199)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-15 03:12:14 +00:00
7654f14241 fix: replace deprecated testcontainers log waits (#36125) 2026-05-15 01:30:59 +00:00
yyh
194b54bae4 fix: allow tag rename without type payload (#36182)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 01:29:17 +00:00
0e16d36edb fix(commands): purge tenant tool credentials on reset-encrypt-key-pair (#35396) (#35843)
Co-authored-by: xr843 <xianren843@protonmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 16:25:54 +00:00
432a6412a3 fix(security): tenant-scope FilePreviewApi text-extract endpoint (GHSA-2qwc-c2cc-2xwv) (#35797)
Signed-off-by: xr843 <137012659+xr843@users.noreply.github.com>
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 16:13:04 +00:00
55d05fe52d fix(security): enforce tenant scoping on app trace-config endpoints (GHSA-48xc-wmw8-3jr3) (#35793)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 15:59:31 +00:00
58 changed files with 485 additions and 239 deletions

View File

@ -24,8 +24,7 @@ RUN apt-get update \
# Install Python dependencies (workspace members under providers/vdb/)
COPY pyproject.toml uv.lock ./
COPY providers ./providers
# Trust the checked-in lock during image builds; dev-only path sources live outside the api/ context.
RUN uv sync --frozen --no-dev
RUN uv sync --locked --no-dev
# production stage
FROM base AS production

View File

@ -14,6 +14,7 @@ from libs.rsa import generate_key_pair
from models import Tenant
from models.model import App, AppMode, Conversation
from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
logger = logging.getLogger(__name__)
@ -23,13 +24,16 @@ DB_UPGRADE_LOCK_TTL_SECONDS = 60
@click.command(
"reset-encrypt-key-pair",
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
"After the reset, all LLM credentials will become invalid, "
"requiring re-entry."
"After the reset, all LLM credentials and tool provider credentials "
"(builtin / API / MCP) will be purged, requiring re-entry. "
"Only support SELF_HOSTED mode.",
)
@click.confirmation_option(
prompt=click.style(
"Are you sure you want to reset encrypt key pair? This operation cannot be rolled back!", fg="red"
"Are you sure you want to reset encrypt key pair? "
"This will also purge builtin / API / MCP tool provider records for every tenant. "
"This operation cannot be rolled back!",
fg="red",
)
)
def reset_encrypt_key_pair():
@ -53,6 +57,13 @@ def reset_encrypt_key_pair():
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
# Purge tool provider records that hold credentials encrypted under the
# tenant key. Leaving them in place causes /console/api/workspaces/current/
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
click.echo(
click.style(
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",

View File

@ -3,7 +3,6 @@ import re
import uuid
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
@ -850,10 +849,11 @@ class AppTraceApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_id: UUID):
@get_app_model
def get(self, app_model):
"""Get app trace"""
with session_factory.create_session() as session:
app_trace_config = OpsTraceManager.get_app_tracing_config(str(app_id), session)
app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session)
return app_trace_config
@ -867,12 +867,13 @@ class AppTraceApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def post(self, app_id: UUID):
@get_app_model
def post(self, app_model):
# add app trace
args = AppTracePayload.model_validate(console_ns.payload)
OpsTraceManager.update_app_tracing_config(
app_id=str(app_id),
app_id=app_model.id,
enabled=args.enabled,
tracing_provider=args.tracing_provider,
)

View File

@ -1,5 +1,4 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource, fields
@ -9,8 +8,10 @@ from werkzeug.exceptions import BadRequest
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from libs.login import login_required
from models import App
from services.ops_service import OpsService
@ -43,11 +44,14 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_id: UUID):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
@get_app_model
def get(self, app_model: App):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
try:
trace_config = OpsService.get_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
trace_config = OpsService.get_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider
)
if not trace_config:
return {"has_not_configured": True}
return trace_config
@ -65,13 +69,14 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self, app_id: UUID):
@get_app_model
def post(self, app_model: App):
"""Create a new trace app configuration"""
args = TraceConfigPayload.model_validate(console_ns.payload)
try:
result = OpsService.create_tracing_app_config(
app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
)
if not result:
raise TracingConfigIsExist()
@ -90,13 +95,14 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def patch(self, app_id: UUID):
@get_app_model
def patch(self, app_model: App):
"""Update an existing trace app configuration"""
args = TraceConfigPayload.model_validate(console_ns.payload)
try:
result = OpsService.update_tracing_app_config(
app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
)
if not result:
raise TracingConfigNotExist()
@ -113,12 +119,13 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def delete(self, app_id: UUID):
@get_app_model
def delete(self, app_model: App):
"""Delete an existing trace app configuration"""
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
try:
result = OpsService.delete_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider)
if not result:
raise TracingConfigNotExist()
return {"result": "success"}, 204

View File

@ -105,7 +105,8 @@ class FilePreviewApi(Resource):
@account_initialization_required
def get(self, file_id):
file_id = str(file_id)
text = FileService(db.engine).get_file_preview(file_id)
_, tenant_id = current_account_with_tenant()
text = FileService(db.engine).get_file_preview(file_id, tenant_id)
return {"content": text}

View File

@ -25,6 +25,10 @@ class TagBasePayload(BaseModel):
type: TagType = Field(description="Tag type")
class TagUpdateRequestPayload(BaseModel):
name: str = Field(description="Tag name", min_length=1, max_length=50)
class TagBindingPayload(BaseModel):
tag_ids: list[str] = Field(description="Tag IDs to bind")
target_id: str = Field(description="Target ID to bind tags to")
@ -68,6 +72,7 @@ class TagResponse(ResponseModel):
register_schema_models(
console_ns,
TagBasePayload,
TagUpdateRequestPayload,
TagBindingPayload,
TagBindingRemovePayload,
TagListQueryParam,
@ -118,7 +123,7 @@ class TagListApi(Resource):
@console_ns.route("/tags/<uuid:tag_id>")
class TagUpdateDeleteApi(Resource):
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@ -129,8 +134,8 @@ class TagUpdateDeleteApi(Resource):
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
payload = TagBasePayload.model_validate(console_ns.payload or {})
tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=payload.type), tag_id)
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id)
binding_count = TagService.get_tag_binding_count(tag_id)

View File

@ -31,7 +31,9 @@ from services.tag_service import (
TagBindingCreatePayload,
TagBindingDeletePayload,
TagService,
UpdateTagPayload,
)
from services.tag_service import (
UpdateTagPayload as UpdateTagServicePayload,
)
register_enum_models(service_api_ns, DatasetPermissionEnum)
@ -556,7 +558,7 @@ class DatasetTagsApi(DatasetApiResource):
payload = TagUpdatePayload.model_validate(service_api_ns.payload or {})
tag_id = payload.tag_id
tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=TagType.KNOWLEDGE), tag_id)
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id)
binding_count = TagService.get_tag_binding_count(tag_id)

View File

@ -7515,7 +7515,7 @@ Remove one or more tag bindings from a target.
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| tag_id | path | | Yes | string |
| payload | body | | Yes | [TagBasePayload](#tagbasepayload) |
| payload | body | | Yes | [TagUpdateRequestPayload](#tagupdaterequestpayload) |
##### Responses
@ -13456,6 +13456,12 @@ Tag type
| ---- | ---- | ----------- | -------- |
| TagType | string | Tag type | |
#### TagUpdateRequestPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | Tag name | Yes |
#### TenantAccountRole
| Name | Type | Description | Required |

View File

@ -42,6 +42,7 @@ dependencies = [
"readabilipy>=0.3.0,<1.0.0",
"resend>=2.27.0,<3.0.0",
# Emerging: newer and fast-moving, use compatible pins
"dify-agent",
"fastopenapi[flask]~=0.7.0",
"graphon~=0.3.1",
"httpx-sse~=0.4.0",
@ -114,7 +115,6 @@ override-dependencies = [
############################################################
dev = [
"coverage>=7.13.4",
"dify-agent",
"dotenv-linter>=0.7.0",
"faker>=40.15.0",
"lxml-stubs>=0.5.1",

View File

@ -172,12 +172,14 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str):
def get_file_preview(self, file_id: str, tenant_id: str):
"""
Return a short text preview extracted from a document file.
"""
with self._session_maker(expire_on_commit=False) as session:
upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1))
upload_file = session.scalar(
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id).limit(1)
)
if not upload_file:
raise NotFound("File not found")

View File

@ -21,7 +21,6 @@ class SaveTagPayload(BaseModel):
class UpdateTagPayload(BaseModel):
name: str = Field(min_length=1, max_length=50)
type: TagType
class TagBindingCreatePayload(BaseModel):

View File

@ -22,7 +22,7 @@ from sqlalchemy import Engine, text
from sqlalchemy.orm import Session
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
from testcontainers.core.waiting_utils import wait_for_logs
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
@ -54,6 +54,10 @@ def _auto_close[T: _CloserProtocol](closer: T) -> Generator[T, None, None]:
closer.close()
def _wait_for_log_message(message: str, timeout: int) -> LogMessageWaitStrategy:
return LogMessageWaitStrategy(message).with_startup_timeout(timeout)
class DifyTestContainers:
"""
Manages all test containers required for Dify integration tests.
@ -99,6 +103,7 @@ class DifyTestContainers:
self.postgres = PostgresContainer(
image="postgres:14-alpine",
).with_network(self.network)
self.postgres.waiting_for(_wait_for_log_message("is ready to accept connections", 30))
self.postgres.start()
db_host = self.postgres.get_container_host_ip()
db_port = self.postgres.get_exposed_port(5432)
@ -115,9 +120,6 @@ class DifyTestContainers:
self.postgres.dbname,
)
# Wait for PostgreSQL to be ready
logger.info("Waiting for PostgreSQL to be ready to accept connections...")
wait_for_logs(self.postgres, "is ready to accept connections", timeout=30)
logger.info("PostgreSQL container is ready and accepting connections")
conn = psycopg2.connect(
@ -152,6 +154,7 @@ class DifyTestContainers:
# Redis is used for storing session data, cache entries, and temporary data
logger.info("Initializing Redis container...")
self.redis = RedisContainer(image="redis:6-alpine", port=6379).with_network(self.network)
self.redis.waiting_for(_wait_for_log_message("Ready to accept connections", 30))
self.redis.start()
redis_host = self.redis.get_container_host_ip()
redis_port = self.redis.get_exposed_port(6379)
@ -159,9 +162,6 @@ class DifyTestContainers:
os.environ["REDIS_PORT"] = str(redis_port)
logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port)
# Wait for Redis to be ready
logger.info("Waiting for Redis to be ready to accept connections...")
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
logger.info("Redis container is ready and accepting connections")
# Start Dify Sandbox container for code execution environment.
@ -170,6 +170,7 @@ class DifyTestContainers:
sandbox_image = os.getenv(SANDBOX_TEST_IMAGE_ENV, DEFAULT_SANDBOX_TEST_IMAGE)
self.dify_sandbox = DockerContainer(image=sandbox_image).with_network(self.network)
self.dify_sandbox.with_exposed_ports(8194)
self.dify_sandbox.waiting_for(_wait_for_log_message("config init success", 60))
self.dify_sandbox.env = {
"API_KEY": "test_api_key",
}
@ -185,9 +186,6 @@ class DifyTestContainers:
sandbox_port,
)
# Wait for Dify Sandbox to be ready
logger.info("Waiting for Dify Sandbox to be ready to accept connections...")
wait_for_logs(self.dify_sandbox, "config init success", timeout=60)
logger.info("Dify Sandbox container is ready and accepting connections")
# Start Dify Plugin Daemon container for plugin management
@ -197,6 +195,7 @@ class DifyTestContainers:
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)
self.dify_plugin_daemon.waiting_for(_wait_for_log_message("start plugin manager daemon", 60))
# Get container internal network addresses
postgres_container_name = self.postgres.get_wrapped_container().name
redis_container_name = self.redis.get_wrapped_container().name
@ -243,9 +242,6 @@ class DifyTestContainers:
plugin_daemon_port,
)
# Wait for Dify Plugin Daemon to be ready
logger.info("Waiting for Dify Plugin Daemon to be ready to accept connections...")
wait_for_logs(self.dify_plugin_daemon, "start plugin manager daemon", timeout=60)
logger.info("Dify Plugin Daemon container is ready and accepting connections")
except Exception as e:
logger.warning("Failed to start Dify Plugin Daemon container: %s", e)

View File

@ -8,7 +8,9 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from flask.testing import FlaskClient
from pydantic import ValidationError
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
from controllers.console import console_ns
@ -57,6 +59,12 @@ from controllers.console.app.workflow_app_log import WorkflowAppLogQuery
from controllers.console.app.workflow_draft_variable import WorkflowDraftVariableUpdatePayload
from controllers.console.app.workflow_statistic import WorkflowStatisticQuery
from controllers.console.app.workflow_trigger import Parser, ParserEnable
from models.model import AppMode
from tests.test_containers_integration_tests.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
create_console_app,
)
def _unwrap(func):
@ -270,6 +278,35 @@ class TestOpsTraceEndpoints:
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
@pytest.mark.parametrize(
"path_template",
[
"/console/api/apps/{app_id}/trace-config?tracing_provider=langfuse",
"/console/api/apps/{app_id}/trace",
],
)
def test_trace_endpoints_hide_apps_from_other_tenants(
self,
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
path_template: str,
):
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers)
foreign_app = create_console_app(
db_session_with_containers,
tenant_id=foreign_tenant.id,
account_id=foreign_account.id,
mode=AppMode.CHAT,
)
response = test_client_with_containers.get(
path_template.format(app_id=foreign_app.id),
headers=authenticate_console_client(test_client_with_containers, account),
)
assert response.status_code == 404
def test_ops_trace_query_basic(self):
query = TraceProviderQuery(tracing_provider="langfuse")
assert query.tracing_provider == "langfuse"
@ -289,7 +326,7 @@ class TestOpsTraceEndpoints:
)
with app.test_request_context("/?tracing_provider=langfuse"):
result = method(app_id="app-1")
result = method(app_model=MagicMock(id="app-1"))
assert result == {"has_not_configured": True}
@ -308,7 +345,7 @@ class TestOpsTraceEndpoints:
json={"tracing_provider": "langfuse", "tracing_config": {"api_key": "k"}},
):
with pytest.raises(BadRequest):
method(app_id="app-1")
method(app_model=MagicMock(id="app-1"))
def test_trace_app_config_delete_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
api = ops_trace_module.TraceAppConfigApi()
@ -322,7 +359,7 @@ class TestOpsTraceEndpoints:
with app.test_request_context("/?tracing_provider=langfuse"):
with pytest.raises(BadRequest):
method(app_id="app-1")
method(app_model=MagicMock(id="app-1"))
class TestSiteEndpoints:

View File

@ -6,17 +6,17 @@ import uuid
from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from configs import dify_config
from constants import HEADER_NAME_CSRF_TOKEN
from graphon.enums import WorkflowExecutionStatus
from libs.datetime_utils import naive_utc_now
from libs.token import _real_cookie_name, generate_csrf_token
from models import Account, DifySetup, Tenant, TenantAccountJoin
from models import Account, Tenant, TenantAccountJoin
from models.account import AccountStatus, TenantAccountRole, TenantStatus
from models.enums import ConversationFromSource, CreatorUserRole
from models.model import App, AppMode, Conversation, Message
from models.workflow import WorkflowRun
from services.account_service import AccountService
from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup
def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
@ -47,9 +47,7 @@ def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
account.timezone = "UTC"
db_session.commit()
dify_setup = DifySetup(version=dify_config.project.version)
db_session.add(dify_setup)
db_session.commit()
ensure_dify_setup(db_session)
return account, tenant

View File

@ -514,7 +514,7 @@ class TestFileService:
db_session_with_containers.commit()
result = FileService(engine).get_file_preview(file_id=upload_file.id)
result = FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id)
assert result == "extracted text content"
mock_external_service_dependencies["extract_processor"].load_from_upload_file.assert_called_once()
@ -529,7 +529,7 @@ class TestFileService:
non_existent_id = str(fake.uuid4())
with pytest.raises(NotFound, match="File not found"):
FileService(engine).get_file_preview(file_id=non_existent_id)
FileService(engine).get_file_preview(file_id=non_existent_id, tenant_id=str(fake.uuid4()))
def test_get_file_preview_unsupported_file_type(
self, db_session_with_containers: Session, engine, mock_external_service_dependencies
@ -549,7 +549,7 @@ class TestFileService:
db_session_with_containers.commit()
with pytest.raises(UnsupportedFileTypeError):
FileService(engine).get_file_preview(file_id=upload_file.id)
FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id)
def test_get_file_preview_text_truncation(
self, db_session_with_containers: Session, engine, mock_external_service_dependencies
@ -572,7 +572,7 @@ class TestFileService:
long_text = "x" * 5000 # Longer than PREVIEW_WORDS_LIMIT
mock_external_service_dependencies["extract_processor"].load_from_upload_file.return_value = long_text
result = FileService(engine).get_file_preview(file_id=upload_file.id)
result = FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id)
assert len(result) == 3000 # PREVIEW_WORDS_LIMIT
assert result == "x" * 3000

View File

@ -759,7 +759,7 @@ class TestTagService:
tag = TagService.save_tags(tag_args)
# Update args
update_args = UpdateTagPayload(name="updated_name", type="knowledge")
update_args = UpdateTagPayload(name="updated_name")
# Act: Execute the method under test
result = TagService.update_tags(update_args, tag.id)
@ -799,7 +799,7 @@ class TestTagService:
non_existent_tag_id = str(uuid.uuid4())
update_args = UpdateTagPayload(name="updated_name", type="knowledge")
update_args = UpdateTagPayload(name="updated_name")
# Act & Assert: Verify proper error handling
with pytest.raises(NotFound) as exc_info:
@ -830,7 +830,7 @@ class TestTagService:
tag2 = TagService.save_tags(tag2_args)
# Try to update second tag with first tag's name
update_args = UpdateTagPayload(name="first_tag", type="app")
update_args = UpdateTagPayload(name="first_tag")
# Act & Assert: Verify proper error handling
with pytest.raises(ValueError) as exc_info:

View File

@ -0,0 +1,108 @@
"""Unit tests for the reset-encrypt-key-pair CLI command (#35396).
The command must purge every table that stores ciphertext encrypted with the
tenant's asymmetric key, otherwise stale rows cause downstream API failures
such as `/console/api/workspaces/current/tool-providers` returning 500.
"""
from unittest.mock import MagicMock, patch
import commands
from commands import system as system_commands
from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
def _invoke_reset() -> int:
try:
commands.reset_encrypt_key_pair.callback()
except SystemExit as e:
return int(e.code or 0)
return 0
def _delete_targets(session_mock: MagicMock) -> list:
"""Extract the model class targeted by each `delete(...)` call on the session."""
targets = []
for call in session_mock.execute.call_args_list:
stmt = call.args[0]
# `delete(Foo)` constructs a `Delete` statement whose entity is `Foo`.
try:
targets.append(stmt.table.name)
except AttributeError:
targets.append(repr(stmt))
return targets
def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys):
monkeypatch.setattr(system_commands.dify_config, "EDITION", "CLOUD")
exit_code = _invoke_reset()
captured = capsys.readouterr()
assert exit_code == 0
assert "only for SELF_HOSTED" in captured.out
def test_reset_purges_provider_and_tool_tables_for_each_tenant(monkeypatch, capsys):
"""The command must purge LLM provider rows AND every tool provider table
that stores ciphertext encrypted under the tenant key (#35396)."""
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
fake_tenant = MagicMock(id="tenant-abc", encrypt_public_key="old-key")
session = MagicMock()
session.scalars.return_value.all.return_value = [fake_tenant]
fake_sessionmaker = MagicMock()
fake_sessionmaker.begin.return_value.__enter__.return_value = session
fake_sessionmaker.begin.return_value.__exit__.return_value = False
with (
patch.object(system_commands, "db", MagicMock()),
patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker),
):
exit_code = _invoke_reset()
captured = capsys.readouterr()
assert exit_code == 0
assert "tenant-abc" in captured.out
# New key pair generated and assigned.
assert fake_tenant.encrypt_public_key == "new-key-tenant-abc"
# Every encrypted-credential table should have been purged for this tenant.
table_names = _delete_targets(session)
expected = {
Provider.__tablename__,
ProviderModel.__tablename__,
BuiltinToolProvider.__tablename__,
ApiToolProvider.__tablename__,
MCPToolProvider.__tablename__,
}
assert expected.issubset(set(table_names)), f"missing purges: expected {expected}, got {table_names}"
def test_reset_iterates_all_tenants(monkeypatch, capsys):
"""Multi-tenant deployments must purge every tenant, not just the first."""
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
tenants = [MagicMock(id=f"tenant-{i}", encrypt_public_key="old") for i in range(3)]
session = MagicMock()
session.scalars.return_value.all.return_value = tenants
fake_sessionmaker = MagicMock()
fake_sessionmaker.begin.return_value.__enter__.return_value = session
fake_sessionmaker.begin.return_value.__exit__.return_value = False
with (
patch.object(system_commands, "db", MagicMock()),
patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker),
):
_invoke_reset()
# Five purges per tenant × 3 tenants = 15 execute calls.
assert session.execute.call_count == 15
for tenant in tenants:
assert tenant.encrypt_public_key == f"new-key-{tenant.id}"

View File

@ -14,6 +14,7 @@ from controllers.console.tag.tags import (
TagUpdateDeleteApi,
)
from models.enums import TagType
from services.tag_service import UpdateTagPayload
def unwrap(func):
@ -147,7 +148,7 @@ class TestTagUpdateDeleteApi:
api = TagUpdateDeleteApi()
method = unwrap(api.patch)
payload = {"name": "updated", "type": "knowledge"}
payload = {"name": "updated"}
with app.test_request_context("/", json=payload):
with (
@ -159,7 +160,7 @@ class TestTagUpdateDeleteApi:
patch(
"controllers.console.tag.tags.TagService.update_tags",
return_value=tag,
),
) as update_tags_mock,
patch(
"controllers.console.tag.tags.TagService.get_tag_binding_count",
return_value=3,
@ -168,6 +169,9 @@ class TestTagUpdateDeleteApi:
result, status = method(api, "tag-1")
assert status == 200
update_payload, tag_id = update_tags_mock.call_args.args
assert update_payload == UpdateTagPayload(name="updated")
assert tag_id == "tag-1"
assert result["binding_count"] == "3"
def test_patch_forbidden(self, app: Flask, readonly_user, payload_patch):

View File

@ -278,7 +278,7 @@ class TestFileApiPost:
class TestFilePreviewApi:
def test_get_preview(self, app, mock_file_service):
def test_get_preview(self, app, mock_account_context, mock_file_service):
api = FilePreviewApi()
get_method = unwrap(api.get)
mock_file_service.get_file_preview.return_value = "preview text"

View File

@ -221,7 +221,7 @@ class TestFileService:
mock_extract.return_value = "Extracted text content"
# Execute
result = file_service.get_file_preview("file_id")
result = file_service.get_file_preview("file_id", "tenant_id")
# Assert
assert result == "Extracted text content"
@ -229,7 +229,7 @@ class TestFileService:
def test_get_file_preview_not_found(self, file_service, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found"):
file_service.get_file_preview("non_existent")
file_service.get_file_preview("non_existent", "tenant_id")
def test_get_file_preview_unsupported_type(self, file_service, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
@ -237,7 +237,7 @@ class TestFileService:
upload_file.extension = "exe"
mock_db_session.scalar.return_value = upload_file
with pytest.raises(UnsupportedFileTypeError):
file_service.get_file_preview("file_id")
file_service.get_file_preview("file_id", "tenant_id")
def test_get_image_preview_success(self, file_service, mock_db_session):
# Setup

4
api/uv.lock generated
View File

@ -1332,6 +1332,7 @@ dependencies = [
{ name = "boto3" },
{ name = "celery" },
{ name = "croniter" },
{ name = "dify-agent" },
{ name = "fastopenapi", extra = ["flask"] },
{ name = "flask" },
{ name = "flask-compress" },
@ -1372,7 +1373,6 @@ dev = [
{ name = "boto3-stubs" },
{ name = "celery-types" },
{ name = "coverage" },
{ name = "dify-agent" },
{ name = "dotenv-linter" },
{ name = "faker" },
{ name = "hypothesis" },
@ -1615,6 +1615,7 @@ requires-dist = [
{ name = "boto3", specifier = ">=1.43.6" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "dify-agent", directory = "../dify-agent" },
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
@ -1655,7 +1656,6 @@ dev = [
{ name = "boto3-stubs", specifier = ">=1.43.2" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
{ name = "dify-agent", directory = "../dify-agent" },
{ name = "dotenv-linter", specifier = ">=0.7.0" },
{ name = "faker", specifier = ">=40.15.0" },
{ name = "hypothesis", specifier = ">=6.152.4" },

View File

@ -334,7 +334,7 @@ describe('CloudPlanItem', () => {
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => {
it('should show education unsupported warning and switch checkout to professional annual', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
@ -355,18 +355,18 @@ describe('CloudPlanItem', () => {
fireEvent.click(button)
expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument()
expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument()
expect(screen.getByText('education.educationPricingConfirm.description'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when canceled', async () => {
it('should continue selected plan checkout when keeping current plan', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
@ -384,6 +384,31 @@ describe('CloudPlanItem', () => {
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when using the close button', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
})

View File

@ -1,15 +1,14 @@
'use client'
import type { FC } from 'react'
import type { BasicPlan } from '../../../type'
import { Button } from '@langgenius/dify-ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useMemo } from 'react'
@ -24,7 +23,7 @@ import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import Button from './button'
import PlanButton from './button'
import List from './list'
const ICON_MAP = {
@ -33,10 +32,6 @@ const ICON_MAP = {
[Plan.team]: <Team />,
}
type ConfirmType = {
type: 'info' | 'warning'
}
type CloudPlanItemProps = {
currentPlan: BasicPlan
plan: BasicPlan
@ -64,15 +59,12 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' })
const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' })
const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan
? t('planNotSupportEducationDiscount', { ns: 'education' })
: undefined
const openAsyncWindow = useAsyncWindowOpen()
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false)
const educationPricingConfirmInfo: ConfirmType = { type: 'warning' }
const btnText = useMemo(() => {
if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent)
@ -139,16 +131,19 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
await handlePayCurrentPlan()
}
const handleContinueCurrentPlan = async () => {
setShowEducationPricingConfirm(false)
const handleSwitchToProfessionalAnnual = async () => {
await handleEducationDiscount()
}
const handleKeepCurrentPlan = async () => {
await handlePayCurrentPlan()
setShowEducationPricingConfirm(false)
}
return (
<div className="flex min-w-0 flex-1 flex-col pb-3">
<div className="flex flex-col px-5 py-4">
<div className="flex flex-col gap-y-6 px-1 pt-10">
{ICON_MAP[plan]}
<div className="flex min-h-[104px] flex-col gap-y-2">
<div className="flex min-h-26 flex-col gap-y-2">
<div className="flex items-center gap-x-2.5">
<div className="text-[30px] leading-[1.2] font-medium text-text-primary">{t(`${i18nPrefix}.name`, { ns: 'billing' })}</div>
{
@ -188,7 +183,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
</>
)}
</div>
<Button
<PlanButton
plan={plan}
isPlanDisabled={isPlanDisabled}
btnText={btnText}
@ -197,41 +192,49 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
/>
</div>
<List plan={plan} />
<AlertDialog
<Dialog
open={showEducationPricingConfirm}
onOpenChange={setShowEducationPricingConfirm}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[520px] overflow-visible"
>
<DialogCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="top-6 right-6"
/>
<div className="flex flex-col gap-2 pr-10">
<DialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('educationPricingConfirm.title', { ns: 'education' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('educationPricingConfirm.description', {
ns: 'education',
planName: selectedPlanName,
billingPeriod: selectedBillingPeriod,
})}
</AlertDialogDescription>
</DialogTitle>
<DialogDescription className="w-full system-md-regular text-text-tertiary">
{t('educationPricingConfirm.description', { ns: 'education' })}
</DialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton
onClick={() => setShowEducationPricingConfirm(false)}
disabled={loading}
<div className="mt-10 flex items-start justify-end gap-3">
<Button
size="large"
onClick={handleKeepCurrentPlan}
disabled={loading || isEducationDiscountLoading}
loading={loading}
className="min-w-38"
>
{t('educationPricingConfirm.cancel', { ns: 'education' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone={educationPricingConfirmInfo.type !== 'info' ? 'destructive' : 'default'}
onClick={handleContinueCurrentPlan}
disabled={loading}
loading={loading}
</Button>
<Button
variant="primary"
size="large"
onClick={handleSwitchToProfessionalAnnual}
disabled={isEducationDiscountLoading}
loading={isEducationDiscountLoading}
className="min-w-61"
>
{t('educationPricingConfirm.continue', { ns: 'education' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -271,6 +271,12 @@ describe('Install Component', () => {
expect(screen.getByTestId('install-multi').parentElement).toHaveClass('overflow-y-auto')
})
it('should constrain the install step so the plugin list can scroll with many items', () => {
const { container } = render(<Install {...defaultProps} />)
expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-hidden')
})
it('should show singular text when one plugin is selected', async () => {
render(<Install {...defaultProps} />)

View File

@ -170,8 +170,8 @@ const Install: FC<Props> = ({
const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace()
return (
<>
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="flex min-h-0 flex-1 flex-col self-stretch overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch overflow-hidden px-6 py-3">
<div className="system-md-regular text-text-secondary">
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { ns: 'plugin', num: selectedPluginsNum })}</p>
</div>
@ -218,7 +218,7 @@ const Install: FC<Props> = ({
</div>
)}
</>
</div>
)
}
export default React.memo(Install)

View File

@ -292,6 +292,12 @@ describe('InstallFromLocalPackage', () => {
expect(screen.getByTestId('is-bundle')).toHaveTextContent('true')
})
it('should constrain dialog height so bundle dependency lists can scroll', () => {
render(<InstallFromLocalPackage {...defaultProps} file={createMockBundleFile()} />)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
})
it('should identify package file correctly', () => {
render(<InstallFromLocalPackage {...defaultProps} />)

View File

@ -93,7 +93,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@ -212,6 +212,19 @@ describe('InstallFromMarketplace', () => {
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
})
it('should constrain bundle dialog height so dependency lists can scroll', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
})
it('should pass isFromMarketPlace as true to bundle component', () => {
const dependencies = createMockDependencies()
render(

View File

@ -77,7 +77,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@ -72,10 +72,10 @@ describe('InfoModal', () => {
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should render copyright when provided', async () => {
it('should render copyright in the full rights reserved format when provided', async () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Dify Inc.',
copyright: 'Dify AI',
}
await renderModal(
@ -86,7 +86,8 @@ describe('InfoModal', () => {
/>,
)
expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
const currentYear = new Date().getFullYear().toString()
expect(screen.getByText(`Copyright © ${currentYear} Dify AI. All Rights Reserved.`)).toBeInTheDocument()
})
it('should render current year in copyright', async () => {

View File

@ -16,6 +16,8 @@ const InfoModal = ({
onClose,
data,
}: Props) => {
const [currentYear] = React.useState(() => new Date().getFullYear())
return (
<Dialog
open={isShow}
@ -24,7 +26,7 @@ const InfoModal = ({
onClose()
}}
>
<DialogContent className="w-full max-w-[400px] min-w-[400px] overflow-hidden! border-none p-0! text-left align-middle">
<DialogContent className="w-full max-w-100 min-w-100 overflow-hidden! border-none p-0! text-left align-middle">
<DialogCloseButton />
<div className={cn('flex flex-col items-center gap-4 px-4 pt-10 pb-8')}>
@ -35,15 +37,19 @@ const InfoModal = ({
background={data?.icon_background || appDefaultIconBackground}
imageUrl={data?.icon_url}
/>
<div className="system-xl-semibold text-text-secondary">{data?.title}</div>
<div className="w-full text-center">
<div className="system-xl-semibold text-text-secondary">{data?.title}</div>
<div className="mt-1 system-xl-medium text-text-tertiary">{data?.description}</div>
</div>
<div className="system-xs-regular text-text-tertiary">
{/* copyright */}
{data?.copyright && (
<div>
©
{(new Date()).getFullYear()}
Copyright ©
{currentYear}
{' '}
{data?.copyright}
. All Rights Reserved.
</div>
)}
{data?.custom_disclaimer && (

View File

@ -48,7 +48,7 @@ export const tagUpdateContract = base
name: string
}
}>())
.output(type<unknown>())
.output(type<Tag>())
export const tagDeleteContract = base
.route({

View File

@ -30,7 +30,6 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
const updateTagMutation = useMutation(consoleQuery.tags.update.mutationOptions())
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(tag.name)
const editTag = (tagId: string, name: string) => {
if (name === tag.name) {
setIsEditing(false)
@ -38,7 +37,6 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
}
if (!name) {
toast.error('tag name is empty')
setName(tag.name)
setIsEditing(false)
return
}
@ -53,13 +51,11 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
}, {
onSuccess: () => {
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setName(name)
setIsEditing(false)
onTagsChange?.()
},
onError: () => {
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
setName(tag.name)
setIsEditing(false)
},
})
@ -123,7 +119,22 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
</button>
</>
)}
{isEditing && (<input aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`} className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary" autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && editTag(tag.id, name)} onBlur={() => editTag(tag.id, name)} />)}
{isEditing && (
<input
aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`}
className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary"
autoFocus
defaultValue={tag.name}
onKeyDown={(e) => {
if (e.key !== 'Enter' || e.nativeEvent.isComposing)
return
e.preventDefault()
e.currentTarget.blur()
}}
onBlur={e => editTag(tag.id, e.currentTarget.value)}
/>
)}
</div>
<AlertDialog open={showRemoveModal} onOpenChange={open => !open && setShowRemoveModal(false)}>
<AlertDialogContent>

View File

@ -16,10 +16,10 @@
"currentSigned": "تم تسجيل الدخول حاليًا باسم",
"educationPricingConfirm.billingPeriod.monthly": "شهري",
"educationPricingConfirm.billingPeriod.yearly": "سنوي",
"educationPricingConfirm.cancel": "إلغاء",
"educationPricingConfirm.continue": "المتابعة بدون خصم",
"educationPricingConfirm.description": "خطتك {{planName}} {{billingPeriod}} لا تدعم الخصم التعليمي. فقط خطة Professional السنوية مؤهلة.",
"educationPricingConfirm.title": "الخصم التعليمي غير متاح",
"educationPricingConfirm.cancel": "الاحتفاظ بالخطة الحالية",
"educationPricingConfirm.continue": "التبديل إلى Professional السنوية",
"educationPricingConfirm.description": "ينطبق الخصم التعليمي على خطة Professional السنوية فقط. الاحتفاظ بخطتك الحالية لن يتضمن الخصم.",
"educationPricingConfirm.title": "الخطة التي اخترتها لا تدعم الخصم التعليمي",
"emailLabel": "بريدك الإلكتروني الحالي",
"form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك",
"form.schoolName.title": "اسم مدرستك",

View File

@ -16,10 +16,10 @@
"currentSigned": "DERZEIT ANGEMELDET ALS",
"educationPricingConfirm.billingPeriod.monthly": "monatlich",
"educationPricingConfirm.billingPeriod.yearly": "jährlich",
"educationPricingConfirm.cancel": "Abbrechen",
"educationPricingConfirm.continue": "Ohne Rabatt fortfahren",
"educationPricingConfirm.description": "Ihr {{planName}} {{billingPeriod}} Plan unterstützt den Bildungsrabatt nicht. Nur der Professional-Jahresplan ist berechtigt.",
"educationPricingConfirm.title": "Bildungsrabatt nicht verfügbar",
"educationPricingConfirm.cancel": "Aktuellen Plan behalten",
"educationPricingConfirm.continue": "Zu Professional jährlich wechseln",
"educationPricingConfirm.description": "Der Bildungsrabatt gilt nur für den jährlichen Professional-Plan. Wenn Sie Ihren aktuellen Plan behalten, ist der Rabatt nicht enthalten.",
"educationPricingConfirm.title": "Ihr ausgewählter Plan unterstützt den Bildungsrabatt nicht",
"emailLabel": "Ihre aktuelle E-Mail",
"form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.",
"form.schoolName.title": "Ihr Schulname",

View File

@ -16,10 +16,10 @@
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "monthly",
"educationPricingConfirm.billingPeriod.yearly": "annual",
"educationPricingConfirm.cancel": "Cancel",
"educationPricingConfirm.continue": "Continue without discount",
"educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.",
"educationPricingConfirm.title": "Education discount not available",
"educationPricingConfirm.cancel": "Keep current plan",
"educationPricingConfirm.continue": "Switch to Professional Annual",
"educationPricingConfirm.description": "The education discount applies to the Professional annual plan only. Keeping your current plan won't include the discount.",
"educationPricingConfirm.title": "Your selected plan doesn't support the education discount",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",

View File

@ -16,10 +16,10 @@
"currentSigned": "ACTUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensual",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sin descuento",
"educationPricingConfirm.description": "Tu plan {{planName}} {{billingPeriod}} no admite el descuento educativo. Solo el plan Professional anual es elegible.",
"educationPricingConfirm.title": "Descuento educativo no disponible",
"educationPricingConfirm.cancel": "Mantener el plan actual",
"educationPricingConfirm.continue": "Cambiar a Professional anual",
"educationPricingConfirm.description": "El descuento educativo solo se aplica al plan Professional anual. Si mantienes tu plan actual, no se incluirá el descuento.",
"educationPricingConfirm.title": "El plan seleccionado no admite el descuento educativo",
"emailLabel": "Tu correo electrónico actual",
"form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela",
"form.schoolName.title": "El nombre de tu escuela",

View File

@ -16,10 +16,10 @@
"currentSigned": "اکنون به عنوان",
"educationPricingConfirm.billingPeriod.monthly": "ماهانه",
"educationPricingConfirm.billingPeriod.yearly": "سالانه",
"educationPricingConfirm.cancel": "لغو",
"educationPricingConfirm.continue": "ادامه بدون تخفیف",
"educationPricingConfirm.description": "طرح {{planName}} {{billingPeriod}} شما از تخفیف آموزشی پشتیبانی نمی‌کند. فقط طرح سالانه Professional واجد شرایط است.",
"educationPricingConfirm.title": "تخفیف آموزشی در دسترس نیست",
"educationPricingConfirm.cancel": "حفظ طرح فعلی",
"educationPricingConfirm.continue": "تغییر به Professional سالانه",
"educationPricingConfirm.description": "تخفیف آموزشی فقط برای طرح سالانه Professional اعمال می‌شود. با حفظ طرح فعلی، این تخفیف شامل نمی‌شود.",
"educationPricingConfirm.title": "طرح انتخاب‌شده شما از تخفیف آموزشی پشتیبانی نمی‌کند",
"emailLabel": "ایمیل فعلی شما",
"form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید",
"form.schoolName.title": "نام مدرسه شما",

View File

@ -16,10 +16,10 @@
"currentSigned": "ACTUELLEMENT CONNECTÉ EN TANT QUE",
"educationPricingConfirm.billingPeriod.monthly": "mensuel",
"educationPricingConfirm.billingPeriod.yearly": "annuel",
"educationPricingConfirm.cancel": "Annuler",
"educationPricingConfirm.continue": "Continuer sans remise",
"educationPricingConfirm.description": "Votre plan {{planName}} {{billingPeriod}} ne prend pas en charge la remise éducative. Seul le plan Professional annuel est éligible.",
"educationPricingConfirm.title": "Remise éducative non disponible",
"educationPricingConfirm.cancel": "Conserver le plan actuel",
"educationPricingConfirm.continue": "Passer à Professional annuel",
"educationPricingConfirm.description": "La remise éducation s'applique uniquement au plan Professional annuel. En conservant votre plan actuel, la remise ne sera pas incluse.",
"educationPricingConfirm.title": "Le plan sélectionné ne prend pas en charge la remise éducation",
"emailLabel": "Votre email actuel",
"form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école",
"form.schoolName.title": "Le nom de votre école",

View File

@ -16,10 +16,10 @@
"currentSigned": "वर्तमान में साइन इन किया गया है के रूप में",
"educationPricingConfirm.billingPeriod.monthly": "मासिक",
"educationPricingConfirm.billingPeriod.yearly": "वार्षिक",
"educationPricingConfirm.cancel": "रद्द करें",
"educationPricingConfirm.continue": "छूट के बिना जारी रखें",
"educationPricingConfirm.description": "आपका {{planName}} {{billingPeriod}} प्लान शिक्षा छूट का समर्थन नहीं करता। केवल Professional वार्षिक प्लान पात्र है।",
"educationPricingConfirm.title": "शिक्षा छूट उपलब्ध नहीं",
"educationPricingConfirm.cancel": "वर्तमान प्लान रखें",
"educationPricingConfirm.continue": "Professional वार्षिक पर स्विच करें",
"educationPricingConfirm.description": "शिक्षा छूट केवल Professional वार्षिक प्लान पर लागू होती है। अपना वर्तमान प्लान रखने पर छूट शामिल नहीं होगी।",
"educationPricingConfirm.title": "आपका चुना हुआ प्लान शिक्षा छूट का समर्थन नहीं करता",
"emailLabel": "आपका वर्तमान ईमेल",
"form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें",
"form.schoolName.title": "आपके स्कूल का नाम",

View File

@ -16,10 +16,10 @@
"currentSigned": "SAAT INI MASUK SEBAGAI",
"educationPricingConfirm.billingPeriod.monthly": "bulanan",
"educationPricingConfirm.billingPeriod.yearly": "tahunan",
"educationPricingConfirm.cancel": "Batal",
"educationPricingConfirm.continue": "Lanjutkan tanpa diskon",
"educationPricingConfirm.description": "Paket {{planName}} {{billingPeriod}} Anda tidak mendukung diskon pendidikan. Hanya paket Professional tahunan yang memenuhi syarat.",
"educationPricingConfirm.title": "Diskon pendidikan tidak tersedia",
"educationPricingConfirm.cancel": "Tetap gunakan paket saat ini",
"educationPricingConfirm.continue": "Beralih ke Professional Tahunan",
"educationPricingConfirm.description": "Diskon pendidikan hanya berlaku untuk paket Professional tahunan. Jika tetap menggunakan paket saat ini, diskon tidak akan disertakan.",
"educationPricingConfirm.title": "Paket yang Anda pilih tidak mendukung diskon pendidikan",
"emailLabel": "Email Anda saat ini",
"form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat",
"form.schoolName.title": "Nama Sekolah Anda",

View File

@ -16,10 +16,10 @@
"currentSigned": "ATTUALMENTE ACCEDUTO COME",
"educationPricingConfirm.billingPeriod.monthly": "mensile",
"educationPricingConfirm.billingPeriod.yearly": "annuale",
"educationPricingConfirm.cancel": "Annulla",
"educationPricingConfirm.continue": "Continua senza sconto",
"educationPricingConfirm.description": "Il tuo piano {{planName}} {{billingPeriod}} non supporta lo sconto educativo. Solo il piano Professional annuale è idoneo.",
"educationPricingConfirm.title": "Sconto educativo non disponibile",
"educationPricingConfirm.cancel": "Mantieni il piano attuale",
"educationPricingConfirm.continue": "Passa a Professional annuale",
"educationPricingConfirm.description": "Lo sconto Education si applica solo al piano Professional annuale. Mantenendo il piano attuale, lo sconto non verrà incluso.",
"educationPricingConfirm.title": "Il piano selezionato non supporta lo sconto Education",
"emailLabel": "La tua email attuale",
"form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola",
"form.schoolName.title": "Il Nome della tua Scuola",

View File

@ -16,10 +16,10 @@
"currentSigned": "現在ログイン中のアカウントは",
"educationPricingConfirm.billingPeriod.monthly": "月次",
"educationPricingConfirm.billingPeriod.yearly": "年次",
"educationPricingConfirm.cancel": "キャンセル",
"educationPricingConfirm.continue": "割引なしで続行",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} プランは教育割引に対応していません。Professional 年プランのみが対象です。",
"educationPricingConfirm.title": "教育割引は利用できません",
"educationPricingConfirm.cancel": "現在のプランを維持",
"educationPricingConfirm.continue": "Professional 年間プランに切り替える",
"educationPricingConfirm.description": "教育割引は Professional 年プランのみ適用されます。現在のプランを維持すると、割引は適用されません。",
"educationPricingConfirm.title": "選択したプランは教育割引に対応していません",
"emailLabel": "現在のメールアドレス",
"form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。",
"form.schoolName.title": "学校名",

View File

@ -16,10 +16,10 @@
"currentSigned": "현재 로그인 중입니다",
"educationPricingConfirm.billingPeriod.monthly": "월간",
"educationPricingConfirm.billingPeriod.yearly": "연간",
"educationPricingConfirm.cancel": "취소",
"educationPricingConfirm.continue": "할인 없이 계속",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} 플랜은 교육 할인을 지원하지 않습니다. Professional 연간 플랜만 자격이 있습니다.",
"educationPricingConfirm.title": "교육 할인 불가",
"educationPricingConfirm.cancel": "현재 플랜 유지",
"educationPricingConfirm.continue": "Professional 연간으로 전환",
"educationPricingConfirm.description": "교육 할인은 Professional 연간 플랜적용됩니다. 현재 플랜을 유지하면 할인이 포함되지 않습니다.",
"educationPricingConfirm.title": "선택한 플랜은 교육 할인을 지원하지 않습니다",
"emailLabel": "현재 이메일",
"form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.",
"form.schoolName.title": "당신의 학교 이름",

View File

@ -16,10 +16,10 @@
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "maandelijks",
"educationPricingConfirm.billingPeriod.yearly": "jaarlijks",
"educationPricingConfirm.cancel": "Annuleren",
"educationPricingConfirm.continue": "Doorgaan zonder korting",
"educationPricingConfirm.description": "Uw {{planName}} {{billingPeriod}} abonnement ondersteunt de onderwijskorting niet. Alleen het jaarlijkse Professional abonnement komt in aanmerking.",
"educationPricingConfirm.title": "Onderwijskorting niet beschikbaar",
"educationPricingConfirm.cancel": "Huidig abonnement behouden",
"educationPricingConfirm.continue": "Overschakelen naar Professional jaarlijks",
"educationPricingConfirm.description": "De onderwijskorting is alleen van toepassing op het jaarlijkse Professional-abonnement. Als u uw huidige abonnement behoudt, is de korting niet inbegrepen.",
"educationPricingConfirm.title": "Uw geselecteerde abonnement ondersteunt de onderwijskorting niet",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",

View File

@ -16,10 +16,10 @@
"currentSigned": "AKTUALNIE ZALOGOWANY JAKO",
"educationPricingConfirm.billingPeriod.monthly": "miesięcznie",
"educationPricingConfirm.billingPeriod.yearly": "rocznie",
"educationPricingConfirm.cancel": "Anuluj",
"educationPricingConfirm.continue": "Kontynuuj bez rabatu",
"educationPricingConfirm.description": "Twój plan {{planName}} {{billingPeriod}} nie obsługuje rabatu edukacyjnego. Tylko roczny plan Professional jest uprawniony.",
"educationPricingConfirm.title": "Rabat edukacyjny niedostępny",
"educationPricingConfirm.cancel": "Zachowaj obecny plan",
"educationPricingConfirm.continue": "Przełącz na Professional roczny",
"educationPricingConfirm.description": "Zniżka edukacyjna dotyczy tylko rocznego planu Professional. Pozostanie przy obecnym planie nie obejmie zniżki.",
"educationPricingConfirm.title": "Wybrany plan nie obsługuje zniżki edukacyjnej",
"emailLabel": "Twój aktualny email",
"form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły",
"form.schoolName.title": "Nazwa Twojej Szkoły",

View File

@ -16,10 +16,10 @@
"currentSigned": "ATUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensal",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sem desconto",
"educationPricingConfirm.description": "Seu plano {{planName}} {{billingPeriod}} não suporta o desconto educacional. Apenas o plano Professional anual é elegível.",
"educationPricingConfirm.title": "Desconto educacional não disponível",
"educationPricingConfirm.cancel": "Manter plano atual",
"educationPricingConfirm.continue": "Mudar para Professional anual",
"educationPricingConfirm.description": "O desconto educacional se aplica apenas ao plano Professional anual. Manter seu plano atual não incluirá o desconto.",
"educationPricingConfirm.title": "O plano selecionado não aceita o desconto educacional",
"emailLabel": "Seu e-mail atual",
"form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola",
"form.schoolName.title": "O nome da sua escola",

View File

@ -16,10 +16,10 @@
"currentSigned": "CONEXIUNE ÎN PREZENT CA",
"educationPricingConfirm.billingPeriod.monthly": "lunar",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Anulează",
"educationPricingConfirm.continue": "Continuă fără reducere",
"educationPricingConfirm.description": "Planul tău {{planName}} {{billingPeriod}} nu suportă reducerea educațională. Doar planul Professional anual este eligibil.",
"educationPricingConfirm.title": "Reducerea educațională nu este disponibilă",
"educationPricingConfirm.cancel": "Păstrează planul curent",
"educationPricingConfirm.continue": "Treci la Professional anual",
"educationPricingConfirm.description": "Reducerea educațională se aplică doar planului Professional anual. Dacă păstrezi planul curent, reducerea nu va fi inclusă.",
"educationPricingConfirm.title": "Planul selectat nu acceptă reducerea educațională",
"emailLabel": "Emailul tău curent",
"form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră",
"form.schoolName.title": "Numele Școlii Tale",

View File

@ -16,10 +16,10 @@
"currentSigned": "В ДАННЫЙ МОМЕНТ ВХОД В ПРОФИЛЬ КАК",
"educationPricingConfirm.billingPeriod.monthly": "ежемесячно",
"educationPricingConfirm.billingPeriod.yearly": "ежегодно",
"educationPricingConfirm.cancel": "Отмена",
"educationPricingConfirm.continue": родолжить без скидки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не поддерживает образовательную скидку. Только годовой план Professional имеет право на скидку.",
"educationPricingConfirm.title": "Образовательная скидка недоступна",
"educationPricingConfirm.cancel": "Оставить текущий план",
"educationPricingConfirm.continue": ерейти на Professional годовой",
"educationPricingConfirm.description": "Образовательная скидка применяется только к годовому плану Professional. Если оставить текущий план, скидка не будет включена.",
"educationPricingConfirm.title": "Выбранный план не поддерживает образовательную скидку",
"emailLabel": "Ваш текущий адрес электронной почты",
"form.schoolName.placeholder": "Введите официальное, полное название вашей школы",
"form.schoolName.title": "Название вашей школы",

View File

@ -16,10 +16,10 @@
"currentSigned": "Trenutno prijavljen kot",
"educationPricingConfirm.billingPeriod.monthly": "mesečno",
"educationPricingConfirm.billingPeriod.yearly": "letno",
"educationPricingConfirm.cancel": "Prekliči",
"educationPricingConfirm.continue": "Nadaljuj brez popusta",
"educationPricingConfirm.description": "Vaš načrt {{planName}} {{billingPeriod}} ne podpira izobraževalnega popusta. Do popusta je upravičen samo letni načrt Professional.",
"educationPricingConfirm.title": "Izobraževalni popust ni na voljo",
"educationPricingConfirm.cancel": "Obdrži trenutni paket",
"educationPricingConfirm.continue": "Preklopi na letni Professional",
"educationPricingConfirm.description": "Izobraževalni popust velja samo za letni paket Professional. Če obdržite trenutni paket, popust ne bo vključen.",
"educationPricingConfirm.title": "Izbrani paket ne podpira izobraževalnega popusta",
"emailLabel": "Vaš trenutni elektronski naslov",
"form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole",
"form.schoolName.title": "Ime vaše šole",

View File

@ -16,10 +16,10 @@
"currentSigned": "ลงชื่อเข้าใช้ในฐานะ",
"educationPricingConfirm.billingPeriod.monthly": "รายเดือน",
"educationPricingConfirm.billingPeriod.yearly": "รายปี",
"educationPricingConfirm.cancel": "ยกเลิก",
"educationPricingConfirm.continue": "ดำเนินการต่อโดยไม่มีส่วนลด",
"educationPricingConfirm.description": "แผน {{planName}} {{billingPeriod}} ของคุณไม่รองรับส่วนลดการศึกษา เฉพาะแผน Professional รายปีเท่านั้นที่มีสิทธิ์",
"educationPricingConfirm.title": "ส่วนลดการศึกษาไม่พร้อมใช้งาน",
"educationPricingConfirm.cancel": "ใช้แผนปัจจุบันต่อ",
"educationPricingConfirm.continue": "เปลี่ยนเป็น Professional รายปี",
"educationPricingConfirm.description": "ส่วนลดการศึกษาใช้ได้เฉพาะกับแผน Professional รายปีเท่านั้น หากใช้แผนปัจจุบันต่อ จะไม่มีส่วนลดนี้รวมอยู่ด้วย",
"educationPricingConfirm.title": "แผนที่คุณเลือกไม่รองรับส่วนลดการศึกษา",
"emailLabel": "อีเมลปัจจุบันของคุณ",
"form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ",
"form.schoolName.title": "ชื่อโรงเรียนของคุณ",

View File

@ -16,10 +16,10 @@
"currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ",
"educationPricingConfirm.billingPeriod.monthly": "aylık",
"educationPricingConfirm.billingPeriod.yearly": "yıllık",
"educationPricingConfirm.cancel": "İptal",
"educationPricingConfirm.continue": "İndirim olmadan devam et",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca yıllık Professional planı uygundur.",
"educationPricingConfirm.title": "Eğitim indirimi mevcut değil",
"educationPricingConfirm.cancel": "Mevcut planı koru",
"educationPricingConfirm.continue": "Professional yıllık plana geç",
"educationPricingConfirm.description": "Eğitim indirimi yalnızca yıllık Professional planı için geçerlidir. Mevcut planınızı korursanız indirim dahil edilmez.",
"educationPricingConfirm.title": "Seçtiğiniz plan eğitim indirimini desteklemiyor",
"emailLabel": "Şu anki e-posta adresin",
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
"form.schoolName.title": "Okulunuzun Adı",

View File

@ -16,10 +16,10 @@
"currentSigned": "В даний момент ви підписані як",
"educationPricingConfirm.billingPeriod.monthly": "щомісячно",
"educationPricingConfirm.billingPeriod.yearly": "щорічно",
"educationPricingConfirm.cancel": "Скасувати",
"educationPricingConfirm.continue": родовжити без знижки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не підтримує освітню знижку. Лише річний план Professional має право на знижку.",
"educationPricingConfirm.title": "Освітня знижка недоступна",
"educationPricingConfirm.cancel": "Залишити поточний план",
"educationPricingConfirm.continue": ерейти на Professional річний",
"educationPricingConfirm.description": "Освітня знижка застосовується лише до річного плану Professional. Якщо залишити поточний план, знижку не буде включено.",
"educationPricingConfirm.title": "Вибраний план не підтримує освітню знижку",
"emailLabel": "Ваш поточний електронний лист",
"form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи",
"form.schoolName.title": "Ваша назва школи",

View File

@ -16,10 +16,10 @@
"currentSigned": "HIỆN ĐANG ĐĂNG NHẬP VÀO",
"educationPricingConfirm.billingPeriod.monthly": "hàng tháng",
"educationPricingConfirm.billingPeriod.yearly": "hàng năm",
"educationPricingConfirm.cancel": "Hủy",
"educationPricingConfirm.continue": "Tiếp tục không có giảm giá",
"educationPricingConfirm.description": "Gói {{planName}} {{billingPeriod}} của bạn không hỗ trợ giảm giá giáo dục. Chỉ gói Professional hàng năm mới được áp dụng.",
"educationPricingConfirm.title": "Giảm giá giáo dục không khả dụng",
"educationPricingConfirm.cancel": "Giữ gói hiện tại",
"educationPricingConfirm.continue": "Chuyển sang Professional hằng năm",
"educationPricingConfirm.description": "Giảm giá giáo dục chỉ áp dụng cho gói Professional hng năm. Nếu giữ gói hiện tại, giảm giá sẽ không được áp dụng.",
"educationPricingConfirm.title": "Gói bạn chọn không hỗ trợ giảm giá giáo dục",
"emailLabel": "Email hiện tại của bạn",
"form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn",
"form.schoolName.title": "Tên Trường Của Bạn",

View File

@ -16,10 +16,10 @@
"currentSigned": "您当前登录的账户是",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用优惠继续",
"educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 年付计划符合条件。",
"educationPricingConfirm.title": "教育优惠不适用于该计划",
"educationPricingConfirm.cancel": "保留当前计划",
"educationPricingConfirm.continue": "切换到 Professional 年付",
"educationPricingConfirm.description": "教育优惠仅适用于 Professional 年付计划。保留当前计划将不包含该优惠。",
"educationPricingConfirm.title": "你选择的计划不支持教育优惠",
"emailLabel": "您当前的邮箱",
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
"form.schoolName.title": "您的学校名称",

View File

@ -16,10 +16,10 @@
"currentSigned": "當前以以下身份登入",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用優惠繼續",
"educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 年付方案符合資格。",
"educationPricingConfirm.title": "教育優惠不適用於此方案",
"educationPricingConfirm.cancel": "保留目前方案",
"educationPricingConfirm.continue": "切換到 Professional 年付",
"educationPricingConfirm.description": "教育優惠僅適用於 Professional 年付方案。保留目前方案將不包含此優惠。",
"educationPricingConfirm.title": "你選擇的方案不支援教育優惠",
"emailLabel": "您當前的電子郵件",
"form.schoolName.placeholder": "請輸入您學校的正式全名",
"form.schoolName.title": "你的學校名稱",

View File

@ -187,15 +187,20 @@ describe('consoleQuery tag mutation defaults', () => {
queryClient.setQueryData(appListKey, [targetTag, otherTag])
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
const updatedTag = createTag({
...targetTag,
name: 'After',
binding_count: 5,
})
const mutationOptions = consoleQuery.tags.update.mutationOptions()
await mutationOptions.onSuccess?.(
undefined,
updatedTag,
{
params: {
tagId: targetTag.id,
},
body: {
name: 'After',
name: 'Ignored Client Name',
},
},
undefined,
@ -203,10 +208,7 @@ describe('consoleQuery tag mutation defaults', () => {
)
expect(queryClient.getQueryData(appListKey)).toEqual([
{
...targetTag,
name: 'After',
},
updatedTag,
otherTag,
])
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])

View File

@ -108,16 +108,13 @@ export const consoleQuery = createTanstackQueryUtils(consoleClient, {
},
update: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
onSuccess: (updatedTag, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tagId
? {
...tag,
name: variables.body.name,
}
? updatedTag
: tag),
)
},