mirror of
https://github.com/langgenius/dify.git
synced 2026-05-15 14:37:59 +08:00
Compare commits
8 Commits
fix/docker
...
fix/webapp
| Author | SHA1 | Date | |
|---|---|---|---|
| d0a57372ba | |||
| 28153df4d3 | |||
| 3bc3386535 | |||
| 7654f14241 | |||
| 194b54bae4 | |||
| 0e16d36edb | |||
| 432a6412a3 | |||
| 55d05fe52d |
@ -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
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -21,7 +21,6 @@ class SaveTagPayload(BaseModel):
|
||||
|
||||
class UpdateTagPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
type: TagType
|
||||
|
||||
|
||||
class TagBindingCreatePayload(BaseModel):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
108
api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py
Normal file
108
api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py
Normal 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}"
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
4
api/uv.lock
generated
@ -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" },
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -48,7 +48,7 @@ export const tagUpdateContract = base
|
||||
name: string
|
||||
}
|
||||
}>())
|
||||
.output(type<unknown>())
|
||||
.output(type<Tag>())
|
||||
|
||||
export const tagDeleteContract = base
|
||||
.route({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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": "اسم مدرستك",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "نام مدرسه شما",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "आपके स्कूल का नाम",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "学校名",
|
||||
|
||||
@ -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": "당신의 학교 이름",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Название вашей школы",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ชื่อโรงเรียนของคุณ",
|
||||
|
||||
@ -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ı",
|
||||
|
||||
@ -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": "Ваша назва школи",
|
||||
|
||||
@ -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 hằng 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",
|
||||
|
||||
@ -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": "您的学校名称",
|
||||
|
||||
@ -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": "你的學校名稱",
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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),
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user