Compare commits

..
Author SHA1 Message Date
Stephen Zhou e9b4ce9029 Tighten component skill guidance 2026-06-26 19:02:26 +08:00
Stephen Zhou 7daa56f48b Refine how-to-write-component skill guidance 2026-06-26 18:27:41 +08:00
Stephen Zhou c48ee1a6b7 Expand component writing skill workflow 2026-06-26 17:53:29 +08:00
Stephen Zhou 80d2412294 Refine how-to-write-component explicit workflow 2026-06-26 17:28:00 +08:00
Stephen Zhou 63d1c8288f Refine how-to-write-component scope guidance 2026-06-26 17:09:12 +08:00
Stephen Zhou be58e10a35 Clarify component folder ownership split 2026-06-26 16:49:58 +08:00
Stephen Zhou 82e0191e05 Refactor obsolete frontend code 2026-06-26 16:39:52 +08:00
Stephen Zhou d24e9d41e4 Refine how-to-write-component skill guidance 2026-06-26 16:28:44 +08:00
Stephen Zhou 137eb90399 Refactor deployment access and overview typing 2026-06-26 16:19:13 +08:00
Stephen Zhou 995e8a6ec4 Clarify component skill scope for explicit invocations 2026-06-26 16:11:09 +08:00
Stephen Zhou 92b5c195a5 refactor: align deployment detail action ownership 2026-06-26 15:59:44 +08:00
Stephen Zhou 37de12bf47 Clarify component writing skill priority 2026-06-26 15:51:28 +08:00
Stephen Zhou 68e323b6dc refactor: align deployment detail form ownership 2026-06-26 15:41:55 +08:00
Stephen Zhou 387dec9169 refactor: align deployment detail component ownership 2026-06-26 15:29:58 +08:00
Escape0707andGitHub eb4ec93cea chore: migrate account role test to testcontainers (#38010) 2026-06-26 05:51:14 +00:00
Xiyuan ChenandGitHub fa6f4b0ea5 refactor(api): enforce enums over string literals on openapi surface (#38009) 2026-06-26 05:48:21 +00:00
267b34caaf fix(web): keep HITL input-field save button visible in the edit dialog (#38007)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-26 05:40:51 +00:00
yyhandGitHub 6ab1dd06ac fix: isolate node selector keyboard events (#37998) 2026-06-26 05:38:10 +00:00
Stephen ZhouandGitHub 113d6d7e00 refactor: align deployment detail state ownership (#38008) 2026-06-26 05:37:21 +00:00
Mohammed AqibGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a66b1de477 refactor: Use pytest caplog instead of logger patching in test_spec.py (#37997)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 04:48:22 +00:00
Stephen ZhouandGitHub 16b698b54d refactor: simplify deployment access ownership (#37994) 2026-06-26 04:10:56 +00:00
yyhandGitHub 4cd8b8c733 fix(web): make marketplace URL builder SSR-safe (#37944) 2026-06-26 03:35:22 +00:00
677ab01806 refactor(tests): use caplog in workflow collaboration service tests (#37468) (#37991)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-26 03:14:06 +00:00
Stephen ZhouandGitHub f4e832f35c fix: decouple deployment access control dialog (#37936) 2026-06-26 02:16:29 +00:00
kurokoboGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1c5e1280cb feat(webapp): display app description on chat and text-generation app screens (#37345)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 02:03:16 +00:00
-LAN-GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0035d90e36 fix(api): require edit access for trace config changes (#37973)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 00:51:39 +00:00
Xiyuan ChenandGitHub 2047e0dc12 fix(cli): use a(n) article in difyctl prerelease warning (#37976) 2026-06-26 00:49:42 +00:00
chaririandGitHub 2e9c0a3c7a refactor(api): migrate response contract tooling to BaseModel (#37950) 2026-06-26 00:11:04 +00:00
ojasarora.ethandGitHub a246dc8b17 ci: emit pyrefly diagnostics as GitHub workflow commands (#37974) 2026-06-25 18:27:46 +00:00
ojasarora.ethandGitHub bb921bcc45 test: replace logger mock with caplog in workflow collaboration test (#37971) 2026-06-25 16:45:59 +00:00
QuantumGhostandGitHub 4f4ac27de2 chore(api): surface pyrefly output on type-check failures (#37934) 2026-06-25 13:47:10 +00:00
227 changed files with 4482 additions and 3809 deletions
+27 -4
View File
@@ -1,17 +1,20 @@
---
name: how-to-write-component
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
description: Use when explicitly invoked or when writing, refactoring, fixing, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
---
# How To Write A Component
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the requested path or feature branch.
When this skill is explicitly invoked, first read this SKILL.md from start to EOF before any task action. Do not rely on excerpts, pasted copies, summaries, or memory. Give these instructions maximum respect.
## First Decisions
| Question | Default | Promote or extract only when |
| --- | --- | --- |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
@@ -19,11 +22,24 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Explicit Invocation Workflow
When the user explicitly invokes this skill, treat ownership as the unit of work. Use these principles rather than a fixed checklist.
- Start from owners. Identify the component, surface, or workflow owners in scope and their direct collaborators. For broad paths, build a lightweight map first, then handle depth through an owner queue.
- Make the intended boundary explicit. Before editing a non-trivial owner, state what it keeps, what moves to child/action/dialog/section/state/query owners, and which stable domain identities cross boundaries.
- For refactors, stop before editing the first non-trivial owner. Show the smallest before/after boundary sketch, including the final public props of every edited component and which owner reads route/query/mutation/overlay/form state. Wait for confirmation before writing code. For later owners, repeat this only when the boundary shape changes.
- Treat public props as part of the owner boundary. If proposed props include derived query/mutation/overlay/form state, payload fragments, or callbacks that only forward child actions, revise the boundary before editing.
- Work in focused owner slices. Finish one owner boundary, inspect its diff, then choose the next owner. Let repeated local patterns expand scope naturally.
- Put behavior where it is used. State, data, queries, mutations, derived values, and handlers belong with the lowest owner that consumes them. Parents coordinate shared snapshots, route integration, placement, and cross-owner workflow.
- Verify the shape in the diff. Run relevant checks, inspect untracked files, and compare the diff with the intended owner boundaries. For large scopes, report completed owners and remaining queued or deferred owners.
- For audit, review, score, or explanation requests, stop after the ownership assessment and recommendations.
## Core Defaults
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Group feature code by workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
@@ -32,6 +48,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
@@ -46,6 +64,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
@@ -60,8 +79,10 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
@@ -83,11 +104,13 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
@@ -96,7 +119,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
## Boundaries And Overlays
- Use the first level below a page or tab to organize independent page sections when it adds structure. This layer is layout/semantic first, not automatically the data owner.
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
+2
View File
@@ -53,6 +53,8 @@ jobs:
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
env:
PYREFLY_OUTPUT_FORMAT: github
run: make type-check-core
- name: Dotenv check
+1 -2
View File
@@ -22,7 +22,7 @@ from .plugin import (
setup_system_trigger_oauth_client,
transform_datasource_credentials,
)
from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_rbac
from .rbac import migrate_member_roles_to_rbac
from .retention import (
archive_workflow_runs,
archive_workflow_runs_plan,
@@ -76,7 +76,6 @@ __all__ = [
"legacy_model_types",
"migrate_annotation_vector_database",
"migrate_data_for_plugin",
"migrate_dataset_permissions_to_rbac",
"migrate_knowledge_vector_database",
"migrate_member_roles_to_rbac",
"migrate_oss",
-2
View File
@@ -7,7 +7,6 @@ from typing import cast
import click
from commands.rbac import migrate_dataset_permissions_to_rbac
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from services.legacy_model_type_migration import (
@@ -178,4 +177,3 @@ def legacy_model_types(
data_migrate.add_command(legacy_model_types)
data_migrate.add_command(migrate_dataset_permissions_to_rbac)
+65 -437
View File
@@ -1,55 +1,11 @@
from __future__ import annotations
import json
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 core.rbac import RBACResourceWhitelistScope
from models import Dataset, DatasetPermission, DatasetPermissionEnum, TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService, ReplaceMemberBindings, ReplaceUserAccessPolicies
_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
_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
from models import TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
@@ -59,86 +15,26 @@ 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.
"""
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
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:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
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(
roles = RBACService.Roles.list(
tenant_id=tenant_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[role_id],
)
return member_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}")
@click.command(
@@ -146,16 +42,7 @@ def _replace_member_role(
)
@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.")
@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:
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
This is an offline migration command for workspaces that already have
@@ -163,322 +50,63 @@ def migrate_member_roles_to_rbac(
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")
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] = {}
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)
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}")
joins = list(session.scalars(stmt).all())
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:
if not joins:
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
return
if dry_run:
click.echo(
click.style(
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
"No RBAC bindings were written.",
fg="yellow",
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,
)
)
else:
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(
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",
)
f"tenant={workspace_id} member={member_account_id} "
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
)
if dry_run:
continue
def _dataset_permission_enum(permission: DatasetPermissionEnum | str | None) -> DatasetPermissionEnum:
if permission is None:
return DatasetPermissionEnum.ONLY_ME
try:
return DatasetPermissionEnum(permission)
except ValueError as exc:
raise ValueError(f"Unsupported legacy dataset permission: {permission}") from exc
def _rbac_dataset_scope_for_legacy_permission(permission: DatasetPermissionEnum) -> RBACResourceWhitelistScope:
if permission is DatasetPermissionEnum.ALL_TEAM:
return RBACResourceWhitelistScope.ALL
if permission in {DatasetPermissionEnum.ONLY_ME, DatasetPermissionEnum.PARTIAL_TEAM}:
return RBACResourceWhitelistScope.SPECIFIC
raise ValueError(f"Unsupported legacy dataset permission: {permission}")
def _emit_dataset_permission_migration_event(payload: dict[str, object]) -> None:
click.echo(json.dumps(payload, sort_keys=True))
@click.command(
"rbac-migrate-dataset-permissions",
help=(
"Migrate legacy dataset permission scopes and partial members into RBAC dataset access bindings. "
"Side effect: replacing each dataset whitelist clears existing per-user policy bindings; "
"the command then recreates legacy partial-member default bindings."
),
)
@click.option("--tenant-id", help="Only migrate datasets in a single workspace.")
@click.option("--dataset-id", help="Only migrate a single dataset.")
@click.option("--batch-size", default=500, show_default=True, type=click.IntRange(min=1))
@click.option(
"--dry-run/--apply",
default=True,
show_default=True,
help="Preview the migration without writing RBAC bindings. Use --apply to write changes.",
)
def migrate_dataset_permissions_to_rbac(
tenant_id: str | None,
dataset_id: str | None,
batch_size: int,
dry_run: bool,
) -> None:
"""Backfill RBAC dataset access config from legacy `Dataset.permission`.
Legacy mapping:
- all_team_members -> RBAC dataset whitelist scope "all"
- partial_members -> RBAC dataset whitelist scope "specific" plus each partial member gets the
virtual default policy
- only_me -> RBAC dataset whitelist scope "specific" with no member policy bindings
The command replaces each dataset's RBAC whitelist scope first. RBAC clears
existing per-user policy bindings during that replace, then this command
recreates the legacy partial-member default bindings. Re-running it is
therefore idempotent for a dataset's current legacy configuration.
"""
click.echo(click.style("Starting RBAC dataset permission migration.", fg="green"))
scanned_count = 0
scope_migrated_count = 0
user_policy_migrated_count = 0
partial_dataset_count = 0
last_dataset_id: str | None = None
while True:
with session_factory.create_session() as session:
stmt = (
select(Dataset.id, Dataset.tenant_id, Dataset.permission, Dataset.created_by)
.order_by(Dataset.id.asc())
.limit(batch_size)
)
if tenant_id:
stmt = stmt.where(Dataset.tenant_id == tenant_id)
if dataset_id:
stmt = stmt.where(Dataset.id == dataset_id)
if last_dataset_id:
stmt = stmt.where(Dataset.id > last_dataset_id)
dataset_rows = list(session.execute(stmt).all())
if not dataset_rows:
break
dataset_ids = [str(row.id) for row in dataset_rows]
partial_members_by_dataset_id: dict[str, list[str]] = {item: [] for item in dataset_ids}
permission_rows = session.execute(
select(DatasetPermission.dataset_id, DatasetPermission.account_id).where(
DatasetPermission.dataset_id.in_(dataset_ids)
)
).all()
for row in permission_rows:
partial_members_by_dataset_id[str(row.dataset_id)].append(str(row.account_id))
for dataset in dataset_rows:
workspace_id = str(dataset.tenant_id)
current_dataset_id = str(dataset.id)
operator_account_id = str(dataset.created_by)
permission_value = _dataset_permission_enum(dataset.permission)
scope = _rbac_dataset_scope_for_legacy_permission(permission_value)
partial_member_ids = sorted(set(partial_members_by_dataset_id[current_dataset_id]))
should_bind_partial_members = permission_value is DatasetPermissionEnum.PARTIAL_TEAM
click.echo(
f"tenant={workspace_id} dataset={current_dataset_id} "
f"operator={operator_account_id} "
f"legacy_permission={permission_value} -> rbac_scope={scope} "
f"partial_members={len(partial_member_ids) if should_bind_partial_members else 0}"
)
scanned_count += 1
replace_whitelist_payload = ReplaceMemberBindings(scope=scope)
if dry_run:
_emit_dataset_permission_migration_event(
{
"event": "dataset_permission_migration_proposed_change",
"action": "replace_whitelist",
"dry_run": True,
"tenant_id": workspace_id,
"dataset_id": current_dataset_id,
"operator_account_id": operator_account_id,
"before": {
"legacy_dataset_permission": permission_value.value,
"legacy_partial_member_ids": partial_member_ids if should_bind_partial_members else [],
},
"after": {
"rbac_whitelist_scope": scope.value,
},
"call": {
"method": "RBACService.DatasetAccess.replace_whitelist",
"kwargs": {
"tenant_id": workspace_id,
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"payload": replace_whitelist_payload.model_dump(mode="json"),
},
},
}
)
if not dry_run:
RBACService.DatasetAccess.replace_whitelist(
tenant_id=workspace_id,
account_id=operator_account_id,
dataset_id=current_dataset_id,
payload=replace_whitelist_payload,
)
scope_migrated_count += 1
if should_bind_partial_members:
partial_dataset_count += 1
for member_account_id in partial_member_ids:
replace_user_access_policies_payload = ReplaceUserAccessPolicies(
access_policy_ids=[_RBAC_DEFAULT_ACCESS_POLICY_ID],
)
if dry_run:
_emit_dataset_permission_migration_event(
{
"event": "dataset_permission_migration_proposed_change",
"action": "replace_user_access_policies",
"dry_run": True,
"tenant_id": workspace_id,
"dataset_id": current_dataset_id,
"operator_account_id": operator_account_id,
"target_account_id": member_account_id,
"before": {
"legacy_dataset_permission": permission_value.value,
"legacy_partial_member_id": member_account_id,
},
"after": {
"rbac_user_access_policy_ids": [_RBAC_DEFAULT_ACCESS_POLICY_ID],
},
"call": {
"method": "RBACService.DatasetAccess.replace_user_access_policies",
"kwargs": {
"tenant_id": workspace_id,
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"target_account_id": member_account_id,
"payload": replace_user_access_policies_payload.model_dump(mode="json"),
},
},
}
)
continue
RBACService.DatasetAccess.replace_user_access_policies(
tenant_id=workspace_id,
account_id=operator_account_id,
dataset_id=current_dataset_id,
target_account_id=member_account_id,
payload=replace_user_access_policies_payload,
)
user_policy_migrated_count += 1
last_dataset_id = dataset_ids[-1]
if dataset_id:
break
if scanned_count == 0:
click.echo(click.style("No datasets found for migration.", fg="yellow"))
return
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(
f"Dry run completed. Scanned {scanned_count} datasets; "
f"{partial_dataset_count} partial-member datasets would be migrated.",
fg="yellow",
)
)
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
else:
click.echo(
click.style(
"RBAC dataset permission migration completed. "
f"Scanned {scanned_count} datasets, migrated {scope_migrated_count} scopes, "
f"wrote {user_policy_migrated_count} user default-policy bindings.",
fg="green",
)
)
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
-6
View File
@@ -34,12 +34,6 @@ class EnterpriseFeatureConfig(BaseSettings):
default=False,
)
ENTERPRISE_RBAC_REQUEST_TIMEOUT: int = Field(
ge=1,
description="Maximum timeout in seconds for inner RBAC requests.",
default=30,
)
class EnterpriseTelemetryConfig(BaseSettings):
"""
+5 -1
View File
@@ -167,12 +167,16 @@ register_schema_models(
ChatMessagesQuery,
MessageFeedbackPayload,
FeedbackExportQuery,
)
register_response_schema_models(
console_ns,
AnnotationCountResponse,
SuggestedQuestionsResponse,
MessageDetailResponse,
MessageInfiniteScrollPaginationResponse,
SimpleResultResponse,
TextFileResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
@@ -85,6 +85,7 @@ def _published_app_filter():
class InstalledAppInfoResponse(ResponseModel):
id: str
name: str | None = None
description: str | None = None
mode: str | None = None
icon_type: str | None = None
icon: str | None = None
@@ -123,6 +124,7 @@ class InstalledAppResponse(ResponseModel):
return {
"id": _safe_primitive(getattr(value, "id", "")) or "",
"name": _safe_primitive(getattr(value, "name", None)),
"description": _safe_primitive(getattr(value, "description", None)),
"mode": _safe_primitive(getattr(value, "mode", None)),
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
"icon": _safe_primitive(getattr(value, "icon", None)),
+8 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any
from flask import request
@@ -13,7 +14,6 @@ from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from libs.login import current_account_with_tenant, login_required
from models import Account
from services.enterprise import rbac_service as svc
@@ -511,8 +511,14 @@ class RBACAccessPolicyBindingUnlockApi(Resource):
# ---------------------------------------------------------------------------
class _AccessScope(StrEnum):
ALL = "all"
SPECIFIC = "specific"
ONLY_ME = "only_me"
class _ResourceAccessScopeRequest(BaseModel):
scope: RBACResourceWhitelistScope
scope: _AccessScope
class _ReplaceBindingsRequest(BaseModel):
+2 -1
View File
@@ -20,7 +20,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
# Register response/query models BEFORE importing controller modules so that
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
from controllers.common.fields import EventStreamResponse
from controllers.common.fields import EventStreamResponse, SimpleResultResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.openapi._models import (
AccountPayload,
@@ -95,6 +95,7 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
SimpleResultResponse,
UsageInfo,
MessageMetadata,
AppListRow,
+3 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from collections.abc import Callable, Iterator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from typing import Any
@@ -61,7 +61,7 @@ logger = logging.getLogger(__name__)
@contextmanager
def _translate_service_errors() -> Iterator[None]:
def _translate_service_errors() -> Generator[None, None, None]:
try:
yield
except WorkflowNotFoundError as ex:
@@ -166,6 +166,7 @@ class AppRunApi(Resource):
surface="apps",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(stream_obj)
+6 -3
View File
@@ -25,12 +25,13 @@ from controllers.openapi._models import (
AppListRow,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from controllers.service_api.app.error import AppUnavailableError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
@@ -166,7 +167,9 @@ class AppListApi(Resource):
# an empty set or list means the caller has no accessible apps.
# End-users bypass RBAC here — their access is controlled by scope upstream.
apply_rbac_filter = (
dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None
dify_config.RBAC_ENABLED
and auth_data.caller_kind != CallerKind.END_USER
and auth_data.account_id is not None
)
access_filter = AppAccessFilter.unrestricted()
if apply_rbac_filter:
@@ -203,7 +206,7 @@ class AppListApi(Resource):
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
status="normal",
status=AppStatus.NORMAL,
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
openapi_visible=True,
@@ -25,6 +25,7 @@ from controllers.openapi.auth.data import AuthData, Edition
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
from services.account_service import TenantService
from services.app_service import AppService
from services.enterprise.app_permitted_service import list_permitted_apps
@@ -62,7 +63,7 @@ class PermittedExternalAppsListApi(Resource):
items: list[AppListRow] = []
for app_id in page_result.app_ids:
app = apps_by_id.get(app_id)
if not app or app.status != "normal":
if not app or app.status != AppStatus.NORMAL:
continue
tenant = tenants_by_id.get(str(app.tenant_id))
items.append(
+7 -3
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import uuid
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import InternalServerError
@@ -21,6 +20,11 @@ class Edition(StrEnum):
SAAS = "saas"
class CallerKind(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
def current_edition() -> Edition:
if dify_config.EDITION == "CLOUD":
return Edition.SAAS
@@ -78,9 +82,9 @@ class AuthData(BaseModel):
tenant_role: TenantAccountRole | None = None
caller: Account | EndUser | None = None
caller_kind: Literal["account", "end_user"] | None = None
caller_kind: CallerKind | None = None
def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]:
def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]:
if self.app is None or self.caller is None or self.caller_kind is None:
raise InternalServerError("pipeline_invariant_violated: app context missing")
return self.app, self.caller, self.caller_kind
+7 -7
View File
@@ -5,10 +5,10 @@ import uuid
from flask import request
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
from controllers.openapi.auth.data import AuthData
from controllers.openapi.auth.data import AuthData, CallerKind
from extensions.ext_database import db
from models.account import TenantStatus
from models.enums import EndUserType
from models.account import AccountStatus, TenantStatus
from models.enums import AppStatus, EndUserType
from services.account_service import AccountService, TenantService
from services.app_service import AppService
from services.end_user_service import EndUserService
@@ -24,7 +24,7 @@ def load_app(data: AuthData) -> None:
except ValueError:
raise NotFound("app not found")
app = AppService.get_app_by_id(db.session, app_id)
if not app or app.status != "normal":
if not app or app.status != AppStatus.NORMAL:
raise NotFound("app not found")
data.app = app
@@ -65,7 +65,7 @@ def load_account(data: AuthData) -> None:
if data.tenant:
account.current_tenant = data.tenant
data.caller = account
data.caller_kind = "account"
data.caller_kind = CallerKind.ACCOUNT
def load_workspace_role(data: AuthData) -> None:
@@ -73,7 +73,7 @@ def load_workspace_role(data: AuthData) -> None:
return
if data.tenant is None or data.account_id is None:
return
if data.caller is not None and getattr(data.caller, "status", None) != "active":
if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE:
return
role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id))
if role is None:
@@ -91,7 +91,7 @@ def resolve_external_user(data: AuthData) -> None:
user_id=data.external_identity.email,
)
data.caller = end_user
data.caller_kind = "end_user"
data.caller_kind = CallerKind.END_USER
def load_app_access_mode(data: AuthData) -> None:
+2 -2
View File
@@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.openapi.auth.data import AuthData
from controllers.openapi.auth.data import AuthData, CallerKind
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from services.account_service import AccountService, TenantService
@@ -58,7 +58,7 @@ def check_rbac_permission(data: AuthData) -> None:
if not dify_config.RBAC_ENABLED:
return
# Only account callers are subject to RBAC; end_user access is scope-controlled.
if data.caller_kind != "account":
if data.caller_kind != CallerKind.ACCOUNT:
return
if data.account_id is None or data.tenant is None:
raise Forbidden("rbac context missing")
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from core.workflow.human_input_policy import (
HumanInputSurface,
is_recipient_type_allowed_for_surface,
@@ -98,7 +98,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
submission_user_id: str | None = None
submission_end_user_id: str | None = None
if caller_kind == "account":
if caller_kind == CallerKind.ACCOUNT:
submission_user_id = caller.id
else:
submission_end_user_id = caller.id
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.openapi import openapi_ns
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -70,7 +70,7 @@ class OpenApiWorkflowEventsApi(Resource):
if workflow_run.app_id != app_model.id:
raise NotFound("Workflow run not found")
if caller_kind == "account":
if caller_kind == CallerKind.ACCOUNT:
if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id:
raise NotFound("Workflow run not found")
else:
+2 -2
View File
@@ -1,3 +1,3 @@
from core.rbac.entities import RBACPermission, RBACResourceScope, RBACResourceWhitelistScope
from core.rbac.entities import RBACPermission, RBACResourceScope
__all__ = ["RBACPermission", "RBACResourceScope", "RBACResourceWhitelistScope"]
__all__ = ["RBACPermission", "RBACResourceScope"]
-8
View File
@@ -13,14 +13,6 @@ class RBACResourceScope(StrEnum):
WORKSPACE = "workspace"
class RBACResourceWhitelistScope(StrEnum):
"""Whitelist scopes accepted by RBAC app and dataset access config APIs."""
ALL = "all"
SPECIFIC = "specific"
ONLY_ME = "only_me"
class RBACPermission(StrEnum):
"""Permission points (RBAC scenes) checked by ``rbac_permission_required``.
+96 -20
View File
@@ -2,8 +2,8 @@
This checker intentionally stays conservative. It only reports a hard schema
mismatch when both sides are statically known for the same 2xx status code:
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``
or ``Model.model_validate(...).model_dump()`` return.
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``,
``Model(...).model_dump()``, or ``Model.model_validate(...).model_dump()`` return.
Raw dictionaries, raw lists, ``None`` responses, streaming helpers, missing
response schemas, and returns with non-literal status codes are classified as
@@ -28,6 +28,7 @@ from typing import Any, Literal
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
NO_BODY_STATUSES = {HTTPStatus.NO_CONTENT.value, HTTPStatus.RESET_CONTENT.value, HTTPStatus.NOT_MODIFIED.value}
DEFAULT_CONTROLLER_DIRS = ("controllers/console", "controllers/service_api", "controllers/web")
IGNORE_COMMENT_MARKERS = ("response-contract:ignore",)
type Classification = Literal["valid", "mismatch", "unknown", "refactorable"]
type ActualKind = Literal[
@@ -41,6 +42,7 @@ type ActualKind = Literal[
"unknown",
]
type MethodNode = ast.FunctionDef | ast.AsyncFunctionDef
type ModelValueSource = Literal["constructor", "model_validate"]
HTTP_STATUS_NAMES = {status.name: status.value for status in HTTPStatus}
HTTP_STATUS_NAMES.update({f"HTTP_{status.value}_{status.name}": status.value for status in HTTPStatus})
@@ -109,18 +111,22 @@ class VariableAssignmentSummary:
"""Track whether a local name is safe to treat as one specific response model."""
known_models: set[str] = field(default_factory=set)
known_sources: set[ModelValueSource] = field(default_factory=set)
has_unknown_assignment: bool = False
def add_known(self, model: str) -> None:
def add_known(self, model: str, source: ModelValueSource) -> None:
self.known_models.add(model)
self.known_sources.add(source)
def add_unknown(self) -> None:
self.has_unknown_assignment = True
def single_known_model(self) -> str | None:
def single_known_model(self) -> tuple[str, ModelValueSource] | None:
if self.has_unknown_assignment or len(self.known_models) != 1:
return None
return next(iter(self.known_models))
model = next(iter(self.known_models))
source: ModelValueSource = "constructor" if self.known_sources == {"constructor"} else "model_validate"
return model, source
def dotted_name(node: ast.AST) -> str | None:
@@ -249,6 +255,12 @@ def model_name_from_model_validate_call(node: ast.AST) -> str | None:
return None
def model_value_from_model_validate_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
if model_name := model_name_from_model_validate_call(node):
return model_name, "model_validate"
return None
def model_name_from_constructor_call(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -257,6 +269,12 @@ def model_name_from_constructor_call(node: ast.AST) -> str | None:
return None
def model_value_from_constructor_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
if model_name := model_name_from_constructor_call(node):
return model_name, "constructor"
return None
def model_name_from_model_dump(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute) or node.func.attr != "model_dump":
return None
@@ -272,6 +290,10 @@ def model_name_from_model_value(node: ast.AST) -> str | None:
return model_name_from_model_validate_call(node) or model_name_from_constructor_call(node)
def model_value_from_model_value(node: ast.AST) -> tuple[str, ModelValueSource] | None:
return model_value_from_model_validate_call(node) or model_value_from_constructor_call(node)
def model_name_from_dump_response(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -287,7 +309,7 @@ def model_name_from_dump_response(node: ast.AST) -> str | None:
def actual_kind_from_expr(
expr: ast.AST | None, variable_models: dict[str, str] | None = None
expr: ast.AST | None, variable_models: dict[str, tuple[str, ModelValueSource]] | None = None
) -> tuple[ActualKind, str | None]:
if expr is None:
return "none", None
@@ -299,10 +321,14 @@ def actual_kind_from_expr(
if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute) and expr.func.attr == "model_dump":
dumped_value = expr.func.value
if isinstance(dumped_value, ast.Name) and variable_models:
# A variable dump can match today, but it bypasses dump_response and
# is easier to drift; keep it visible as refactorable.
model_name = variable_models.get(dumped_value.id)
if model_name:
model_assignment = variable_models.get(dumped_value.id)
if model_assignment:
model_name, source = model_assignment
if source == "constructor":
return "model", model_name
# A variable dump from model_validate can match today, but it
# bypasses dump_response and is easier to drift; keep it visible
# as refactorable.
return "model_dump_variable", model_name
model_dump_model = model_name_from_model_dump(expr)
@@ -325,7 +351,9 @@ def actual_kind_from_expr(
return "unknown", None
def actual_response_from_return(return_node: ast.Return, variable_models: dict[str, str]) -> ActualResponse:
def actual_response_from_return(
return_node: ast.Return, variable_models: dict[str, tuple[str, ModelValueSource]]
) -> ActualResponse:
status: int | None = 200
body_expr = return_node.value
@@ -363,18 +391,21 @@ def target_names(target: ast.AST) -> Iterable[str]:
def record_assignment(
assignments: defaultdict[str, VariableAssignmentSummary], targets: Iterable[str], model_name: str | None
assignments: defaultdict[str, VariableAssignmentSummary],
targets: Iterable[str],
model_assignment: tuple[str, ModelValueSource] | None,
) -> None:
for target in targets:
if model_name is None:
if model_assignment is None:
# Once a name receives an unknown value, later model_dump() calls on it
# are no longer a reliable signal for the returned schema.
assignments[target].add_unknown()
else:
assignments[target].add_known(model_name)
model_name, source = model_assignment
assignments[target].add_known(model_name, source)
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple[str, ModelValueSource]]:
"""Infer local variables that are unambiguously assigned one response model."""
assignments: defaultdict[str, VariableAssignmentSummary] = defaultdict(VariableAssignmentSummary)
@@ -385,10 +416,10 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
record_assignment(
assignments,
(name for target in targets for name in target_names(target)),
model_name_from_model_value(value),
model_value_from_model_value(value),
)
case ast.AnnAssign(target=target, value=value) if value is not None:
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
case ast.AugAssign(target=target) | ast.For(target=target) | ast.AsyncFor(target=target):
# Mutation and loop targets overwrite prior values with runtime-dependent data.
record_assignment(assignments, target_names(target), None)
@@ -399,9 +430,13 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
case ast.ExceptHandler(name=name) if name:
assignments[name].add_unknown()
case ast.NamedExpr(target=target, value=value):
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
return {name: model for name, summary in assignments.items() if (model := summary.single_known_model()) is not None}
return {
name: assignment
for name, summary in assignments.items()
if (assignment := summary.single_known_model()) is not None
}
def actual_responses_for_method(method: MethodNode) -> list[ActualResponse]:
@@ -545,13 +580,52 @@ def iter_controller_files(paths: Iterable[Path]) -> Iterable[Path]:
yield from sorted(child for child in path.rglob("*.py") if child.is_file())
def node_start_lineno(node: ast.ClassDef | MethodNode) -> int:
decorator_lines = [decorator.lineno for decorator in node.decorator_list]
if decorator_lines:
return min(decorator_lines)
return node.lineno
def line_has_ignore_marker(line: str) -> bool:
_, marker, comment = line.partition("#")
if not marker:
return False
normalized = comment.lower()
return any(ignore_marker in normalized for ignore_marker in IGNORE_COMMENT_MARKERS)
def node_has_ignore_comment(lines: Sequence[str], node: ast.ClassDef | MethodNode) -> bool:
start = node_start_lineno(node)
end = node.end_lineno or node.lineno
if any(line_has_ignore_marker(line) for line in lines[start - 1 : end]):
return True
line_index = start - 2
while line_index >= 0:
stripped = lines[line_index].strip()
if not stripped:
line_index -= 1
continue
if not stripped.startswith("#"):
break
if line_has_ignore_marker(lines[line_index]):
return True
line_index -= 1
return False
def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
module = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
source = file_path.read_text(encoding="utf-8")
lines = source.splitlines()
module = ast.parse(source, filename=str(file_path))
checks: list[ContractCheck] = []
for node in module.body:
if not isinstance(node, ast.ClassDef):
continue
if node_has_ignore_comment(lines, node):
continue
class_routes = routes_from_decorators(node.decorator_list)
class_documented = response_docs_from_decorators(node.decorator_list)
@@ -559,6 +633,8 @@ def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
for item in node.body:
if not isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) or item.name not in HTTP_METHODS:
continue
if node_has_ignore_comment(lines, item):
continue
routes = routes_from_decorators(item.decorator_list) or class_routes
if not routes:
-2
View File
@@ -27,7 +27,6 @@ def init_app(app: DifyApp):
install_plugins,
install_rag_pipeline_plugins,
migrate_data_for_plugin,
migrate_dataset_permissions_to_rbac,
migrate_member_roles_to_rbac,
migrate_oss,
migration_data_wizard,
@@ -57,7 +56,6 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
migrate_dataset_permissions_to_rbac,
migrate_member_roles_to_rbac,
backfill_plugin_auto_upgrade,
extract_plugins,
+2 -1
View File
@@ -7,7 +7,8 @@ class ResponseModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
extra="ignore",
populate_by_name=True,
validate_by_name=True,
validate_by_alias=True,
serialize_by_alias=True,
protected_namespaces=(),
)
+21 -3
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
import argparse
import sys
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARNING ")
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARN ", "WARNING ")
_LOCATION_PREFIX = "-->"
@@ -13,7 +14,7 @@ def extract_diagnostics(raw_output: str) -> str:
The full pyrefly output includes code excerpts and carets, which create noisy
diffs. This helper keeps only:
- diagnostic headline lines (``ERROR ...`` / ``WARNING ...``)
- diagnostic headline lines (``ERROR ...`` / ``WARN ...`` / ``WARNING ...``)
- the following location line (``--> path:line:column``), when present
"""
@@ -36,11 +37,28 @@ def extract_diagnostics(raw_output: str) -> str:
return "\n".join(diagnostics) + "\n"
def render_diagnostics(raw_output: str, exit_code: int) -> str:
"""Render concise diagnostics and fall back to raw output on unmatched failures."""
diagnostics = extract_diagnostics(raw_output)
if diagnostics:
return diagnostics
if exit_code != 0:
return raw_output
return ""
def main() -> int:
"""Read pyrefly output from stdin and print normalized diagnostics."""
parser = argparse.ArgumentParser()
parser.add_argument("--status", type=int, default=0)
args = parser.parse_args()
raw_output = sys.stdin.read()
sys.stdout.write(extract_diagnostics(raw_output))
sys.stdout.write(render_diagnostics(raw_output, exit_code=args.status))
return 0
+4 -4
View File
@@ -13432,7 +13432,6 @@ Soft lifecycle state for Agent records.
| created_at | integer | | No |
| files | [ string ] | | Yes |
| id | string | | Yes |
| message_chain_id | string | | No |
| message_id | string | | Yes |
| observation | string | | No |
| position | integer | | Yes |
@@ -14543,8 +14542,8 @@ Enum class for configurate method of provider model.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| annotation_create_account | [SimpleAccount](#simpleaccount) | | No |
| annotation_id | string | | Yes |
| created_at | integer | | No |
| id | string | | Yes |
#### ConversationDetail
@@ -16713,6 +16712,7 @@ Input field definition for snippet parameters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
@@ -17082,6 +17082,7 @@ Enum class for large language model mode.
| agent_thoughts | [ [AgentThought](#agentthought) ] | | No |
| annotation | [ConversationAnnotation](#conversationannotation) | | No |
| annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No |
| answer | string | | Yes |
| answer_tokens | integer | | No |
| conversation_id | string | | Yes |
| created_at | integer | | No |
@@ -17095,12 +17096,11 @@ Enum class for large language model mode.
| inputs | object | | Yes |
| message | [JSONValue](#jsonvalue) | | No |
| message_files | [ [MessageFile](#messagefile) ] | | No |
| message_metadata_dict | [JSONValue](#jsonvalue) | | No |
| message_tokens | integer | | No |
| metadata | [JSONValue](#jsonvalue) | | No |
| parent_message_id | string | | No |
| provider_response_latency | number | | No |
| query | string | | Yes |
| re_sign_file_url_answer | string | | Yes |
| status | string | | Yes |
| workflow_run_id | string | | No |
+6
View File
@@ -990,6 +990,12 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| last_used_at | string | | No |
| prefix | string | | Yes |
#### SimpleResultResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### SupportedAppType
App types the ``app`` usage face (``get app``) lists and filters.
+7 -11
View File
@@ -12,7 +12,6 @@ from sqlalchemy.exc import SQLAlchemyError
from configs import dify_config
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from models import TenantAccountJoin, TenantAccountRole
from services.enterprise.base import EnterpriseRequest
@@ -436,7 +435,6 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.delete",
"app.acl.release_and_version",
"app.acl.monitor",
"app.acl.log_and_annotation",
"app.acl.access_config",
]
@@ -651,18 +649,17 @@ class ReplaceRoleBindings(_RBACModel):
class ReplaceMemberBindings(_RBACModel):
scope: RBACResourceWhitelistScope = RBACResourceWhitelistScope.SPECIFIC
scope: str = "specific"
@field_validator("scope")
@classmethod
def _normalize_scope(cls, value: Any) -> RBACResourceWhitelistScope:
def _normalize_scope(cls, value: Any) -> str:
scope = str(value or "").strip().lower()
if scope == "":
return RBACResourceWhitelistScope.SPECIFIC
try:
return RBACResourceWhitelistScope(scope)
except ValueError as exc:
raise ValueError(f"invalid scope: {value}") from exc
if scope in {"", "specific"}:
return "specific"
if scope in {"all", "only_me"}:
return scope
raise ValueError(f"invalid scope: {value}")
class DeleteMemberBindings(_RBACModel):
@@ -746,7 +743,6 @@ def _inner_call(
account_id=account_id,
json=json,
params=params,
timeout=dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT,
)
@@ -27,7 +27,6 @@ extend-select = ["ANN401", "ARG", "TID251"]
"controllers/web/test_wraps.py" = ["ARG"]
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
"models/test_account.py" = ["ARG"]
"models/test_conversation_message_inputs.py" = ["ARG"]
"models/test_conversation_status_count.py" = ["ARG"]
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
@@ -3,6 +3,7 @@ Integration tests for Account and Tenant model methods that interact with the da
Migrated from unit_tests/models/test_account_models.py, replacing
@patch("models.account.db") mock patches with real PostgreSQL operations.
Also absorbs unit_tests/models/test_account.py role helper coverage.
Covers:
- Account.current_tenant setter (sets _current_tenant and role from TenantAccountJoin)
@@ -12,6 +13,7 @@ Covers:
"""
from collections.abc import Generator
from typing import cast
from uuid import uuid4
import pytest
@@ -20,8 +22,10 @@ from sqlalchemy.orm import Session
from models.account import Account, AccountIntegrate, Tenant, TenantAccountJoin, TenantAccountRole
TrackedRow = Account | AccountIntegrate | Tenant | TenantAccountJoin
def _cleanup_tracked_rows(db_session: Session, tracked: list) -> None:
def _cleanup_tracked_rows(db_session: Session, tracked: list[TrackedRow]) -> None:
"""Delete rows tracked during the test so committed state does not leak into the DB.
Rolls back any pending (uncommitted) session state first, then issues DELETE
@@ -52,7 +56,7 @@ def _build_account(email_prefix: str = "account") -> Account:
class _DBTrackingTestBase:
"""Base class providing a tracker list and shared row factories for account/tenant tests."""
_tracked: list
_tracked: list[TrackedRow]
@pytest.fixture(autouse=True)
def _setup_cleanup(self, db_session_with_containers: Session) -> Generator[None, None, None]:
@@ -84,6 +88,22 @@ class _DBTrackingTestBase:
return join
class TestTenantAccountRole:
"""Tests for TenantAccountRole helper methods."""
def test_account_is_privileged_role(self) -> None:
assert TenantAccountRole.ADMIN == "admin"
assert TenantAccountRole.OWNER == "owner"
assert TenantAccountRole.EDITOR == "editor"
assert TenantAccountRole.NORMAL == "normal"
assert TenantAccountRole.is_privileged_role(TenantAccountRole.ADMIN)
assert TenantAccountRole.is_privileged_role(TenantAccountRole.OWNER)
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.NORMAL)
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.EDITOR)
assert not TenantAccountRole.is_privileged_role(cast(TenantAccountRole, ""))
class TestAccountCurrentTenantSetter(_DBTrackingTestBase):
"""Integration tests for Account.current_tenant property setter."""
@@ -176,7 +196,7 @@ class TestAccountGetByOpenId(_DBTrackingTestBase):
assert result is not None
assert result.id == account.id
def test_get_by_openid_returns_none_when_no_integrate_exists(self, db_session_with_containers: Session) -> None:
def test_get_by_openid_returns_none_when_no_integrate_exists(self) -> None:
"""get_by_openid returns None when no AccountIntegrate row matches."""
result = Account.get_by_openid("github", f"github_{uuid4()}")
@@ -50,7 +50,6 @@ project-excludes = [
"libs/broadcast_channel/redis/test_streams_channel.py",
"libs/test_auto_renew_redis_lock_integration.py",
"libs/test_rate_limiter_integration.py",
"models/test_account.py",
"models/test_conversation_message_inputs.py",
"models/test_types_enum_text.py",
"repositories/test_sqlalchemy_api_workflow_node_execution_repository.py",
@@ -336,174 +336,6 @@ def _insert_load_balancing_model_config(
)
def test_data_migrate_group_registers_dataset_permission_rbac_migration(command_module) -> None:
command = command_module.data_migrate.commands["rbac-migrate-dataset-permissions"]
assert command is command_module.migrate_dataset_permissions_to_rbac
assert "operator_account_id" not in {param.name for param in command.params}
def test_dataset_permission_rbac_migration_help_mentions_binding_clear_side_effect(command_module) -> None:
result = CliRunner().invoke(
command_module.data_migrate,
["rbac-migrate-dataset-permissions", "--help"],
)
assert result.exit_code == 0
normalized_output = " ".join(result.output.split())
assert "clears existing per-user policy bindings" in normalized_output
assert "recreates legacy partial-member default bindings" in normalized_output
def test_dataset_permission_rbac_migration_maps_legacy_permissions_to_enum_scopes() -> None:
rbac_module = importlib.import_module("commands.rbac")
assert (
rbac_module._rbac_dataset_scope_for_legacy_permission(rbac_module.DatasetPermissionEnum.ALL_TEAM)
is rbac_module.RBACResourceWhitelistScope.ALL
)
assert (
rbac_module._rbac_dataset_scope_for_legacy_permission(rbac_module.DatasetPermissionEnum.PARTIAL_TEAM)
is rbac_module.RBACResourceWhitelistScope.SPECIFIC
)
assert rbac_module._dataset_permission_enum("partial_members") is rbac_module.DatasetPermissionEnum.PARTIAL_TEAM
assert rbac_module._dataset_permission_enum(None) is rbac_module.DatasetPermissionEnum.ONLY_ME
def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator(
command_module,
monkeypatch: pytest.MonkeyPatch,
) -> None:
rbac_module = importlib.import_module("commands.rbac")
dataset_row = SimpleNamespace(
id="dataset-1",
tenant_id="tenant-1",
permission="only_me",
created_by="creator-account-1",
)
execute_results = [[dataset_row], [], []]
calls: list[dict[str, object]] = []
session_closed = False
class FakeExecuteResult:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
def all(self) -> list[object]:
return self._rows
class FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
nonlocal session_closed
session_closed = True
pass
def execute(self, stmt):
return FakeExecuteResult(execute_results.pop(0))
class FakeSessionFactory:
@staticmethod
def create_session() -> FakeSession:
return FakeSession()
def fake_replace_whitelist(**kwargs):
assert session_closed is True
calls.append(kwargs)
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist)
command_module.migrate_dataset_permissions_to_rbac.callback(
tenant_id=None,
dataset_id=None,
batch_size=500,
dry_run=False,
)
assert calls[0]["tenant_id"] == "tenant-1"
assert calls[0]["account_id"] == "creator-account-1"
assert calls[0]["dataset_id"] == "dataset-1"
assert calls[0]["payload"].scope is rbac_module.RBACResourceWhitelistScope.SPECIFIC
def test_dataset_permission_rbac_migration_dry_run_outputs_structured_proposed_changes(
command_module,
monkeypatch: pytest.MonkeyPatch,
) -> None:
rbac_module = importlib.import_module("commands.rbac")
dataset_row = SimpleNamespace(
id="dataset-1",
tenant_id="tenant-1",
permission="partial_members",
created_by="creator-account-1",
)
permission_row = SimpleNamespace(dataset_id="dataset-1", account_id="member-account-1")
execute_results = [[dataset_row], [permission_row], []]
class FakeExecuteResult:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
def all(self) -> list[object]:
return self._rows
class FakeSession:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
pass
def execute(self, stmt):
return FakeExecuteResult(execute_results.pop(0))
class FakeSessionFactory:
@staticmethod
def create_session() -> FakeSession:
return FakeSession()
monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory)
monkeypatch.setattr(
rbac_module.RBACService.DatasetAccess,
"replace_whitelist",
lambda **kwargs: pytest.fail("dry-run must not replace whitelist"),
)
monkeypatch.setattr(
rbac_module.RBACService.DatasetAccess,
"replace_user_access_policies",
lambda **kwargs: pytest.fail("dry-run must not replace user access policies"),
)
result = CliRunner().invoke(
command_module.data_migrate,
["rbac-migrate-dataset-permissions", "--dry-run"],
)
assert result.exit_code == 0
events = [json.loads(line) for line in result.output.splitlines() if line.startswith("{")]
assert [event["action"] for event in events] == ["replace_whitelist", "replace_user_access_policies"]
assert events[0]["before"] == {
"legacy_dataset_permission": "partial_members",
"legacy_partial_member_ids": ["member-account-1"],
}
assert events[0]["after"] == {"rbac_whitelist_scope": "specific"}
assert events[0]["call"] == {
"method": "RBACService.DatasetAccess.replace_whitelist",
"kwargs": {
"tenant_id": "tenant-1",
"account_id": "creator-account-1",
"dataset_id": "dataset-1",
"payload": {"scope": "specific"},
},
}
assert events[1]["target_account_id"] == "member-account-1"
assert events[1]["after"] == {"rbac_user_access_policy_ids": ["default"]}
assert events[1]["call"]["kwargs"]["payload"] == {"access_policy_ids": ["default"]}
def test_data_migrate_command_defaults_output_to_stdout_stream(
command_module,
monkeypatch: pytest.MonkeyPatch,
@@ -77,6 +77,25 @@ class AnnotationApi(Resource):
assert "prefer dump_response" in checks[0].reason
def test_constructor_variable_model_dump_is_valid(tmp_path: Path):
checks = _checks_for_source(
tmp_path,
"""
@ns.route("/annotations")
class AnnotationApi(Resource):
@ns.response(201, "Created", ns.models[AnnotationResponse.__name__])
def post(self):
response = AnnotationResponse(id="new", name=name)
return response.model_dump(mode="json"), 201
""",
)
assert len(checks) == 1
assert checks[0].classification == "valid"
assert checks[0].actual[0].kind == "model"
assert checks[0].actual[0].model == "AnnotationResponse"
def test_variable_model_dump_with_wrong_documented_schema_is_mismatch(tmp_path: Path):
checks = _checks_for_source(
tmp_path,
@@ -117,6 +136,38 @@ class StreamApi(Resource):
assert {actual.model for actual in checks[0].actual} == {"StreamResponse"}
def test_response_contract_ignore_comment_skips_route_method(tmp_path: Path):
checks = _checks_for_source(
tmp_path,
"""
@ns.route("/binary")
class BinaryApi(Resource):
# response-contract:ignore binary response
@ns.response(200, "Binary file")
def get(self):
return send_file(path)
# response-contract:ignore compact Flask response
@ns.route("/compact")
class CompactApi(Resource):
def get(self):
return make_response({"url": "https://example.com"})
@ns.route("/regular")
class RegularApi(Resource):
@ns.response(200, "OK", ns.models[RegularResponse.__name__])
def get(self):
return dump_response(RegularResponse, {})
""",
)
assert len(checks) == 1
assert checks[0].class_name == "RegularApi"
assert checks[0].classification == "valid"
def test_main_is_report_only_by_default_for_mismatches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
module = _load_lint_response_contracts_module()
controller_path = tmp_path / "controllers" / "sample.py"
@@ -22,23 +22,17 @@ class TestSpecSchemaDefinitionsApi:
assert status == 200
assert resp == schema_definitions
def test_get_exception_returns_empty_list(self):
def test_get_exception_returns_empty_list(self, caplog):
api = spec_module.SpecSchemaDefinitionsApi()
method = unwrap(api.get)
with (
patch.object(
spec_module,
"SchemaManager",
side_effect=Exception("boom"),
),
patch.object(
spec_module.logger,
"exception",
) as log_exception,
with patch.object(
spec_module,
"SchemaManager",
side_effect=Exception("boom"),
):
resp, status = method(api)
assert status == 200
assert resp == []
log_exception.assert_called_once()
assert "boom" in caplog.text
@@ -136,7 +136,7 @@ class TestPydanticModels:
def test_resource_access_scope_defaults_empty_account_ids(self):
parsed = rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "specific"})
assert parsed.scope is rbac_mod.RBACResourceWhitelistScope.SPECIFIC
assert parsed.scope is rbac_mod._AccessScope.SPECIFIC
def test_resource_access_scope_coerce_null_account_ids(self):
rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "all"})
@@ -3,13 +3,36 @@
from __future__ import annotations
import sys
from types import SimpleNamespace
import uuid
from unittest.mock import Mock
import pytest
from flask import Flask
from controllers.openapi._models import AppRunRequest
from models import Account
from models.model import App, AppMode
_TEST_APP_ID = str(uuid.uuid4())
_TEST_TENANT_ID = str(uuid.uuid4())
_TEST_ACCOUNT_ID = str(uuid.uuid4())
def _make_app() -> App:
app = App()
app.id = _TEST_APP_ID
app.tenant_id = _TEST_TENANT_ID
app.name = "Streaming app"
app.mode = AppMode.CHAT
app.enable_site = False
app.enable_api = True
return app
def _make_account() -> Account:
account = Account(name="OpenAPI caller", email="[email protected]")
account.id = _TEST_ACCOUNT_ID
return account
def test_app_run_request_has_no_response_mode_field():
@@ -40,15 +63,19 @@ def test_run_chat_always_calls_generate_with_streaming_true(
from controllers.openapi.app_run import _run_chat
generate_mock = Mock(return_value=iter([]))
class GenerateService:
generate = generate_mock
monkeypatch.setattr(
sys.modules["controllers.openapi.app_run"],
"AppGenerateService",
SimpleNamespace(generate=generate_mock),
GenerateService,
)
with app.test_request_context("/openapi/v1/apps/app-1/run", method="POST"):
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"):
_run_chat(
SimpleNamespace(id="app-1", tenant_id="t-1"),
SimpleNamespace(id="acct-1"),
_make_app(),
_make_account(),
AppRunRequest(inputs={}, query="hello"),
)
_, kwargs = generate_mock.call_args
@@ -80,11 +107,11 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel
auth_data = AuthData.model_construct(
token_type=TokenType.OAUTH_ACCOUNT,
account_id=uuid.uuid4(),
account_id=uuid.UUID(_TEST_ACCOUNT_ID),
token_hash="test",
scopes=frozenset({Scope.FULL}),
app=SimpleNamespace(id="app-1", tenant_id="t-1"),
caller=SimpleNamespace(id="acct-1"),
app=_make_app(),
caller=_make_account(),
caller_kind="account",
)
@@ -5,6 +5,7 @@ view function decorated with @accepts/@returns, driven inside a request context.
"""
from functools import wraps
from typing import Any, cast
import pytest
from pydantic import BaseModel, ConfigDict, Field
@@ -100,7 +101,7 @@ def test_accepts_validation_error_is_sanitized_and_structured(app):
with pytest.raises(UnprocessableEntity) as exc_info:
view()
data = exc_info.value.data
data = cast(dict[str, Any], cast(Any, exc_info.value).data)
assert data["message"] == "Request validation failed"
assert isinstance(data["errors"], list)
assert data["errors"]
@@ -1,4 +1,4 @@
from libs.pyrefly_diagnostics import extract_diagnostics
from libs.pyrefly_diagnostics import extract_diagnostics, render_diagnostics
def test_extract_diagnostics_keeps_only_summary_and_location_lines() -> None:
@@ -40,6 +40,37 @@ def test_extract_diagnostics_handles_error_without_location_line() -> None:
assert diagnostics == "ERROR unexpected pyrefly output format [bad-format]\n"
def test_extract_diagnostics_keeps_warn_headlines_and_location_lines() -> None:
# Arrange
raw_output = """INFO Checking project configured at `/tmp/project/pyrefly.toml`
WARN Skipping include pattern `/tmp/project/tests` because it is matched by `project-excludes`.
--> tests/test_containers_integration_tests/pyrefly.toml:3:1
"""
# Act
diagnostics = extract_diagnostics(raw_output)
# Assert
assert diagnostics == (
"WARN Skipping include pattern `/tmp/project/tests` because it is matched by `project-excludes`.\n"
" --> tests/test_containers_integration_tests/pyrefly.toml:3:1\n"
)
def test_render_diagnostics_falls_back_to_raw_output_for_nonzero_exit_without_matches() -> None:
# Arrange
raw_output = (
"INFO Checking project configured at `/tmp/project/pyrefly.toml`\n"
"No Python files matched pattern `/tmp/project/tests/test_containers_integration_tests`\n"
)
# Act
diagnostics = render_diagnostics(raw_output, exit_code=1)
# Assert
assert diagnostics == raw_output
def test_extract_diagnostics_returns_empty_for_non_error_output() -> None:
# Arrange
raw_output = "INFO Checking project configured at `/tmp/project/pyrefly.toml`\n"
@@ -1,14 +0,0 @@
from models.account import TenantAccountRole
def test_account_is_privileged_role():
assert TenantAccountRole.ADMIN == "admin"
assert TenantAccountRole.OWNER == "owner"
assert TenantAccountRole.EDITOR == "editor"
assert TenantAccountRole.NORMAL == "normal"
assert TenantAccountRole.is_privileged_role(TenantAccountRole.ADMIN)
assert TenantAccountRole.is_privileged_role(TenantAccountRole.OWNER)
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.NORMAL)
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.EDITOR)
assert not TenantAccountRole.is_privileged_role("")
@@ -633,8 +633,6 @@ class TestMyPermissions:
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
@pytest.mark.parametrize(
("role", "expected_snippet_keys"),
@@ -1,3 +1,4 @@
import logging
from unittest.mock import Mock, patch
import pytest
@@ -427,16 +428,20 @@ class TestWorkflowCollaborationService:
repository.delete_leader.assert_not_called()
def test_broadcast_leader_change_logs_emit_errors(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
self,
service: tuple[WorkflowCollaborationService, Mock, Mock],
caplog: pytest.LogCaptureFixture,
) -> None:
collaboration_service, repository, socketio = service
repository.get_session_sids.return_value = ["sid-1", "sid-2"]
socketio.emit.side_effect = [RuntimeError("boom"), None]
with patch("services.workflow_collaboration_service.logging.exception") as exception_mock:
with caplog.at_level(logging.ERROR):
collaboration_service.broadcast_leader_change("wf-1", "sid-2")
assert exception_mock.call_count == 1
error_records = [record for record in caplog.records if record.levelno == logging.ERROR]
assert len(error_records) == 1
assert "Failed to emit leader status to session sid-1" in error_records[0].getMessage()
def test_broadcast_online_users_sorts_and_emits(
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
+1 -1
View File
@@ -158,6 +158,6 @@ describe('Version command', () => {
if (output?.kind !== 'formatted')
throw new Error('expected formatted output')
expect(output.data.text()).toContain('WARNING: This build is a rc release')
expect(output.data.text()).toContain('WARNING: This build is a(n) rc release')
})
})
+5 -5
View File
@@ -60,7 +60,7 @@ describe('renderVersionText', () => {
}
const text = renderVersionText(report)
expect(text).toContain('WARNING: This build is a rc release')
expect(text).toContain('WARNING: This build is a(n) rc release')
expect(text).toContain('install or wait for the stable channel')
})
@@ -72,7 +72,7 @@ describe('renderVersionText', () => {
}
const text = renderVersionText(report)
expect(text).toContain('WARNING: This build is a alpha release')
expect(text).toContain('WARNING: This build is a(n) alpha release')
expect(text).toContain('install or wait for the stable channel')
})
@@ -84,7 +84,7 @@ describe('renderVersionText', () => {
}
const text = renderVersionText(report)
expect(text).toContain('WARNING: This build is a edge release')
expect(text).toContain('WARNING: This build is a(n) edge release')
expect(text).toContain('install or wait for the stable channel')
})
@@ -140,7 +140,7 @@ describe('renderVersionText', () => {
// RC warning) ran, yet the output is byte-clean.
expect(plain).not.toMatch(ANSI_RE)
expect(plain).toContain('Compatibility: incompatible')
expect(plain).toContain('WARNING: This build is a rc release')
expect(plain).toContain('WARNING: This build is a(n) rc release')
})
describe('with picocolors stubbed to always emit ANSI', () => {
@@ -183,7 +183,7 @@ describe('renderVersionText', () => {
expect(colored).toMatch(ANSI_RE)
expect(colored).toContain('Compatibility: incompatible')
// prerelease warning lines also routed through yellow.
expect(colored).toContain('WARNING: This build is a rc release')
expect(colored).toContain('WARNING: This build is a(n) rc release')
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { colorScheme } from '@/sys/io/color'
function prereleaseWarning(channel: Channel): readonly string[] {
return [
`WARNING: This build is a ${channel} release. It is not stable`,
`WARNING: This build is a(n) ${channel} release. It is not stable`,
' and may have bugs. For production use, install or wait for the stable channel.',
]
}
+22 -4
View File
@@ -28,6 +28,10 @@ pyrefly_args=(
"--project-excludes=tests/"
)
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
pyrefly_args+=("--output-format=github")
fi
if [[ -f "$EXCLUDES_FILE" ]]; then
while IFS= read -r exclude; do
[[ -z "$exclude" || "${exclude:0:1}" == "#" ]] && continue
@@ -36,6 +40,14 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
fi
run_pyrefly() {
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
set +e
"$@"
local pyrefly_status=$?
set -e
return "$pyrefly_status"
fi
local tmp_output
tmp_output="$(mktemp)"
@@ -44,7 +56,7 @@ run_pyrefly() {
local pyrefly_status=$?
set -e
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
uv run --directory api python libs/pyrefly_diagnostics.py --status "$pyrefly_status" < "$tmp_output"
rm -f "$tmp_output"
return "$pyrefly_status"
}
@@ -62,11 +74,17 @@ fi
run_pyrefly "${pyrefly_command[@]}" || status=$?
if (( ${#target_paths[@]} == 0 )); then
test_containers_args=(
"--summary=none"
"--use-ignore-files=false"
"--config=$TEST_CONTAINERS_CONFIG"
)
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
test_containers_args+=("--output-format=github")
fi
run_pyrefly \
uv run --directory api --dev pyrefly check \
"--summary=none" \
"--use-ignore-files=false" \
"--config=$TEST_CONTAINERS_CONFIG" \
"${test_containers_args[@]}" \
|| status=$?
fi
+8
View File
@@ -891,6 +891,14 @@
"count": 3
}
},
"web/app/components/app/overview/settings/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/overview/workflow-hidden-input-fields.tsx": {
"no-restricted-imports": {
"count": 1
@@ -424,7 +424,6 @@ export type Site = {
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
input_placeholder?: string | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
@@ -1561,7 +1560,6 @@ export type SiteWritable = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
input_placeholder?: string | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
@@ -203,7 +203,6 @@ export const zSite = z.object({
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullable(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
@@ -2127,7 +2126,6 @@ export const zSiteWritable = z.object({
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
@@ -580,7 +580,6 @@ export type AppSiteUpdatePayload = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
input_placeholder?: string | null
privacy_policy?: string | null
prompt_public?: boolean | null
show_workflow_steps?: boolean | null
@@ -599,7 +598,6 @@ export type AppSiteResponse = {
description?: string | null
icon?: string | null
icon_background?: string | null
input_placeholder?: string | null
privacy_policy?: string | null
prompt_public: boolean
show_workflow_steps: boolean
@@ -1244,7 +1242,6 @@ export type Site = {
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
input_placeholder?: string | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
@@ -2694,7 +2691,6 @@ export type SiteWritable = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
input_placeholder?: string | null
privacy_policy?: string | null
show_workflow_steps: boolean
title: string
@@ -358,7 +358,6 @@ export const zAppSiteUpdatePayload = z.object({
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
prompt_public: z.boolean().nullish(),
show_workflow_steps: z.boolean().nullish(),
@@ -380,7 +379,6 @@ export const zAppSiteResponse = z.object({
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
prompt_public: z.boolean(),
show_workflow_steps: z.boolean(),
@@ -860,7 +858,6 @@ export const zSite = z.object({
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullable(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
@@ -3549,7 +3546,6 @@ export const zSiteWritable = z.object({
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
input_placeholder: z.string().nullish(),
privacy_policy: z.string().nullish(),
show_workflow_steps: z.boolean(),
title: z.string(),
@@ -232,6 +232,7 @@ export type SavedMessageItem = {
}
export type InstalledAppInfoResponse = {
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
@@ -246,7 +247,6 @@ export type AgentThought = {
created_at?: number | null
files: Array<string>
id: string
message_chain_id?: string | null
message_id: string
observation?: string | null
position: number
@@ -229,6 +229,7 @@ export const zParameters = z.object({
* InstalledAppInfoResponse
*/
export const zInstalledAppInfoResponse = z.object({
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
@@ -266,7 +267,6 @@ export const zAgentThought = z.object({
created_at: z.int().nullish(),
files: z.array(z.string()),
id: z.string(),
message_chain_id: z.string().nullish(),
message_id: z.string(),
observation: z.string().nullish(),
position: z.int(),
@@ -405,6 +405,10 @@ export type SessionRow = {
prefix: string
}
export type SimpleResultResponse = {
result: string
}
export type SupportedAppType = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
export type TaskStopResponse = {
@@ -501,6 +501,13 @@ export const zSessionListResponse = z.object({
total: z.int(),
})
/**
* SimpleResultResponse
*/
export const zSimpleResultResponse = z.object({
result: z.string(),
})
/**
* SupportedAppType
*
@@ -25,8 +25,6 @@ import {
zAccessServiceUpdateAccessPolicyBody,
zAccessServiceUpdateAccessPolicyPath,
zAccessServiceUpdateAccessPolicyResponse,
zAccessSubjectServiceListAccessSubjectsQuery,
zAccessSubjectServiceListAccessSubjectsResponse,
zAppInstanceServiceCreateAppInstanceBody,
zAppInstanceServiceCreateAppInstanceResponse,
zAppInstanceServiceDeleteAppInstancePath,
@@ -109,21 +107,6 @@ import {
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
} from './zod.gen'
export const listAccessSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessSubjectService_ListAccessSubjects',
path: '/enterprise/access-subjects',
tags: ['AccessSubjectService'],
})
.input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() }))
.output(zAccessSubjectServiceListAccessSubjectsResponse)
export const accessSubjectService = {
listAccessSubjects,
}
export const listAppInstanceSummaries = oc
.route({
inputStructure: 'detailed',
@@ -730,7 +713,6 @@ export const webAppAuth = {
}
export const contract = {
accessSubjectService,
appInstanceService,
accessService,
deploymentService,
@@ -1038,6 +1038,7 @@ export type UpdateEnvironmentRequest = {
environmentId?: string
displayName: string
description?: string
cpuCount?: number
}
export type UpdateEnvironmentResponse = {
@@ -1367,6 +1368,7 @@ export type InfoConfigReply = {
Branding?: BrandingInfo
WebAppAuth?: WebAppAuthInfo
PluginInstallationPermission?: PluginInstallationPermissionInfo
EnableAppDeploy?: boolean
}
export type InnerAdmission = {
@@ -1513,11 +1515,6 @@ export type LimitFields = {
appRunnerEnvCpus?: ResourceQuota
}
export type ListAccessSubjectsReply = {
subjects?: Array<Subject>
pagination?: Pagination
}
export type ListGroupAppsResponse = {
items?: Array<GroupAppItem>
total?: string
@@ -2138,25 +2135,6 @@ export type Pagination = {
totalPages?: number
}
export type AccessSubjectServiceListAccessSubjectsData = {
body?: never
path?: never
query?: {
keyword?: string
groupId?: string
pageNumber?: number
resultsPerPage?: number
}
url: '/enterprise/access-subjects'
}
export type AccessSubjectServiceListAccessSubjectsResponses = {
200: ListAccessSubjectsReply
}
export type AccessSubjectServiceListAccessSubjectsResponse
= AccessSubjectServiceListAccessSubjectsResponses[keyof AccessSubjectServiceListAccessSubjectsResponses]
export type AppInstanceServiceListAppInstanceSummariesData = {
body?: never
path?: never
@@ -912,6 +912,7 @@ export const zUpdateEnvironmentRequest = z.object({
environmentId: z.string().optional(),
displayName: z.string(),
description: z.string().optional(),
cpuCount: z.number().optional(),
})
export const zUpdateEnvironmentResponse = z.object({
@@ -2072,6 +2073,7 @@ export const zInfoConfigReply = z.object({
Branding: zBrandingInfo.optional(),
WebAppAuth: zWebAppAuthInfo.optional(),
PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(),
EnableAppDeploy: z.boolean().optional(),
})
export const zWebOAuth2LoginReply = z.object({
@@ -2243,11 +2245,6 @@ export const zListRollbackTargetsResponse = z.object({
pagination: zPagination,
})
export const zListAccessSubjectsReply = z.object({
subjects: z.array(zSubject).optional(),
pagination: zPagination.optional(),
})
export const zListMembersReply = z.object({
data: z.array(zAccountDetail).optional(),
pagination: zPagination.optional(),
@@ -2268,26 +2265,6 @@ export const zListWorkspacesReply = z.object({
pagination: zPagination.optional(),
})
export const zAccessSubjectServiceListAccessSubjectsQuery = z.object({
keyword: z.string().optional(),
groupId: z.string().optional(),
pageNumber: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
resultsPerPage: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
})
/**
* OK
*/
export const zAccessSubjectServiceListAccessSubjectsResponse = zListAccessSubjectsReply
export const zAppInstanceServiceListAppInstanceSummariesQuery = z.object({
pageNumber: z
.int()
+74 -4
View File
@@ -10,13 +10,21 @@ type SwaggerSchema = JsonObject & {
$ref?: string
}
type OpenApiMediaType = JsonObject & {
schema?: SwaggerSchema
}
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type OpenApiComponents = JsonObject & {
schemas?: Record<string, SwaggerSchema>
}
type SwaggerOperation = JsonObject & {
operationId?: string
responses?: Record<string, unknown>
responses?: Record<string, OpenApiResponse>
}
type SwaggerDocument = JsonObject & {
@@ -52,6 +60,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url))
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$'
const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$'
const opaqueJsonContent = (): Record<string, OpenApiMediaType> => ({
'application/json': {
schema: {
additionalProperties: true,
type: 'object',
},
},
})
const apiSpecs: ApiSpec[] = [
{ filename: 'console-openapi.json', name: 'console' },
@@ -182,6 +201,46 @@ const addOperationIds = (document: SwaggerDocument) => {
}
}
const isOpaqueContractResponse = (response: OpenApiResponse) => {
const content = response.content
if (!isObject(content))
return false
return Object.entries(content).some(([mediaType, media]) => {
if (!isObject(media))
return false
return (mediaType === 'application/json' || mediaType === 'text/event-stream') && !('schema' in media)
})
}
const hasOpaqueContractSuccessResponse = (operation: SwaggerOperation) => {
return Object.entries(operation.responses ?? {}).some(([status, response]) => {
return /^2\d\d$/.test(status) && isObject(response) && isOpaqueContractResponse(response)
})
}
const normalizeOpaqueContractResponses = (document: SwaggerDocument) => {
// Some backend endpoints has no schema (e.g. external) and will trap heyapi here
// So we forge an opaque schema here
for (const pathItem of Object.values(document.paths ?? {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!operationMethods.has(method) || !isObject(operation))
continue
const swaggerOperation = operation as SwaggerOperation
if (!hasOpaqueContractSuccessResponse(swaggerOperation))
continue
Object.values(swaggerOperation.responses ?? {})
.filter(response => isObject(response) && isOpaqueContractResponse(response))
.forEach((response) => {
response.content = opaqueJsonContent()
})
}
}
}
const hasSuccessResponse = (operation: SwaggerOperation) => {
return Object.entries(operation.responses ?? {}).some(([status, response]) => {
if (!/^2\d\d$/.test(status))
@@ -215,6 +274,7 @@ const filterContractOperations = (document: SwaggerDocument) => {
}
const normalizeApiSwagger = (document: SwaggerDocument) => {
normalizeOpaqueContractResponses(document)
filterContractOperations(document)
addOperationIds(document)
@@ -380,10 +440,20 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
'name': 'zod',
'~resolvers': {
string: (ctx) => {
if (ctx.schema.format !== 'binary')
return undefined
if (ctx.schema.format === 'binary')
return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File')))
return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File')))
if (ctx.schema.pattern === pydanticDecimalStringPattern) {
// the pydantic generated regex will emit error like
// regexp/no-useless-assertions, so patch the regex here
return $(ctx.symbols.z)
.attr('string')
.call()
.attr('regex')
.call($.regexp(codegenSafeDecimalStringPattern))
}
return undefined
},
},
},
@@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
}))
vi.mock('@/app/components/app/app-access-control', () => ({
AccessControl: ({
default: ({
onConfirm,
onClose,
}: {
@@ -112,9 +112,14 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
},
}))
vi.mock('@/app/components/app/app-access-control', () => ({
AccessControl: () => <div data-testid="app-access-control" />,
}))
vi.mock('@/app/components/app/app-access-control', () => {
const MockAccessControl = () => <div data-testid="app-access-control" />
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
@@ -212,14 +212,19 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
),
}))
vi.mock('@/app/components/app/app-access-control', () => ({
AccessControl: ({ onConfirm, onClose }: Record<string, unknown>) => (
vi.mock('@/app/components/app/app-access-control', () => {
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
<div data-testid="access-control-modal">
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>
</div>
),
}))
)
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: overrides.id ?? 'app-1',
@@ -232,20 +232,7 @@ describe('Billing Page + Plan Integration', () => {
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when manager has subscription management permission', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.subscription.manage'],
})
render(<Billing />)
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is granted without manager role', () => {
it('should show billing button when subscription management permission is granted', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({
isCurrentWorkspaceManager: false,
@@ -254,7 +241,8 @@ describe('Billing Page + Plan Integration', () => {
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is missing', () => {
@@ -1,8 +1,5 @@
import { AccessTab } from '@/features/deployments/detail/access-tab'
export default async function InstanceDetailAccessPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <AccessTab appInstanceId={appInstanceId} />
export default function InstanceDetailAccessPage() {
return <AccessTab />
}
@@ -1,8 +1,5 @@
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
import { ApiTokensTab } from '@/features/deployments/detail/api-tokens-tab'
export default async function InstanceDetailApiTokensPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeveloperApiTab appInstanceId={appInstanceId} />
export default function InstanceDetailApiTokensPage() {
return <ApiTokensTab />
}
@@ -1,8 +1,5 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
import { InstancesTab } from '@/features/deployments/detail/instances-tab'
export default async function InstanceDetailInstancesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeployTab appInstanceId={appInstanceId} />
export default function InstanceDetailInstancesPage() {
return <InstancesTab />
}
@@ -1,14 +1,11 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default async function InstanceDetailLayout({ children, params }: {
export default function InstanceDetailLayout({ children }: {
children: ReactNode
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return (
<InstanceDetail appInstanceId={appInstanceId}>
<InstanceDetail>
{children}
</InstanceDetail>
)
@@ -1,8 +1,5 @@
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
export default async function InstanceDetailOverviewPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <OverviewTab appInstanceId={appInstanceId} />
export default function InstanceDetailOverviewPage() {
return <OverviewTab />
}
@@ -1,8 +1,5 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
import { ReleasesTab } from '@/features/deployments/detail/releases-tab'
export default async function InstanceDetailReleasesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <VersionsTab appInstanceId={appInstanceId} />
export default function InstanceDetailReleasesPage() {
return <ReleasesTab />
}
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AccessControlDialog } from '../access-control-dialog'
import AccessControlDialog from '../access-control-dialog'
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
@@ -1,43 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import { AccessControlItem } from '../access-control-item'
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
import { createAccessControlDraftHarness } from './access-control-test-utils'
import AccessControlItem from '../access-control-item'
describe('AccessControlItem', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.PUBLIC,
selectedGroupsForBreadcrumb: [],
})
})
it('should update current menu when selecting a different access type', () => {
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.PUBLIC },
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
render(harness.element)
const option = screen.getByRole('radio', { name: 'Organization Only' })
const option = screen.getByText('Organization Only').parentElement as HTMLElement
fireEvent.click(option)
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should keep the selected state for the active access type', () => {
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.ORGANIZATION },
)
render(harness.element)
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
const option = screen.getByRole('radio', { name: 'Organization Only' })
expect(option).toHaveAttribute('data-checked')
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('border-components-option-card-option-selected-border')
})
})
@@ -1,17 +0,0 @@
import type { ReactNode } from 'react'
import type { AccessMode } from '@/models/access-control'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useAccessControlStore } from '../store'
export function AccessControlRadioGroupHarness({ children }: {
children: ReactNode
}) {
const currentMenu = useAccessControlStore(state => state.currentMenu)
const setCurrentMenu = useAccessControlStore(state => state.setCurrentMenu)
return (
<RadioGroup<AccessMode> value={currentMenu} onValueChange={setCurrentMenu}>
{children}
</RadioGroup>
)
}
@@ -1,71 +0,0 @@
import type { ReactNode } from 'react'
import type { AccessControlDraft, AccessControlStore } from '../store'
import { createElement } from 'react'
import { AccessMode } from '@/models/access-control'
import { useAccessControlStore } from '../store'
import { AccessControlDraftProvider } from '../store-provider'
const emptyDraft = {
appId: '',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
specificGroups: [],
specificMembers: [],
selectedGroupsForBreadcrumb: [],
} satisfies Required<AccessControlDraft>
function draftKey(draft: AccessControlDraft) {
return [
draft.appId ?? '',
draft.currentMenu,
draft.specificGroups?.map(group => group.id).join(',') ?? '',
draft.specificMembers?.map(member => member.id).join(',') ?? '',
draft.selectedGroupsForBreadcrumb?.map(group => group.id).join(',') ?? '',
].join(':')
}
function completeDraft(initialDraft: Partial<AccessControlDraft> = {}): Required<AccessControlDraft> {
return {
...emptyDraft,
...initialDraft,
}
}
function SnapshotProbe({ onSnapshot }: {
onSnapshot: (snapshot: AccessControlStore) => void
}) {
onSnapshot(useAccessControlStore(state => state))
return null
}
export function createAccessControlDraftHarness(
children: ReactNode,
initialDraft?: Partial<AccessControlDraft>,
) {
const draft = completeDraft(initialDraft)
let snapshot: AccessControlStore = {
appId: draft.appId,
specificGroups: draft.specificGroups,
setSpecificGroups: () => undefined,
specificMembers: draft.specificMembers,
setSpecificMembers: () => undefined,
currentMenu: draft.currentMenu,
setCurrentMenu: () => undefined,
selectedGroupsForBreadcrumb: draft.selectedGroupsForBreadcrumb,
setSelectedGroupsForBreadcrumb: () => undefined,
}
return {
element: createElement(
AccessControlDraftProvider,
{
draftKey: draftKey(draft),
initialDraft: draft,
},
createElement(SnapshotProbe, {
onSnapshot: nextSnapshot => snapshot = nextSnapshot,
}),
children,
),
getSnapshot: () => snapshot,
}
}
@@ -1,23 +1,24 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import { AccessControlDialog } from '../access-control-dialog'
import { AccessControlItem } from '../access-control-item'
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
import { AccessControl } from '../index'
import { SpecificGroupsOrMembers } from '../specific-groups-or-members'
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
import { createAccessControlDraftHarness } from './access-control-test-utils'
import AccessControlDialog from '../access-control-dialog'
import AccessControlItem from '../access-control-item'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
import AccessControl from '../index'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
const mockMutate = vi.fn()
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
@@ -35,19 +36,12 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
}
})
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {
@@ -100,12 +94,10 @@ beforeAll(() => {
})
beforeEach(() => {
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
mockUseMutation.mockReturnValue({
mockMutateAsync.mockResolvedValue(undefined)
mockUseUpdateAccessMode.mockReturnValue({
isPending: false,
mutate: mockMutate,
mutateAsync: mockMutateAsync,
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
@@ -125,39 +117,33 @@ beforeEach(() => {
// AccessControlItem handles selected vs. unselected styling and click state updates
describe('AccessControlItem', () => {
it('should update current menu when selecting a different access type', () => {
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.PUBLIC },
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
render(harness.element)
const option = screen.getByRole('radio', { name: 'Organization Only' })
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('cursor-pointer')
fireEvent.click(option)
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should keep current menu when clicking the selected access type', () => {
const harness = createAccessControlDraftHarness(
<AccessControlRadioGroupHarness>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>
</AccessControlRadioGroupHarness>,
{ currentMenu: AccessMode.ORGANIZATION },
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
render(harness.element)
const option = screen.getByRole('radio', { name: 'Organization Only' })
const option = screen.getByText('Organization Only').parentElement as HTMLElement
fireEvent.click(option)
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
})
@@ -194,40 +180,32 @@ describe('AccessControlDialog', () => {
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
describe('SpecificGroupsOrMembers', () => {
it('should render collapsed view when not in specific selection mode', () => {
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{ currentMenu: AccessMode.ORGANIZATION },
)
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(harness.element)
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
it('should show loading state while pending', async () => {
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers loading />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
render(harness.element)
const { container } = render(<SpecificGroupsOrMembers />)
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{
appId: 'app-1',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
specificGroups: [baseGroup],
specificMembers: [baseMember],
},
)
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
render(harness.element)
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
@@ -256,12 +234,8 @@ describe('SpecificGroupsOrMembers', () => {
describe('AddMemberOrGroupDialog', () => {
it('should open search popover and display candidates', async () => {
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -272,21 +246,17 @@ describe('AddMemberOrGroupDialog', () => {
it('should allow selecting members and expanding groups', async () => {
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
await user.click(expandButton)
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
@@ -299,11 +269,7 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
@@ -320,9 +286,9 @@ describe('AddMemberOrGroupDialog', () => {
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
expect(harness.getSnapshot().specificGroups).toEqual([])
expect(harness.getSnapshot().specificMembers).toEqual([])
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
expect(useAccessControlStore.getState().specificGroups).toEqual([])
expect(useAccessControlStore.getState().specificMembers).toEqual([])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
expect(fetchNextPage).not.toHaveBeenCalled()
})
@@ -335,11 +301,7 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -353,6 +315,10 @@ describe('AccessControl', () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
useAccessControlStore.setState({
specificGroups: [baseGroup],
specificMembers: [baseMember],
})
const app = {
id: 'app-id-1',
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
@@ -366,22 +332,21 @@ describe('AccessControl', () => {
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
{
body: {
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
{ subjectId: baseMember.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
],
},
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
],
})
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalled()
})
@@ -1,9 +1,9 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode, SubjectType } from '@/models/access-control'
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
import { createAccessControlDraftHarness } from './access-control-test-utils'
import useAccessControlStore from '@/context/access-control-store'
import { SubjectType } from '@/models/access-control'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
const mockUseSearchForWhiteListCandidates = vi.fn()
const intersectionObserverMocks = vi.hoisted(() => ({
@@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
}))
@@ -69,6 +69,13 @@ describe('AddMemberOrGroupDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: 'app-1',
specificGroups: [],
specificMembers: [],
currentMenu: SubjectType.GROUP as never,
selectedGroupsForBreadcrumb: [],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
@@ -81,11 +88,7 @@ describe('AddMemberOrGroupDialog', () => {
it('should open the search popover and display candidates', async () => {
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -96,20 +99,16 @@ describe('AddMemberOrGroupDialog', () => {
it('should allow expanding groups and selecting members', async () => {
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
await user.click(screen.getByRole('option', { name: /Member One/ }))
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should show the empty state when no candidates are returned', async () => {
@@ -121,11 +120,7 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -133,6 +128,9 @@ describe('AddMemberOrGroupDialog', () => {
})
it('should keep breadcrumbs visible when the current group has no candidates', async () => {
useAccessControlStore.setState({
selectedGroupsForBreadcrumb: [baseGroup],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
@@ -141,15 +139,7 @@ describe('AddMemberOrGroupDialog', () => {
})
const user = userEvent.setup()
const harness = createAccessControlDraftHarness(
<AddMemberOrGroupDialog />,
{
appId: 'app-1',
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [baseGroup],
},
)
render(harness.element)
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
@@ -159,6 +149,6 @@ describe('AddMemberOrGroupDialog', () => {
await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' }))
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
})
})
@@ -3,8 +3,9 @@ import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import { AccessControl } from '../index'
import AccessControl from '../index'
let mockWebappAuth = {
enabled: true,
@@ -17,24 +18,20 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
systemFeatures: { webapp_auth: mockWebappAuth },
})
const mockMutate = vi.fn()
const mockUseMutation = vi.hoisted(() => vi.fn())
const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: (...args: unknown[]) => mockUseMutation(...args),
}
})
describe('AccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -44,13 +41,14 @@ describe('AccessControl', () => {
allow_email_password_login: false,
allow_email_code_login: false,
}
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
mockUseMutation.mockReturnValue({
isPending: false,
mutate: mockMutate,
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
@@ -83,18 +81,18 @@ describe('AccessControl', () => {
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().appId).toBe(app.id)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
{
body: {
appId: app.id,
accessMode: AccessMode.PUBLIC,
},
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.PUBLIC,
})
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalledTimes(1)
})
@@ -118,30 +116,4 @@ describe('AccessControl', () => {
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
it('should prevent confirming specific access before subjects are loaded', () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
render(
<AccessControl
app={{ id: 'app-id-3', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
onClose={vi.fn()}
/>,
)
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
const organizationOption = screen.getByRole('radio', {
name: 'app.accessControlDialog.accessItems.organization',
})
expect(confirmButton).toBeDisabled()
expect(organizationOption).toHaveAttribute('aria-disabled', 'true')
fireEvent.click(confirmButton)
expect(mockMutate).not.toHaveBeenCalled()
})
})
@@ -1,17 +1,17 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import { SpecificGroupsOrMembers } from '../specific-groups-or-members'
import { createAccessControlDraftHarness } from './access-control-test-utils'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseSearchForWhiteListCandidates = vi.fn()
const mockUseAppWhiteListSubjects = vi.fn()
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
vi.mock('../add-member-or-group-pop', () => ({
default: () => <div data-testid="add-member-or-group-dialog" />,
}))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
@@ -36,48 +36,50 @@ describe('SpecificGroupsOrMembers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
})
it('should render the collapsed row when not in specific mode', () => {
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{ currentMenu: AccessMode.ORGANIZATION },
)
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(harness.element)
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
})
it('should show loading when the selected subjects are pending', async () => {
const harness = createAccessControlDraftHarness(<SpecificGroupsOrMembers loading />)
render(harness.element)
it('should show loading while whitelist subjects are pending', async () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled()
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
const harness = createAccessControlDraftHarness(
<SpecificGroupsOrMembers />,
{
appId: 'app-1',
specificGroups: [baseGroup],
specificMembers: [baseMember],
},
)
useAccessControlStore.setState({ appId: 'app-1' })
render(harness.element)
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
@@ -89,9 +91,9 @@ describe('SpecificGroupsOrMembers', () => {
const memberRemove = removeButtons[1]!
fireEvent.click(groupRemove)
expect(harness.getSnapshot().specificGroups).toEqual([])
expect(useAccessControlStore.getState().specificGroups).toEqual([])
fireEvent.click(memberRemove)
expect(harness.getSnapshot().specificMembers).toEqual([])
expect(useAccessControlStore.getState().specificMembers).toEqual([])
})
})
@@ -1,110 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { AccessControlItem } from './access-control-item'
import { SpecificGroupsOrMembers, WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { useAccessControlStore } from './store'
type AccessControlDialogContentProps = {
title?: ReactNode
description?: ReactNode
accessLabel?: ReactNode
hideExternal?: boolean
hideExternalTip?: boolean
saving?: boolean
controlsDisabled?: boolean
confirmDisabled?: boolean
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
onClose: () => void
onConfirm: () => void
}
export function AccessControlDialogContent({
title,
description,
accessLabel,
hideExternal = false,
hideExternalTip = false,
saving = false,
controlsDisabled = false,
confirmDisabled = false,
specificGroupsOrMembersProps,
onClose,
onConfirm,
}: AccessControlDialogContentProps) {
const { t } = useTranslation()
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
return (
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title ?? t('accessControlDialog.title', { ns: 'app' })}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{description ?? t('accessControlDialog.description', { ns: 'app' })}
</DialogDescription>
</div>
<RadioGroup<AccessMode>
value={currentMenu}
onValueChange={setCurrentMenu}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby="access-control-options-label"
disabled={controlsDisabled}
>
<div className="leading-6">
<p id="access-control-options-label" className="system-sm-medium text-text-tertiary">
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
</AccessControlItem>
{!hideExternal && (
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
</p>
</div>
{!hideExternalTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
)}
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
</p>
</div>
</AccessControlItem>
</RadioGroup>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
)
}
@@ -5,6 +5,7 @@ import {
DialogCloseButton,
DialogContent,
} from '@langgenius/dify-ui/dialog'
import { useCallback } from 'react'
type DialogProps = {
className?: string
@@ -13,17 +14,21 @@ type DialogProps = {
onClose?: () => void
}
export function AccessControlDialog({
const AccessControlDialog = ({
className,
children,
show,
onClose,
}: DialogProps) {
}: DialogProps) => {
const close = useCallback(() => {
onClose?.()
}, [onClose])
return (
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all',
className,
)}
>
@@ -33,3 +38,5 @@ export function AccessControlDialog({
</Dialog>
)
}
export default AccessControlDialog
@@ -1,26 +1,37 @@
'use client'
import type { PropsWithChildren } from 'react'
import type { FC, PropsWithChildren } from 'react'
import type { AccessMode } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioRoot } from '@langgenius/dify-ui/radio'
import useAccessControlStore from '@/context/access-control-store'
export function AccessControlItem({ type, children }: PropsWithChildren<{
type AccessControlItemProps = PropsWithChildren<{
type: AccessMode
}>) {
}>
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
if (currentMenu !== type) {
return (
<div
className="cursor-pointer rounded-[10px] border
border-components-option-card-option-border bg-components-option-card-option-bg
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
onClick={() => setCurrentMenu(type)}
>
{children}
</div>
)
}
return (
<RadioRoot<AccessMode>
value={type}
variant="unstyled"
render={<div />}
className={cn(
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
)}
<div className="rounded-[10px] border-[1.5px]
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
>
{children}
</RadioRoot>
</div>
)
}
AccessControlItem.displayName = 'AccessControlItem'
export default AccessControlItem
@@ -1,31 +1,371 @@
'use client'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
AccessSubjectAddButton,
} from './access-subject-selector/add-button'
import { useAccessControlStore } from './store'
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Loading from '../../base/loading'
export function AddMemberOrGroupDialog({ disabled = false }: {
disabled?: boolean
}) {
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
const pages = data?.pages ?? []
const subjects = pages.flatMap(page => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else
nextMembers.push((subject as SubjectAccount).accountData)
}
setSpecificGroups(nextGroups)
setSpecificMembers(nextMembers)
}
return (
<AccessSubjectAddButton
selectedGroups={specificGroups}
selectedMembers={specificMembers}
disabled={disabled}
breadcrumbGroups={selectedGroupsForBreadcrumb}
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading
? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
)
: (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb />
</div>
)}
{hasResults
? (
<>
<ComboboxList className="max-h-none p-1">
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
)
: (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
function SubjectItem({ subject }: { subject: Subject }) {
if (subject.subjectType === SubjectType.GROUP)
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
}
function SelectedGroupsBreadCrumb() {
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const { t } = useTranslation()
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
setSelectedGroupsForBreadcrumb(newGroups)
}
const handleReset = () => {
setSelectedGroupsForBreadcrumb([])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb
? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
</button>
)
: (
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
)}
{selectedGroupsForBreadcrumb.map((group, index) => {
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
return (
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
<span>/</span>
{isLastGroup
? <span>{group.name}</span>
: (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadCrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
}
function GroupItem({ group, subject }: GroupItemProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const isChecked = specificGroups.some(g => g.id === group.id)
const handleExpandClick = () => {
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
}
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</BaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
onPointerDown={event => event.preventDefault()}
onClick={handleExpandClick}
>
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
<RiArrowRightSLine className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
}
function MemberItem({ member, subject }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const specificMembers = useAccessControlStore(s => s.specificMembers)
const isChecked = specificMembers.some(m => m.id === member.id)
return (
<BaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
(
{t('you', { ns: 'common' })}
)
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</BaseItem>
)
}
type BaseItemProps = {
className?: string
subject: Subject
children: React.ReactNode
}
function BaseItem({ children, className, subject }: BaseItemProps) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</ComboboxItem>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
)
}
@@ -1,18 +1,20 @@
'use client'
import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen'
import type { Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { consoleQuery } from '@/service/client'
import { AccessControlDialog } from './access-control-dialog'
import { AccessControlDialogContent } from './access-control-dialog-content'
import { useAccessControlStore } from './store'
import { AccessControlDraftProvider } from './store-provider'
import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
type AccessControlProps = {
app: App
@@ -20,113 +22,92 @@ type AccessControlProps = {
onConfirm?: () => void
}
export function AccessControl(props: AccessControlProps) {
export default function AccessControl(props: AccessControlProps) {
const { app, onClose, onConfirm } = props
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const hideExternalTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
const initialAccessMode = app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS
const whiteListSubjectsQuery = useAppWhiteListSubjects(
app.id,
initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const initialSpecificGroups = whiteListSubjectsQuery.data?.groups ?? []
const initialSpecificMembers = whiteListSubjectsQuery.data?.members ?? []
const draftKey = [
app.id,
initialAccessMode,
initialSpecificGroups.map(group => group.id).join(','),
initialSpecificMembers.map(member => member.id).join(','),
].join(':')
return (
<AccessControlDraftProvider
draftKey={draftKey}
initialDraft={{
appId: app.id,
currentMenu: initialAccessMode,
specificGroups: initialSpecificGroups,
specificMembers: initialSpecificMembers,
selectedGroupsForBreadcrumb: [],
}}
>
<AccessControlForm
app={app}
hideExternalTip={hideExternalTip}
subjectsLoading={initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && whiteListSubjectsQuery.isPending}
onClose={onClose}
onConfirm={onConfirm}
successMessage={t('accessControlDialog.updateSuccess', { ns: 'app' })}
/>
</AccessControlDraftProvider>
)
}
function AccessControlForm({
app,
hideExternalTip,
subjectsLoading,
successMessage,
onClose,
onConfirm,
}: {
app: App
hideExternalTip: boolean
subjectsLoading: boolean
successMessage: string
onClose: () => void
onConfirm?: () => void
}) {
const setAppId = useAccessControlStore(s => s.setAppId)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const currentMenu = useAccessControlStore(s => s.currentMenu)
const { isPending, mutate: updateAccessMode } = useMutation(consoleQuery.explore.updateAppAccessMode.mutationOptions())
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
const hideTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
function handleConfirm() {
useEffect(() => {
setAppId(app.id)
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
}, [app, setAppId, setCurrentMenu])
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
const handleConfirm = useCallback(async () => {
const submitData: {
appId: string
accessMode: AccessMode
subjects?: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[]
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
} = { appId: app.id, accessMode: currentMenu }
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
const subjects: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[] = []
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
specificGroups.forEach((group) => {
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP })
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
})
specificMembers.forEach((member) => {
subjects.push({
subjectId: member.id,
subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
subjectType: SubjectType.ACCOUNT,
})
})
submitData.subjects = subjects
}
updateAccessMode({
body: submitData,
}, {
onSuccess: () => {
toast.success(successMessage)
onConfirm?.()
},
})
}
await updateAccessMode(submitData)
toast.success(t('accessControlDialog.updateSuccess', { ns: 'app' }))
onConfirm?.()
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
return (
<AccessControlDialog show onClose={onClose}>
<AccessControlDialogContent
hideExternalTip={hideExternalTip}
saving={isPending}
controlsDisabled={subjectsLoading || isPending}
confirmDisabled={subjectsLoading}
specificGroupsOrMembersProps={{
loading: subjectsLoading,
}}
onClose={onClose}
onConfirm={handleConfirm}
/>
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
</div>
<div className="flex flex-col gap-y-1 px-6 pb-3">
<div className="leading-6">
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiBuildingLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers />
</AccessControlItem>
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<RiGlobalLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
</div>
</AccessControlItem>
</div>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
</div>
</div>
</AccessControlDialog>
)
}
@@ -1,30 +1,34 @@
'use client'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
import { useAccessControlStore } from './store'
import Loading from '../../base/loading'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
export type SpecificGroupsOrMembersProps = {
loading?: boolean
}
export function SpecificGroupsOrMembers({
loading = false,
}: SpecificGroupsOrMembersProps) {
export default function SpecificGroupsOrMembers() {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const appId = useAccessControlStore(s => s.appId)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const { t } = useTranslation()
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
useEffect(() => {
setSpecificGroups(data?.groups ?? [])
setSpecificMembers(data?.members ?? [])
}, [data, setSpecificGroups, setSpecificMembers])
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<RiLockLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
</div>
@@ -35,28 +39,109 @@ export function SpecificGroupsOrMembers({
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<RiLockLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
<div className="flex items-center gap-x-1">
<AddMemberOrGroupDialog disabled={loading} />
<AddMemberOrGroupDialog />
</div>
</div>
<div className="px-1 pb-1">
<AccessSubjectSelectionList
selectedGroups={specificGroups}
selectedMembers={specificMembers}
loading={loading}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
</div>
</div>
</div>
)
}
function RenderGroupsAndMembers() {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
if (specificGroups.length <= 0 && specificMembers.length <= 0)
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
<div className="flex flex-row flex-wrap gap-1">
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
<div className="flex flex-row flex-wrap gap-1">
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
</div>
</>
)
}
type GroupItemProps = {
group: AccessControlGroup
}
function GroupItem({ group }: GroupItemProps) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const handleRemoveGroup = useCallback(() => {
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
}, [group, setSpecificGroups, specificGroups])
return (
<BaseItem
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
onRemove={handleRemoveGroup}
>
<p className="system-xs-regular text-text-primary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
</BaseItem>
)
}
type MemberItemProps = {
member: AccessControlAccount
}
function MemberItem({ member }: MemberItemProps) {
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const handleRemoveMember = useCallback(() => {
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
}, [member, setSpecificMembers, specificMembers])
return (
<BaseItem
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="system-xs-regular text-text-primary">{member.name}</p>
</BaseItem>
)
}
type BaseItemProps = {
icon: React.ReactNode
children: React.ReactNode
onRemove?: () => void
}
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
const { t } = useTranslation()
return (
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
{icon}
</div>
</div>
{children}
<button
type="button"
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={t('operation.remove', { ns: 'common' })}
onClick={onRemove}
>
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
</button>
</div>
)
}
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
@@ -1,34 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { AccessControlDraft, AccessControlStoreApi } from './store'
import { useRef } from 'react'
import { AccessControlStoreContext, createAccessControlStore } from './store'
export function AccessControlDraftProvider({
children,
draftKey,
initialDraft,
}: {
children?: ReactNode
draftKey: string
initialDraft: AccessControlDraft
}) {
const storeRef = useRef<{
draftKey: string
store: AccessControlStoreApi
} | undefined>(undefined)
if (!storeRef.current || storeRef.current.draftKey !== draftKey) {
storeRef.current = {
draftKey,
store: createAccessControlStore(initialDraft),
}
}
return (
<AccessControlStoreContext value={storeRef.current.store}>
{children}
</AccessControlStoreContext>
)
}
@@ -1,52 +0,0 @@
import type { StoreApi } from 'zustand'
import type { AccessControlAccount, AccessControlGroup, AccessMode } from '@/models/access-control'
import type { App } from '@/types/app'
import { createContext, use } from 'react'
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
export type AccessControlDraft = {
appId?: App['id']
currentMenu: AccessMode
specificGroups?: AccessControlGroup[]
specificMembers?: AccessControlAccount[]
selectedGroupsForBreadcrumb?: AccessControlGroup[]
}
export type AccessControlStore = {
appId: App['id']
specificGroups: AccessControlGroup[]
setSpecificGroups: (specificGroups: AccessControlGroup[]) => void
specificMembers: AccessControlAccount[]
setSpecificMembers: (specificMembers: AccessControlAccount[]) => void
currentMenu: AccessMode
setCurrentMenu: (currentMenu: AccessMode) => void
selectedGroupsForBreadcrumb: AccessControlGroup[]
setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void
}
export type AccessControlStoreApi = StoreApi<AccessControlStore>
export function createAccessControlStore(initialDraft: AccessControlDraft) {
return createStore<AccessControlStore>(set => ({
appId: initialDraft.appId ?? '',
specificGroups: initialDraft.specificGroups ?? [],
setSpecificGroups: specificGroups => set({ specificGroups }),
specificMembers: initialDraft.specificMembers ?? [],
setSpecificMembers: specificMembers => set({ specificMembers }),
currentMenu: initialDraft.currentMenu,
setCurrentMenu: currentMenu => set({ currentMenu }),
selectedGroupsForBreadcrumb: initialDraft.selectedGroupsForBreadcrumb ?? [],
setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }),
}))
}
export const AccessControlStoreContext = createContext<AccessControlStoreApi | undefined>(undefined)
export function useAccessControlStore<T>(selector: (state: AccessControlStore) => T) {
const store = use(AccessControlStoreContext)
if (!store)
throw new Error('useAccessControlStore must be used inside AccessControlDraftProvider')
return useStore(store, selector)
}
@@ -137,14 +137,19 @@ vi.mock('@/app/components/app/overview/embedded', () => ({
: null),
}))
vi.mock('../../app-access-control', () => ({
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
vi.mock('../../app-access-control', () => {
const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
<div data-testid="access-control">
<button onClick={() => void onConfirm()}>confirm-access-control</button>
<button onClick={onClose}>close-access-control</button>
</div>
),
}))
)
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
vi.mock('@/app/components/tools/workflow-tool', () => ({
WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
@@ -38,7 +38,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
@@ -46,7 +46,7 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { AccessControl } from '../app-access-control'
import AccessControl from '../app-access-control'
import {
PublisherAccessSection,
PublisherActionsSection,
@@ -21,7 +21,6 @@ let mockChatConversationDetail: Record<string, unknown> | undefined
let mockCompletionConversationDetail: Record<string, unknown> | undefined
let mockShowMessageLogModal = false
let mockShowPromptLogModal = false
let mockShowAgentLogModal = false
let mockCurrentLogItem: Record<string, unknown> | undefined
let mockCurrentLogModalActiveTab = 'messages'
@@ -82,7 +81,6 @@ vi.mock('@/app/components/app/store', () => ({
setShowAgentLogModal: mockSetShowAgentLogModal,
setShowMessageLogModal: mockSetShowMessageLogModal,
showPromptLogModal: mockShowPromptLogModal,
showAgentLogModal: mockShowAgentLogModal,
currentLogModalActiveTab: mockCurrentLogModalActiveTab,
}),
}))
@@ -128,7 +126,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited,
onAnnotationRemoved,
switchSibling,
hideLogModal,
}: {
chatList: Array<{ id: string }>
onFeedback: (mid: string, value: { rating: string, content?: string }) => Promise<boolean>
@@ -136,9 +133,8 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited: (query: string, answer: string, index: number) => void
onAnnotationRemoved: (index: number) => Promise<boolean>
switchSibling: (siblingMessageId: string) => void
hideLogModal?: boolean
}) => (
<div data-testid="chat-panel" data-hide-log-modal={String(hideLogModal)}>
<div data-testid="chat-panel">
<div>{chatList.length}</div>
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>chat-feedback</button>
<button onClick={() => onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1)}>chat-add-annotation</button>
@@ -149,14 +145,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ floating, onCancel }: { floating?: boolean, onCancel: () => void }) => (
<div data-testid="agent-log-modal" data-floating={String(floating)}>
<button onClick={onCancel}>close-agent-log-modal</button>
</div>
),
}))
vi.mock('@/app/components/base/message-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="message-log-modal">
@@ -267,7 +255,6 @@ describe('ConversationList', () => {
mockCompletionConversationDetail = undefined
mockShowMessageLogModal = false
mockShowPromptLogModal = false
mockShowAgentLogModal = false
mockCurrentLogItem = undefined
mockCurrentLogModalActiveTab = 'messages'
mockDelAnnotation.mockResolvedValue(undefined)
@@ -396,7 +383,6 @@ describe('ConversationList', () => {
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
expect(screen.getByTestId('message-log-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('chat-feedback'))
@@ -413,61 +399,6 @@ describe('ConversationList', () => {
})
})
it('should mount agent log modals from the detail panel instead of the nested chat layout', async () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
user_input_form: [],
},
message: {
inputs: {},
},
}
mockShowAgentLogModal = true
mockCurrentLogItem = {
id: 'message-1',
conversationId: 'conversation-1',
}
mockFetchChatMessages.mockResolvedValue({
data: [
{
id: 'message-1',
answer: 'Assistant reply',
query: 'Latest question',
created_at: 1710000000,
inputs: {},
feedbacks: [],
message: [],
message_files: [],
agent_thoughts: [{ id: 'thought-1' }],
},
],
has_more: false,
})
renderConversationList({
searchParams: '?page=2&conversation_id=conversation-1',
})
await waitFor(() => {
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
})
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
expect(screen.getByTestId('agent-log-modal')).toHaveAttribute('data-floating', 'true')
fireEvent.click(screen.getByText('close-agent-log-modal'))
expect(mockSetCurrentLogItem).toHaveBeenCalled()
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
})
it('should render completion details and refetch after feedback updates', async () => {
mockCompletionConversationDetail = {
id: 'conversation-1',
@@ -493,7 +424,7 @@ describe('ConversationList', () => {
},
}
mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
mockCurrentLogItem = { id: 'log-2' }
renderConversationList({
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
@@ -695,7 +626,7 @@ describe('ConversationList', () => {
},
}
mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
mockCurrentLogItem = { id: 'log-2' }
renderConversationList({
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
+2 -29
View File
@@ -36,7 +36,6 @@ import ModelInfo from '@/app/components/app/log/model-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import TextGeneration from '@/app/components/app/text-generate/item'
import ActionButton from '@/app/components/base/action-button'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Chat from '@/app/components/base/chat/chat'
import CopyIcon from '@/app/components/base/copy-icon'
import Loading from '@/app/components/base/loading'
@@ -166,25 +165,13 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
})
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
const {
currentLogItem,
setCurrentLogItem,
showMessageLogModal,
setShowMessageLogModal,
showPromptLogModal,
setShowPromptLogModal,
showAgentLogModal,
setShowAgentLogModal,
currentLogModalActiveTab,
} = useAppStore(useShallow((state: AppStoreState) => ({
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal,
setShowMessageLogModal: state.setShowMessageLogModal,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
currentLogModalActiveTab: state.currentLogModalActiveTab,
})))
const { t } = useTranslation()
@@ -408,7 +395,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION
const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
const shouldShowPromptLogModal = showPromptLogModal && !!currentLogItem?.log
const varList = getDetailVarList(detail, varValues)
const message_files = getCompletionMessageFiles(detail, isChatMode)
@@ -521,7 +507,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
noChatInput
showPromptLog
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
@@ -561,7 +546,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
noChatInput
showPromptLog
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
@@ -590,18 +574,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
/>
</WorkflowContextProvider>
)}
{showAgentLogModal && (
<AgentLogModal
floating
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
{shouldShowPromptLogModal && (
{!isChatMode && showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
@@ -25,14 +25,19 @@ vi.mock('../customize', () => ({
default: () => <div data-testid="customize-modal" />,
}))
vi.mock('../../app-access-control', () => ({
AccessControl: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
vi.mock('../../app-access-control', () => {
const MockAccessControl = ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
<div data-testid="access-control">
<button type="button" onClick={onClose}>close-access</button>
<button type="button" onClick={onConfirm}>confirm-access</button>
</div>
),
}))
)
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
describe('app-card-sections', () => {
const t = (key: string) => key
@@ -80,14 +80,19 @@ vi.mock('../customize', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? <button data-testid="customize-modal" onClick={onClose}>customize-modal</button> : null,
}))
vi.mock('../../app-access-control', () => ({
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
vi.mock('../../app-access-control', () => {
const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
<div data-testid="access-control-modal">
<button onClick={() => void onConfirm()}>confirm-access-control</button>
<button onClick={onClose}>close-access-control</button>
</div>
),
}))
)
return {
default: MockAccessControl,
AccessControl: MockAccessControl,
}
})
const mockWindowOpen = vi.fn()
Object.defineProperty(window, 'open', {
@@ -37,7 +37,7 @@ import Divider from '@/app/components/base/divider'
import ShareQRCode from '@/app/components/base/qrcode'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AccessControl } from '../app-access-control'
import AccessControl from '../app-access-control'
import CustomizeModal from './customize'
import EmbeddedModal from './embedded'
import SettingsModal from './settings'
@@ -19,7 +19,8 @@ import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Divider from '@/app/components/base/divider'
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { languages } from '@/i18n-config/language'
@@ -45,7 +46,6 @@ export type ConfigParams = {
copyright: string
privacy_policy: string
custom_disclaimer: string
input_placeholder: string
icon_type: AppIconType
icon: string
icon_background?: string
@@ -54,13 +54,6 @@ export type ConfigParams = {
enable_sso?: boolean
}
const INPUT_PLACEHOLDER_MAX_LENGTH = 64
const INPUT_PLACEHOLDER_SUPPORTED_MODES: ReadonlyArray<AppModeEnum> = [
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.ADVANCED_CHAT,
]
const prefixSettings = 'overview.appInfo.settings'
type SelectOption = {
value: Language
@@ -78,7 +71,6 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => {
copyright,
privacy_policy,
custom_disclaimer,
input_placeholder,
show_workflow_steps,
use_icon_as_answer_icon,
} = appInfo.site
@@ -92,7 +84,6 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => {
copyrightSwitchValue: !!copyright,
privacyPolicy: privacy_policy,
customDisclaimer: custom_disclaimer,
inputPlaceholder: input_placeholder ?? '',
show_workflow_steps,
use_icon_as_answer_icon,
enable_sso: appInfo.enable_sso,
@@ -117,7 +108,6 @@ const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) => JSON.st
appInfo.site.copyright,
appInfo.site.privacy_policy,
appInfo.site.custom_disclaimer,
appInfo.site.input_placeholder,
appInfo.site.default_language,
appInfo.site.icon_type,
appInfo.site.icon,
@@ -135,7 +125,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
onSave,
}) => {
const [isShowMore, setIsShowMore] = useState(false)
const [inputPlaceholderFocused, setInputPlaceholderFocused] = useState(false)
const { default_language } = appInfo.site
const nextInputInfo = createInputInfo(appInfo)
const nextAppIcon = createAppIcon(appInfo)
@@ -151,51 +140,10 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const [previousSettingsResetKey, setPreviousSettingsResetKey] = useState(settingsResetKey)
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
const { setShowPricingModal } = useModalContext()
const isCloudSandboxPlan = enableBilling && plan.type === Plan.sandbox
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const isFreePlan = plan.type === 'sandbox'
const showUpgradeAction = IS_CLOUD_EDITION && enableBilling && isFreePlan
const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language)
const inputPlaceholderLabelId = React.useId()
const inputPlaceholderDescriptionId = React.useId()
const inputPlaceholderValue = isCloudSandboxPlan ? '' : (inputInfo.inputPlaceholder ?? '')
const copyrightSwitchValue = isCloudSandboxPlan ? false : inputInfo.copyrightSwitchValue
// Editable + has value + blurred -> preview as gray placeholder text (matches how it'll render in chat).
const showInputPlaceholderPreview = !isCloudSandboxPlan && inputPlaceholderValue.trim().length > 0 && !inputPlaceholderFocused
const inputPlaceholderField = (
<div
className={cn(
'mt-2 flex h-10 items-center gap-2 rounded-lg border border-components-input-border-hover bg-components-input-bg-normal pr-1 pl-3 transition-colors',
!isCloudSandboxPlan && inputPlaceholderFocused && 'border-components-input-border-active bg-components-input-bg-active',
isCloudSandboxPlan && 'cursor-not-allowed opacity-60',
)}
>
<input
type="text"
name="input_placeholder"
value={inputPlaceholderValue}
onChange={e => setInputInfo(item => ({ ...item, inputPlaceholder: e.target.value }))}
onFocus={() => setInputPlaceholderFocused(true)}
onBlur={() => setInputPlaceholderFocused(false)}
disabled={isCloudSandboxPlan}
maxLength={INPUT_PLACEHOLDER_MAX_LENGTH}
autoComplete="off"
aria-labelledby={inputPlaceholderLabelId}
aria-describedby={inputPlaceholderDescriptionId}
placeholder={t(`${prefixSettings}.more.inputPlaceholderPlaceholder`, { ns: 'appOverview' }) as string}
className={cn(
'flex-1 bg-transparent body-md-regular outline-hidden',
showInputPlaceholderPreview ? 'text-text-placeholder' : 'text-text-primary',
isCloudSandboxPlan && 'cursor-not-allowed',
)}
/>
<span
aria-hidden="true"
className="grid h-7 w-7 shrink-0 cursor-not-allowed place-items-center rounded-md bg-components-button-primary-bg opacity-50"
>
<span className="i-custom-vender-solid-communication-send-03 h-4 w-4 text-components-button-primary-text" />
</span>
</div>
)
const handleLanguageChange = (nextValue: string | null) => {
const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue)
@@ -203,8 +151,11 @@ const SettingsModal: FC<ISettingsModalProps> = ({
setLanguage(nextLanguage.value)
}
const handlePlanClick = useCallback(() => {
setShowPricingModal()
}, [setShowPricingModal])
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
const shouldResetForm = isShow && (!previousIsShow || settingsResetKey !== previousSettingsResetKey)
if (isShow !== previousIsShow || shouldResetForm) {
@@ -264,16 +215,13 @@ const SettingsModal: FC<ISettingsModalProps> = ({
chat_color_theme: inputInfo.chatColorTheme,
chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
prompt_public: false,
copyright: (!webappCopyrightEnabled || isCloudSandboxPlan)
copyright: !webappCopyrightEnabled
? ''
: copyrightSwitchValue
: inputInfo.copyrightSwitchValue
? inputInfo.copyright
: '',
privacy_policy: inputInfo.privacyPolicy,
custom_disclaimer: inputInfo.customDisclaimer,
input_placeholder: (isCloudSandboxPlan || !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode))
? ''
: (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH),
icon_type: appIcon.type,
icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId,
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
@@ -425,7 +373,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* more settings switch */}
<Divider className="my-0 h-px" />
{!isShowMore && (
<button type="button" className="flex w-full cursor-pointer items-center text-left" onClick={() => setIsShowMore(true)}>
<div className="flex cursor-pointer items-center" onClick={() => setIsShowMore(true)}>
<div className="grow">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
@@ -437,56 +385,18 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</p>
</div>
<span aria-hidden="true" className="ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-secondary" />
</button>
</div>
)}
{/* more settings */}
{isShowMore && (
<>
{/* input placeholder — Chatbot / Agent / Chatflow only */}
{INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode) && (
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div id={inputPlaceholderLabelId} className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.inputPlaceholder`, { ns: 'appOverview' })}</div>
{isCloudSandboxPlan && (
<div className="h-[18px] select-none">
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
<span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-xs-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
</div>
</PremiumBadgeButton>
</div>
)}
</div>
</div>
<p id={inputPlaceholderDescriptionId} className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.more.inputPlaceholderTip`, { ns: 'appOverview' })}</p>
{isCloudSandboxPlan
? (
<Tooltip>
<TooltipTrigger render={inputPlaceholderField} />
<TooltipContent className="w-[180px]">
{t(`${prefixSettings}.more.inputPlaceholderTooltip`, { ns: 'appOverview' })}
</TooltipContent>
</Tooltip>
)
: inputPlaceholderField}
{!isCloudSandboxPlan && (
<div className="mt-1 text-right body-xs-regular text-text-tertiary">
{`${inputInfo.inputPlaceholder?.length ?? 0} / ${INPUT_PLACEHOLDER_MAX_LENGTH}`}
</div>
)}
</div>
)}
{/* copyright */}
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
{/* upgrade button */}
{isCloudSandboxPlan && (
{showUpgradeAction && (
<div className="h-[18px] select-none">
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
<span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
@@ -502,7 +412,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{webappCopyrightEnabled
? (
<Switch
checked={copyrightSwitchValue}
checked={inputInfo.copyrightSwitchValue}
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/>
)
@@ -513,7 +423,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div>
<Switch
disabled
checked={copyrightSwitchValue}
checked={inputInfo.copyrightSwitchValue}
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
/>
</div>
@@ -526,7 +436,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
)}
</div>
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
{copyrightSwitchValue && (
{inputInfo.copyrightSwitchValue && (
<Input
className="mt-2 h-10"
value={inputInfo.copyright}
+1 -1
View File
@@ -72,7 +72,7 @@ const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-mod
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
ssr: false,
})
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control').then(mod => mod.AccessControl), {
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
ssr: false,
})
@@ -119,17 +119,6 @@ describe('AgentLogModal', () => {
})
})
it('should render the floating modal through a dialog portal', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
const { container } = render(<AgentLogModal {...mockProps} floating />)
const modal = screen.getByRole('dialog')
expect(container).not.toContainElement(modal)
expect(document.body).toContainElement(modal)
expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!')
})
it('should call onCancel when close button is clicked', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
@@ -169,18 +158,4 @@ describe('AgentLogModal', () => {
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
it('should not use click-away to close the floating dialog', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
let clickAwayHandler!: (event: Event) => void
vi.mocked(useClickAway).mockImplementation((callback) => {
clickAwayHandler = callback
})
render(<AgentLogModal {...mockProps} floating />)
clickAwayHandler(new Event('click'))
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
@@ -11,13 +10,11 @@ import AgentLogDetail from './detail'
type AgentLogModalProps = Readonly<{
currentLogItem?: IChatItem
width: number
floating?: boolean
onCancel: () => void
}>
const AgentLogModal: FC<AgentLogModalProps> = ({
currentLogItem,
width,
floating,
onCancel,
}) => {
const { t } = useTranslation()
@@ -25,7 +22,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
const [mounted, setMounted] = useState(false)
useClickAway(() => {
if (mounted && !floating)
if (mounted)
onCancel()
}, ref)
@@ -36,44 +33,6 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
if (!currentLogItem || !currentLogItem.conversationId)
return null
const detailContent = (
<>
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
</>
)
if (floating) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropClassName="bg-transparent!"
className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! pb-3! shadow-xl!"
>
<DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</DialogTitle>
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onCancel}
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
{detailContent}
</DialogContent>
</Dialog>
)
}
return (
<div
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')}
@@ -95,7 +54,11 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
{detailContent}
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
</div>
)
}
@@ -7,7 +7,9 @@ import type {
} from '../types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
@@ -30,6 +32,7 @@ import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
import { useChatWithHistoryContext } from './context'
const ChatWrapper = () => {
const { t } = useTranslation()
const {
appParams,
appPrevChatTree,
@@ -174,7 +177,17 @@ const ChatWrapper = () => {
}
}, [])
const [hasSent, setHasSent] = useState(false)
const [prevConversationId, setPrevConversationId] = useState(currentConversationId)
if (prevConversationId !== currentConversationId) {
setPrevConversationId(currentConversationId)
if (!currentConversationId)
setHasSent(false)
}
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
if (!currentConversationId)
setHasSent(true)
const data: any = {
query: message,
files,
@@ -223,6 +236,60 @@ const ChatWrapper = () => {
}, [isInstalledApp])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
const [descExpanded, setDescExpanded] = useState(false)
const description = appData?.site.description
const [showDescToggle, setShowDescToggle] = useState(false)
const handleDescRef = useCallback((node: HTMLDivElement | null) => {
setShowDescToggle(!!node && node.scrollHeight > node.clientHeight)
}, [])
const descriptionNode = useMemo(() => {
if (!description || currentConversationId || hasSent)
return null
return (
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
<div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md">
<div className={cn('p-6', isMobile && 'p-4')}>
<div
ref={handleDescRef}
className={cn(
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
!descExpanded && 'line-clamp-3',
descExpanded && 'max-h-32 overflow-y-auto',
)}
>
{description}
{!descExpanded && showDescToggle && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
)}
</div>
{showDescToggle && (
<button
type="button"
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
onClick={() => setDescExpanded(v => !v)}
>
{descExpanded
? (
<>
<RiArrowUpSLine className="size-3" />
{t('chat.collapse', { ns: 'share' })}
</>
)
: (
<>
<RiArrowDownSLine className="size-3" />
{t('chat.expand', { ns: 'share' })}
</>
)}
</button>
)}
</div>
</div>
</div>
)
}, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t])
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
@@ -332,6 +399,7 @@ const ChatWrapper = () => {
onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{descriptionNode}
{chatNode}
{welcome}
</>
@@ -98,6 +98,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
app_id: id,
site: {
title: app.name,
description: app.description,
icon_type: app.icon_type,
icon: app.icon,
icon_background: app.icon_background,
@@ -23,7 +23,6 @@ import Operation from './operation'
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
customPlaceholder?: string
showFeatureBar?: boolean
showFileUpload?: boolean
featureBarReadonly?: boolean
@@ -45,7 +44,7 @@ type ChatInputAreaProps = {
*/
sendOnEnter?: boolean
}
const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const { t } = useTranslation()
const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight()
const [query, setQuery] = useState('')
@@ -148,7 +147,7 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
<div ref={textValueRef} className="pointer-events-none invisible absolute size-auto p-1 body-lg-regular leading-6 whitespace-pre">
{query}
</div>
<Textarea ref={ref => textareaRef.current = ref as any} className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')} placeholder={!readonly && customPlaceholder?.trim() ? customPlaceholder : decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} autoFocus minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onPaste={handleClipboardPasteFile} onDragEnter={handleDragFileEnter} onDragLeave={handleDragFileLeave} onDragOver={handleDragFileOver} onDrop={handleDropFile} readOnly={readonly} />
<Textarea ref={ref => textareaRef.current = ref as any} className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')} placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} autoFocus minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onPaste={handleClipboardPasteFile} onDragEnter={handleDragFileEnter} onDragLeave={handleDragFileLeave} onDragOver={handleDragFileOver} onDrop={handleDropFile} readOnly={readonly} />
</div>
{!isMultipleLine && operation}
</div>
@@ -241,7 +241,6 @@ const Chat: FC<ChatProps> = ({
!noChatInput && (
<ChatInputArea
botName={appData?.site?.title || 'Bot'}
customPlaceholder={appData?.site?.input_placeholder}
disabled={inputDisabled}
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
@@ -8,7 +8,9 @@ import type {
} from '../types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
@@ -32,6 +34,7 @@ import { useEmbeddedChatbotContext } from './context'
import { isDify } from './utils'
const ChatWrapper = () => {
const { t } = useTranslation()
const {
appData,
appParams,
@@ -177,7 +180,17 @@ const ChatWrapper = () => {
}
}, [])
const [hasSent, setHasSent] = useState(false)
const [prevConversationId, setPrevConversationId] = useState(currentConversationId)
if (prevConversationId !== currentConversationId) {
setPrevConversationId(currentConversationId)
if (!currentConversationId)
setHasSent(false)
}
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
if (!currentConversationId)
setHasSent(true)
const data: any = {
query: message,
files,
@@ -219,6 +232,75 @@ const ChatWrapper = () => {
const isTryApp = appSourceType === AppSourceType.tryApp
const [collapsed, setCollapsed] = useState(!!currentConversationId && !isTryApp) // try app always use the new chat
const [descExpanded, setDescExpanded] = useState(false)
const description = appData?.site.description
const [showDescToggle, setShowDescToggle] = useState(false)
const descRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = descRef.current
if (!el)
return
if (el.offsetWidth > 0) {
setShowDescToggle(el.scrollHeight > el.clientHeight)
return
}
const observer = new ResizeObserver((entries) => {
if (!entries[0] || entries[0].contentRect.width === 0)
return
setShowDescToggle(el.scrollHeight > el.clientHeight)
observer.disconnect()
})
observer.observe(el)
return () => observer.disconnect()
}, [description])
const descriptionNode = useMemo(() => {
if (!description || currentConversationId || hasSent)
return null
return (
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
<div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md">
<div className={cn('p-6', isMobile && 'p-4')}>
<div
ref={descRef}
className={cn(
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
!descExpanded && 'line-clamp-3',
descExpanded && 'max-h-32 overflow-y-auto',
)}
>
{description}
{!descExpanded && showDescToggle && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
)}
</div>
{showDescToggle && (
<button
type="button"
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
onClick={() => setDescExpanded(v => !v)}
>
{descExpanded
? (
<>
<RiArrowUpSLine className="size-3" />
{t('chat.collapse', { ns: 'share' })}
</>
)
: (
<>
<RiArrowDownSLine className="size-3" />
{t('chat.expand', { ns: 'share' })}
</>
)}
</button>
)}
</div>
</div>
</div>
)
}, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t])
const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length)
@@ -318,6 +400,7 @@ const ChatWrapper = () => {
onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{descriptionNode}
{chatNode}
{welcome}
</>
@@ -6,7 +6,6 @@ let fetching = false
let isManager = true
let enableBilling = true
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
let billingUrlEnabled = false
const refetchMock = vi.fn()
const openAsyncWindowMock = vi.fn()
@@ -20,14 +19,11 @@ type BillingWindowOptions = {
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
vi.mock('@/service/use-billing', () => ({
useBillingUrl: (enabled: boolean) => {
billingUrlEnabled = enabled
return {
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}
},
useBillingUrl: () => ({
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
@@ -58,32 +54,28 @@ describe('Billing', () => {
fetching = false
isManager = true
enableBilling = true
billingUrlEnabled = false
workspacePermissionKeys = ['billing.subscription.manage']
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('hides the billing action when subscription management permission is granted without manager role', () => {
it('shows the billing action when subscription management permission is granted without manager role', () => {
isManager = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
})
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
workspacePermissionKeys = []
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
vi.clearAllMocks()
workspacePermissionKeys = ['billing.subscription.manage']
enableBilling = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
})
it('opens the billing window with the immediate url when the button is clicked', async () => {

Some files were not shown because too many files have changed in this diff Show More