diff --git a/api/commands/rbac.py b/api/commands/rbac.py index 33eb5858da4..630304b67e3 100644 --- a/api/commands/rbac.py +++ b/api/commands/rbac.py @@ -1,12 +1,52 @@ from __future__ import annotations +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor, as_completed + import click from sqlalchemy import select +from configs import dify_config from core.db.session_factory import session_factory from models import TenantAccountJoin, TenantAccountRole from services.enterprise.rbac_service import ListOption, RBACService +_LEGACY_ROLE_TO_BUILTIN_TAG = { + TenantAccountRole.OWNER.value: "owner", + TenantAccountRole.ADMIN.value: "admin", + TenantAccountRole.EDITOR.value: "editor", + TenantAccountRole.NORMAL.value: "normal", + TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator", +} + + +def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]: + """Resolve every legacy workspace role to the current tenant's builtin RBAC role id. + + The migration replays the old `TenantAccountJoin.role` values onto the + RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and + identified by runtime ids, so the command must look them up per tenant. + """ + roles = RBACService.Roles.list( + tenant_id=tenant_id, + account_id=operator_account_id, + options=ListOption(page_number=1, results_per_page=100), + ).data + role_id_by_tag = { + role.role_tag: role.id + for role in roles + if role.is_builtin and role.category == "global_system_default" and role.role_tag + } + resolved: dict[str, str] = {} + for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items(): + role_id = role_id_by_tag.get(expected_builtin_tag) + if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED: + continue + if not role_id: + raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}") + resolved[legacy_role] = role_id + return resolved + def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str: """Resolve a legacy workspace role to the current tenant's builtin RBAC role id. @@ -15,26 +55,86 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and identified by runtime ids, so the command must look them up per tenant. """ - expected_builtin_tag = { - TenantAccountRole.OWNER.value: "owner", - TenantAccountRole.ADMIN.value: "admin", - TenantAccountRole.EDITOR.value: "editor", - TenantAccountRole.NORMAL.value: "normal", - TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator", - }.get(legacy_role) - if not expected_builtin_tag: + if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG: raise ValueError(f"Unsupported legacy workspace role: {legacy_role}") - roles = RBACService.Roles.list( + return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role] + + +def _iter_tenant_member_batches( + tenant_id: str | None, + *, + db_batch_size: int, + api_batch_size: int, +) -> Iterator[tuple[str, str, list[tuple[str, str]]]]: + """Yield legacy member roles in tenant-scoped API-sized batches. + + Rows are projected to primitive values and streamed from the database, so + the command never materializes every TenantAccountJoin ORM object. The + iterator only keeps one tenant's API-sized batches in memory while it + finds that tenant's owner account. + """ + with session_factory.create_session() as session: + stmt = ( + select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role) + .order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc()) + .execution_options(yield_per=db_batch_size) + ) + if tenant_id: + stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id) + + current_tenant_id: str | None = None + owner_account_id: str | None = None + batches: list[list[tuple[str, str]]] = [] + batch: list[tuple[str, str]] = [] + + def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]: + if current_tenant_id is None: + return + if batch: + batches.append(batch.copy()) + if not owner_account_id: + raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}") + for item in batches: + yield current_tenant_id, owner_account_id, item + + for row in session.execute(stmt): + workspace_id = str(row.tenant_id) + if current_tenant_id is not None and workspace_id != current_tenant_id: + yield from flush_current_tenant() + owner_account_id = None + batches = [] + batch = [] + current_tenant_id = workspace_id + account_id = str(row.account_id) + role = str(row.role) + if role == TenantAccountRole.OWNER.value: + owner_account_id = account_id + batch.append((account_id, role)) + if len(batch) >= api_batch_size: + batches.append(batch) + batch = [] + + yield from flush_current_tenant() + + +def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool: + return current_roles_by_account_id.get(account_id) == {role_id} + + +def _replace_member_role( + tenant_id: str, + operator_account_id: str, + member_account_id: str, + role_id: str, +) -> str: + RBACService.MemberRoles.replace( tenant_id=tenant_id, account_id=operator_account_id, - options=ListOption(page_number=1, results_per_page=100), - ).data - for role in roles: - if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag: - return str(role.id) - - raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}") + member_account_id=member_account_id, + role_ids=[role_id], + ) + return member_account_id @click.command( @@ -42,7 +142,16 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro ) @click.option("--tenant-id", help="Only migrate a single workspace.") @click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.") -def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None: +@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.") +@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.") +@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.") +def migrate_member_roles_to_rbac( + tenant_id: str | None, + dry_run: bool, + db_batch_size: int, + api_batch_size: int, + workers: int, +) -> None: """Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data. This is an offline migration command for workspaces that already have @@ -50,63 +159,102 @@ def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None: member-role binding store. """ click.echo(click.style("Starting RBAC member-role migration.", fg="green")) + if workers < 1: + raise click.BadParameter("workers must be >= 1", param_hint="--workers") - with session_factory.create_session() as session: - stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc()) - if tenant_id: - stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id) + tenant_count = 0 + scanned_count = 0 + skipped_count = 0 + migrated_count = 0 + current_tenant_id: str | None = None + role_ids_by_legacy_role: dict[str, str] = {} - joins = list(session.scalars(stmt).all()) + for workspace_id, owner_account_id, batch in _iter_tenant_member_batches( + tenant_id, + db_batch_size=db_batch_size, + api_batch_size=api_batch_size, + ): + scanned_count += len(batch) + if workspace_id != current_tenant_id: + tenant_count += 1 + current_tenant_id = workspace_id + role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id) + click.echo(f"tenant={workspace_id}") - if not joins: + current_roles_by_account_id: dict[str, set[str]] = {} + if not dry_run: + current_roles = RBACService.MemberRoles.batch_get( + tenant_id=workspace_id, + account_id=owner_account_id, + member_account_ids=[account_id for account_id, _ in batch], + ) + current_roles_by_account_id = { + item.account_id: {str(role.id) for role in item.roles} for item in current_roles + } + + replace_jobs: list[tuple[str, str]] = [] + for member_account_id, legacy_role in batch: + resolved_role_id = role_ids_by_legacy_role.get(legacy_role) + if not resolved_role_id: + raise ValueError(f"Unsupported legacy workspace role: {legacy_role}") + + if dry_run: + click.echo( + f"tenant={workspace_id} member={member_account_id} " + f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}" + ) + continue + + if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id): + skipped_count += 1 + continue + + replace_jobs.append((member_account_id, resolved_role_id)) + + if replace_jobs: + if workers == 1: + for member_account_id, resolved_role_id in replace_jobs: + _replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id) + migrated_count += 1 + else: + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [ + executor.submit( + _replace_member_role, + workspace_id, + owner_account_id, + member_account_id, + resolved_role_id, + ) + for member_account_id, resolved_role_id in replace_jobs + ] + for future in as_completed(futures): + future.result() + migrated_count += 1 + + if scanned_count % 10000 == 0: + click.echo( + f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}", + err=True, + ) + + if scanned_count == 0: click.echo(click.style("No workspace members found for migration.", fg="yellow")) return - owner_account_by_tenant: dict[str, str] = {} - resolved_role_ids: dict[tuple[str, str], str] = {} - migrated_count = 0 - - for join in joins: - workspace_id = str(join.tenant_id) - member_account_id = str(join.account_id) - legacy_role = str(join.role) - - if workspace_id not in owner_account_by_tenant: - owner_join = next( - ( - item - for item in joins - if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value - ), - None, - ) - if not owner_join: - raise ValueError(f"Workspace owner not found for tenant={workspace_id}") - owner_account_by_tenant[workspace_id] = str(owner_join.account_id) - - operator_account_id = owner_account_by_tenant[workspace_id] - cache_key = (workspace_id, legacy_role) - if cache_key not in resolved_role_ids: - resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role) - - resolved_role_id = resolved_role_ids[cache_key] - click.echo( - f"tenant={workspace_id} member={member_account_id} " - f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}" - ) - - if dry_run: - continue - - RBACService.MemberRoles.replace( - tenant_id=workspace_id, - account_id=operator_account_id, - member_account_id=member_account_id, - role_ids=[resolved_role_id], - ) - migrated_count += 1 - if dry_run: - click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow")) + click.echo( + click.style( + f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. " + "No RBAC bindings were written.", + fg="yellow", + ) + ) else: - click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green")) + click.echo( + click.style( + f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, " + f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.", + fg="green", + ) + ) diff --git a/api/core/mcp/mcp_client.py b/api/core/mcp/mcp_client.py index 2b0645b5589..c6d33292dae 100644 --- a/api/core/mcp/mcp_client.py +++ b/api/core/mcp/mcp_client.py @@ -1,10 +1,13 @@ import logging +import re from collections.abc import Callable from contextlib import AbstractContextManager, ExitStack from types import TracebackType from typing import Any from urllib.parse import urlparse +from flask import has_request_context, request + from core.mcp.client.sse_client import sse_client from core.mcp.client.streamable_client import streamablehttp_client from core.mcp.error import MCPConnectionError @@ -23,10 +26,22 @@ class MCPClient: sse_read_timeout: float | None = None, ): self.server_url = server_url - self.headers = headers or {} + self.headers = headers.copy() if headers else {} self.timeout = timeout self.sse_read_timeout = sse_read_timeout + # Substitute placeholders with incoming request headers if in a request context + if has_request_context() and self.headers: + pattern = re.compile(r"\{\{\s*request\.headers?\.(.+?)\s*\}\}", re.IGNORECASE) + for key, value in list(self.headers.items()): + if isinstance(value, str): + + def replace_func(match): + header_name = match.group(1) + return request.headers.get(header_name, "") + + self.headers[key] = pattern.sub(replace_func, value) + # Initialize session and client objects self._session: ClientSession | None = None self._exit_stack = ExitStack() diff --git a/api/migrations/versions/2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py b/api/migrations/versions/2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py index 1aa92b7d50e..d55df007bbe 100644 --- a/api/migrations/versions/2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py +++ b/api/migrations/versions/2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py @@ -45,34 +45,52 @@ def upgrade(): # PostgreSQL 18's `uuidv7` function. This capability is rarely needed in practice, as IDs can be # generated and controlled within the application layer. conn = op.get_bind() - - if _is_pg(conn): - # PostgreSQL: Create uuidv7 functions - op.execute(sa.text(r""" -/* Main function to generate a uuidv7 value with millisecond precision */ -CREATE FUNCTION uuidv7() RETURNS uuid -AS -$$ - -- Replace the first 48 bits of a uuidv4 with the current - -- number of milliseconds since 1970-01-01 UTC - -- and set the "ver" field to 7 by setting additional bits -SELECT encode( - set_bit( - set_bit( - overlay(uuid_send(gen_random_uuid()) placing - substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from - 3) - from 1 for 6), - 52, 1), - 53, 1), 'hex')::uuid; -$$ LANGUAGE SQL VOLATILE PARALLEL SAFE; -COMMENT ON FUNCTION uuidv7 IS - 'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness'; + if _is_pg(conn): + # PostgreSQL: Create uuidv7 functions. + # PostgreSQL 18 ships a native pg_catalog.uuidv7(), so only create our own + # implementation when the server does not already provide one. Otherwise the + # CREATE FUNCTION below and the unqualified COMMENT statement collide with the + # built-in and the migration fails. + # + # The existence check is done server-side via a DO block rather than + # conn.execute().scalar() because the latter returns None in offline + # migration mode (no real database connection), causing an AttributeError. + op.execute(sa.text(r""" +DO $do$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE p.proname = 'uuidv7' AND n.nspname = 'pg_catalog' + ) THEN + /* Main function to generate a uuidv7 value with millisecond precision */ + CREATE FUNCTION public.uuidv7() RETURNS uuid + AS + $func$ + -- Replace the first 48 bits of a uuidv4 with the current + -- number of milliseconds since 1970-01-01 UTC + -- and set the "ver" field to 7 by setting additional bits + SELECT encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) placing + substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from + 3) + from 1 for 6), + 52, 1), + 53, 1), 'hex')::uuid; + $func$ LANGUAGE SQL VOLATILE PARALLEL SAFE; + + COMMENT ON FUNCTION public.uuidv7 IS + 'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness'; + END IF; +END +$do$; """)) op.execute(sa.text(r""" -CREATE FUNCTION uuidv7_boundary(timestamptz) RETURNS uuid +CREATE FUNCTION public.uuidv7_boundary(timestamptz) RETURNS uuid AS $$ /* uuid fields: version=0b0111, variant=0b10 */ @@ -83,7 +101,7 @@ SELECT encode( 'hex')::uuid; $$ LANGUAGE SQL STABLE STRICT PARALLEL SAFE; -COMMENT ON FUNCTION uuidv7_boundary(timestamptz) IS +COMMENT ON FUNCTION public.uuidv7_boundary(timestamptz) IS 'Generate a non-random uuidv7 with the given timestamp (first 48 bits) and all random bits to 0. As the smallest possible uuidv7 for that timestamp, it may be used as a boundary for partitions.'; """ )) @@ -95,7 +113,10 @@ def downgrade(): conn = op.get_bind() if _is_pg(conn): - op.execute(sa.text("DROP FUNCTION uuidv7")) - op.execute(sa.text("DROP FUNCTION uuidv7_boundary")) + # IF EXISTS keeps the downgrade a no-op on PostgreSQL 18, where the native + # pg_catalog.uuidv7() was kept and no public.uuidv7() was created. Scoping the + # drop to the public schema avoids touching the built-in. + op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7()")) + op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)")) else: pass diff --git a/api/tests/unit_tests/core/mcp/test_mcp_client.py b/api/tests/unit_tests/core/mcp/test_mcp_client.py index c245b4a77ef..62baee83ce3 100644 --- a/api/tests/unit_tests/core/mcp/test_mcp_client.py +++ b/api/tests/unit_tests/core/mcp/test_mcp_client.py @@ -43,6 +43,57 @@ class TestMCPClient: assert client.timeout is None assert client.sse_read_timeout is None + def test_init_with_dynamic_request_headers(self): + """Test client initialization with dynamic request headers.""" + from flask import Flask + + app = Flask("test") + with app.test_request_context(headers={"X-Custom-Auth": "my-secret-token", "X-User-Id": "user123"}): + client = MCPClient( + server_url="http://test.example.com", + headers={ + "Authorization": "Bearer {{request.headers.X-Custom-Auth}}", + "X-Request-User": "{{request.header.X-User-Id}}", + "X-Static-Header": "static-val", + }, + ) + + assert client.headers == { + "Authorization": "Bearer my-secret-token", + "X-Request-User": "user123", + "X-Static-Header": "static-val", + } + + def test_init_with_dynamic_request_headers_missing(self): + """Test client initialization with dynamic request headers that are missing.""" + from flask import Flask + + app = Flask("test") + with app.test_request_context(headers={}): + client = MCPClient( + server_url="http://test.example.com", + headers={ + "Authorization": "Bearer {{request.headers.X-Custom-Auth}}", + }, + ) + + assert client.headers == { + "Authorization": "Bearer ", + } + + def test_init_with_dynamic_request_headers_no_context(self): + """Test client initialization with dynamic request headers but no request context.""" + client = MCPClient( + server_url="http://test.example.com", + headers={ + "Authorization": "Bearer {{request.headers.X-Custom-Auth}}", + }, + ) + + assert client.headers == { + "Authorization": "Bearer {{request.headers.X-Custom-Auth}}", + } + @patch("core.mcp.mcp_client.streamablehttp_client") @patch("core.mcp.mcp_client.ClientSession") def test_initialize_with_mcp_url(self, mock_client_session, mock_streamable_client): diff --git a/api/tests/unit_tests/migrations/test_uuidv7_pg18_migration.py b/api/tests/unit_tests/migrations/test_uuidv7_pg18_migration.py new file mode 100644 index 00000000000..ae5d2a1d0d0 --- /dev/null +++ b/api/tests/unit_tests/migrations/test_uuidv7_pg18_migration.py @@ -0,0 +1,111 @@ +"""Tests for the uuidv7 SQL migration's PostgreSQL 18 compatibility guard. + +The migration file name is not a valid Python identifier (it starts with a date and +contains hyphens), so it is loaded directly from its path. The ``models`` import at the +top of the migration is stubbed because the migration never uses it during +``upgrade()``/``downgrade()`` and pulling in the real package would require a full app +context. +""" + +import importlib.util +import sys +import types +from pathlib import Path +from unittest import mock + +import pytest + +MIGRATION_PATH = ( + Path(__file__).resolve().parents[3] + / "migrations" + / "versions" + / "2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py" +) + + +def _load_migration(): + # The migration does `import models as models` but never references it, so a stub is + # enough and keeps the test free of any database/app configuration. + sys.modules.setdefault("models", types.ModuleType("models")) + spec = importlib.util.spec_from_file_location("uuidv7_pg18_migration_under_test", MIGRATION_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _make_bind(dialect_name): + bind = mock.MagicMock() + bind.dialect.name = dialect_name + return bind + + +def _executed_sql(fake_op): + return [str(call.args[0]) for call in fake_op.execute.call_args_list] + + +@pytest.fixture +def migration(): + return _load_migration() + + +def test_upgrade_creates_both_functions_when_native_uuidv7_absent(migration): + # PostgreSQL 13 to 17: no native pg_catalog.uuidv7(), so both functions are created. + # The DO block contains the CREATE FUNCTION guarded by IF NOT EXISTS, and + # uuidv7_boundary is created unconditionally. + bind = _make_bind("postgresql") + with mock.patch.object(migration, "op") as fake_op: + fake_op.get_bind.return_value = bind + migration.upgrade() + + sql = _executed_sql(fake_op) + assert any("CREATE FUNCTION public.uuidv7()" in stmt for stmt in sql) + assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql) + + +def test_upgrade_skips_uuidv7_but_keeps_boundary_when_native_present(migration): + # PostgreSQL 18: native pg_catalog.uuidv7() exists, so the DO block must guard + # the CREATE FUNCTION with an IF NOT EXISTS check against pg_catalog. + # uuidv7_boundary is still missing and has to be created unconditionally. + bind = _make_bind("postgresql") + with mock.patch.object(migration, "op") as fake_op: + fake_op.get_bind.return_value = bind + migration.upgrade() + + sql = _executed_sql(fake_op) + # The DO block must contain the pg_catalog existence check. + do_block = next((stmt for stmt in sql if "DO $do$" in stmt), None) + assert do_block is not None + assert "pg_catalog" in do_block + assert "uuidv7" in do_block + assert "IF NOT EXISTS" in do_block + # uuidv7_boundary is always created (not guarded by the DO block). + assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql) + + +def test_upgrade_is_noop_on_non_postgres(migration): + bind = _make_bind("sqlite") + with mock.patch.object(migration, "op") as fake_op: + fake_op.get_bind.return_value = bind + migration.upgrade() + + fake_op.execute.assert_not_called() + + +def test_downgrade_uses_if_exists_and_public_schema(migration): + bind = _make_bind("postgresql") + with mock.patch.object(migration, "op") as fake_op: + fake_op.get_bind.return_value = bind + migration.downgrade() + + sql = _executed_sql(fake_op) + assert "DROP FUNCTION IF EXISTS public.uuidv7()" in sql + assert "DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)" in sql + + +def test_downgrade_is_noop_on_non_postgres(migration): + bind = _make_bind("sqlite") + with mock.patch.object(migration, "op") as fake_op: + fake_op.get_bind.return_value = bind + migration.downgrade() + + fake_op.execute.assert_not_called() diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx index d77f3f9ef68..e49efc2a10d 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx @@ -13,6 +13,8 @@ import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocale } from '@/context/i18n' +import { getDocLanguage } from '@/i18n-config/language' import PermissionPicker from './permission-picker' export type PermissionSetModalMode = 'create' | 'edit' | 'view' @@ -42,6 +44,8 @@ const PermissionSetModalBody = ({ onSubmit, }: PermissionSetModalBodyProps) => { const { t } = useTranslation() + const locale = useLocale() + const docLanguage = getDocLanguage(locale) const [name, setName] = useState(initialValues?.name ?? '') const [description, setDescription] = useState(initialValues?.description ?? '') const [permissionKeys, setPermissionKeys] = useState(initialValues?.permissionKeys ?? []) @@ -122,7 +126,7 @@ const PermissionSetModalBody = ({
{ const { t } = useTranslation() + const locale = useLocale() + const docLanguage = getDocLanguage(locale) const [name, setName] = useState(role?.name ?? '') const [desc, setDesc] = useState(role?.description ?? '') const [permissionKeys, setPermissionKeys] = useState(role?.permission_keys ?? []) @@ -116,7 +120,7 @@ const RoleModal = ({