Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b4ce9029 | ||
|
|
7daa56f48b | ||
|
|
c48ee1a6b7 | ||
|
|
80d2412294 | ||
|
|
63d1c8288f | ||
|
|
be58e10a35 | ||
|
|
82e0191e05 | ||
|
|
d24e9d41e4 | ||
|
|
137eb90399 | ||
|
|
995e8a6ec4 | ||
|
|
92b5c195a5 | ||
|
|
37de12bf47 | ||
|
|
68e323b6dc | ||
|
|
387dec9169 | ||
|
|
eb4ec93cea | ||
|
|
fa6f4b0ea5 | ||
|
|
267b34caaf | ||
|
|
6ab1dd06ac | ||
|
|
113d6d7e00 | ||
|
|
a66b1de477 | ||
|
|
16b698b54d | ||
|
|
4cd8b8c733 | ||
|
|
677ab01806 | ||
|
|
f4e832f35c | ||
|
|
1c5e1280cb | ||
|
|
0035d90e36 | ||
|
|
2047e0dc12 | ||
|
|
2e9c0a3c7a | ||
|
|
a246dc8b17 | ||
|
|
bb921bcc45 | ||
|
|
4f4ac27de2 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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``.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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=(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
-17
@@ -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()
|
||||
})
|
||||
|
||||
+21
-31
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+35
-33
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user