Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e29f2c9c | ||
|
|
a70fcd315a | ||
|
|
f12602c046 | ||
|
|
d2e1f08026 | ||
|
|
c49b4e20bf | ||
|
|
85cc5d16be | ||
|
|
2c8869f688 | ||
|
|
3afd571743 | ||
|
|
ddc83de791 | ||
|
|
b59125f2f0 | ||
|
|
6292119848 | ||
|
|
d59c76bfdc | ||
|
|
4c591d51cb | ||
|
|
370fc1e81f |
@@ -12,7 +12,6 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
| 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. |
|
||||
@@ -24,7 +23,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
|
||||
- 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -33,8 +32,6 @@ 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.
|
||||
@@ -49,7 +46,6 @@ 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.
|
||||
@@ -64,10 +60,8 @@ 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.
|
||||
@@ -89,13 +83,11 @@ 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.
|
||||
@@ -104,7 +96,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 or the root folder becomes noisy. 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. 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,8 +53,6 @@ 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_member_roles_to_rbac
|
||||
from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_rbac
|
||||
from .retention import (
|
||||
archive_workflow_runs,
|
||||
archive_workflow_runs_plan,
|
||||
@@ -76,6 +76,7 @@ __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,6 +7,7 @@ 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 (
|
||||
@@ -177,3 +178,4 @@ def legacy_model_types(
|
||||
|
||||
|
||||
data_migrate.add_command(legacy_model_types)
|
||||
data_migrate.add_command(migrate_dataset_permissions_to_rbac)
|
||||
|
||||
+438
-66
@@ -1,11 +1,55 @@
|
||||
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 models import TenantAccountJoin, TenantAccountRole
|
||||
from services.enterprise.rbac_service import ListOption, RBACService
|
||||
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
|
||||
|
||||
|
||||
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
|
||||
@@ -15,26 +59,86 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
expected_builtin_tag = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}.get(legacy_role)
|
||||
if not expected_builtin_tag:
|
||||
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
roles = RBACService.Roles.list(
|
||||
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
|
||||
|
||||
|
||||
def _iter_tenant_member_batches(
|
||||
tenant_id: str | None,
|
||||
*,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
"""Yield legacy member roles in tenant-scoped API-sized batches.
|
||||
|
||||
Rows are projected to primitive values and streamed from the database, so
|
||||
the command never materializes every TenantAccountJoin ORM object. The
|
||||
iterator only keeps one tenant's API-sized batches in memory while it
|
||||
finds that tenant's owner account.
|
||||
"""
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
|
||||
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
.execution_options(yield_per=db_batch_size)
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
|
||||
current_tenant_id: str | None = None
|
||||
owner_account_id: str | None = None
|
||||
batches: list[list[tuple[str, str]]] = []
|
||||
batch: list[tuple[str, str]] = []
|
||||
|
||||
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
if current_tenant_id is None:
|
||||
return
|
||||
if batch:
|
||||
batches.append(batch.copy())
|
||||
if not owner_account_id:
|
||||
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
|
||||
for item in batches:
|
||||
yield current_tenant_id, owner_account_id, item
|
||||
|
||||
for row in session.execute(stmt):
|
||||
workspace_id = str(row.tenant_id)
|
||||
if current_tenant_id is not None and workspace_id != current_tenant_id:
|
||||
yield from flush_current_tenant()
|
||||
owner_account_id = None
|
||||
batches = []
|
||||
batch = []
|
||||
current_tenant_id = workspace_id
|
||||
account_id = str(row.account_id)
|
||||
role = str(row.role)
|
||||
if role == TenantAccountRole.OWNER.value:
|
||||
owner_account_id = account_id
|
||||
batch.append((account_id, role))
|
||||
if len(batch) >= api_batch_size:
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
|
||||
yield from flush_current_tenant()
|
||||
|
||||
|
||||
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
|
||||
return current_roles_by_account_id.get(account_id) == {role_id}
|
||||
|
||||
|
||||
def _replace_member_role(
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
member_account_id: str,
|
||||
role_id: str,
|
||||
) -> str:
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
for role in roles:
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag:
|
||||
return str(role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[role_id],
|
||||
)
|
||||
return member_account_id
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -42,7 +146,16 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate a single workspace.")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
|
||||
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
|
||||
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
|
||||
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
|
||||
def migrate_member_roles_to_rbac(
|
||||
tenant_id: str | None,
|
||||
dry_run: bool,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
workers: int,
|
||||
) -> None:
|
||||
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
|
||||
|
||||
This is an offline migration command for workspaces that already have
|
||||
@@ -50,63 +163,322 @@ def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
member-role binding store.
|
||||
"""
|
||||
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
|
||||
if workers < 1:
|
||||
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
tenant_count = 0
|
||||
scanned_count = 0
|
||||
skipped_count = 0
|
||||
migrated_count = 0
|
||||
current_tenant_id: str | None = None
|
||||
role_ids_by_legacy_role: dict[str, str] = {}
|
||||
|
||||
joins = list(session.scalars(stmt).all())
|
||||
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
|
||||
tenant_id,
|
||||
db_batch_size=db_batch_size,
|
||||
api_batch_size=api_batch_size,
|
||||
):
|
||||
scanned_count += len(batch)
|
||||
if workspace_id != current_tenant_id:
|
||||
tenant_count += 1
|
||||
current_tenant_id = workspace_id
|
||||
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
|
||||
click.echo(f"tenant={workspace_id}")
|
||||
|
||||
if not joins:
|
||||
current_roles_by_account_id: dict[str, set[str]] = {}
|
||||
if not dry_run:
|
||||
current_roles = RBACService.MemberRoles.batch_get(
|
||||
tenant_id=workspace_id,
|
||||
account_id=owner_account_id,
|
||||
member_account_ids=[account_id for account_id, _ in batch],
|
||||
)
|
||||
current_roles_by_account_id = {
|
||||
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
|
||||
}
|
||||
|
||||
replace_jobs: list[tuple[str, str]] = []
|
||||
for member_account_id, legacy_role in batch:
|
||||
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
|
||||
if not resolved_role_id:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
replace_jobs.append((member_account_id, resolved_role_id))
|
||||
|
||||
if replace_jobs:
|
||||
if workers == 1:
|
||||
for member_account_id, resolved_role_id in replace_jobs:
|
||||
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
|
||||
migrated_count += 1
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_replace_member_role,
|
||||
workspace_id,
|
||||
owner_account_id,
|
||||
member_account_id,
|
||||
resolved_role_id,
|
||||
)
|
||||
for member_account_id, resolved_role_id in replace_jobs
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
future.result()
|
||||
migrated_count += 1
|
||||
|
||||
if scanned_count % 10000 == 0:
|
||||
click.echo(
|
||||
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if scanned_count == 0:
|
||||
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
|
||||
return
|
||||
|
||||
owner_account_by_tenant: dict[str, str] = {}
|
||||
resolved_role_ids: dict[tuple[str, str], str] = {}
|
||||
migrated_count = 0
|
||||
|
||||
for join in joins:
|
||||
workspace_id = str(join.tenant_id)
|
||||
member_account_id = str(join.account_id)
|
||||
legacy_role = str(join.role)
|
||||
|
||||
if workspace_id not in owner_account_by_tenant:
|
||||
owner_join = next(
|
||||
(
|
||||
item
|
||||
for item in joins
|
||||
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not owner_join:
|
||||
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
|
||||
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
|
||||
|
||||
operator_account_id = owner_account_by_tenant[workspace_id]
|
||||
cache_key = (workspace_id, legacy_role)
|
||||
if cache_key not in resolved_role_ids:
|
||||
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
|
||||
|
||||
resolved_role_id = resolved_role_ids[cache_key]
|
||||
if dry_run:
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
|
||||
"No RBAC bindings were written.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
|
||||
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=workspace_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[resolved_role_id],
|
||||
)
|
||||
migrated_count += 1
|
||||
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
|
||||
|
||||
if dry_run:
|
||||
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} datasets; "
|
||||
f"{partial_dataset_count} partial-member datasets would be migrated.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -34,6 +34,12 @@ 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,16 +167,12 @@ 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,7 +85,6 @@ 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
|
||||
@@ -124,7 +123,6 @@ 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,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
@@ -14,6 +13,7 @@ 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,14 +511,8 @@ class RBACAccessPolicyBindingUnlockApi(Resource):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _AccessScope(StrEnum):
|
||||
ALL = "all"
|
||||
SPECIFIC = "specific"
|
||||
ONLY_ME = "only_me"
|
||||
|
||||
|
||||
class _ResourceAccessScopeRequest(BaseModel):
|
||||
scope: _AccessScope
|
||||
scope: RBACResourceWhitelistScope
|
||||
|
||||
|
||||
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, SimpleResultResponse
|
||||
from controllers.common.fields import EventStreamResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.openapi._models import (
|
||||
AccountPayload,
|
||||
@@ -95,7 +95,6 @@ register_response_schema_models(
|
||||
openapi_ns,
|
||||
ErrorBody,
|
||||
EventStreamResponse,
|
||||
SimpleResultResponse,
|
||||
UsageInfo,
|
||||
MessageMetadata,
|
||||
AppListRow,
|
||||
|
||||
@@ -34,7 +34,6 @@ class OpenApiErrorCode(StrEnum):
|
||||
# transport-generic (resolved from HTTP status for plain werkzeug raises)
|
||||
BAD_REQUEST = "bad_request"
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
TOKEN_EXPIRED = "token_expired"
|
||||
FORBIDDEN = "forbidden"
|
||||
NOT_FOUND = "not_found"
|
||||
METHOD_NOT_ALLOWED = "method_not_allowed"
|
||||
@@ -224,19 +223,6 @@ class OpenApiErrorFormatter:
|
||||
return isinstance(part, (str, int)) and not isinstance(part, bool)
|
||||
|
||||
|
||||
class InvalidBearer(OpenApiError): # noqa: N818
|
||||
code = 401
|
||||
error_code = OpenApiErrorCode.UNAUTHORIZED
|
||||
description = "Invalid or unknown bearer token."
|
||||
|
||||
|
||||
class SessionExpired(OpenApiError): # noqa: N818
|
||||
code = 401
|
||||
error_code = OpenApiErrorCode.TOKEN_EXPIRED
|
||||
description = "Your session has expired."
|
||||
hint = "Re-authenticate to continue (e.g. re-run your login command)."
|
||||
|
||||
|
||||
class FilenameNotExists(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Generator
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -61,7 +61,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _translate_service_errors() -> Generator[None, None, None]:
|
||||
def _translate_service_errors() -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except WorkflowNotFoundError as ex:
|
||||
@@ -166,7 +166,6 @@ class AppRunApi(Resource):
|
||||
surface="apps",
|
||||
)
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(stream_obj)
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,12 @@ from controllers.openapi._models import (
|
||||
AppListRow,
|
||||
)
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, 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
|
||||
@@ -167,9 +166,7 @@ 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 != CallerKind.END_USER
|
||||
and auth_data.account_id is not None
|
||||
dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None
|
||||
)
|
||||
access_filter = AppAccessFilter.unrestricted()
|
||||
if apply_rbac_filter:
|
||||
@@ -206,7 +203,7 @@ class AppListApi(Resource):
|
||||
limit=query.limit,
|
||||
mode=query.mode.value if query.mode else "all", # type:ignore
|
||||
name=query.name,
|
||||
status=AppStatus.NORMAL,
|
||||
status="normal",
|
||||
# Visibility gate pushed into the query — pagination.total stays
|
||||
# consistent across pages because invisible rows never count.
|
||||
openapi_visible=True,
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -63,7 +62,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 != AppStatus.NORMAL:
|
||||
if not app or app.status != "normal":
|
||||
continue
|
||||
tenant = tenants_by_id.get(str(app.tenant_id))
|
||||
items.append(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -20,11 +21,6 @@ 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
|
||||
@@ -82,9 +78,9 @@ class AuthData(BaseModel):
|
||||
tenant_role: TenantAccountRole | None = None
|
||||
|
||||
caller: Account | EndUser | None = None
|
||||
caller_kind: CallerKind | None = None
|
||||
caller_kind: Literal["account", "end_user"] | None = None
|
||||
|
||||
def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]:
|
||||
def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]:
|
||||
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
|
||||
|
||||
@@ -17,7 +17,6 @@ from flask_login import user_logged_in
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi._audit import emit_wrong_surface
|
||||
from controllers.openapi._errors import InvalidBearer, SessionExpired
|
||||
from controllers.openapi.auth.data import (
|
||||
AuthData,
|
||||
Edition,
|
||||
@@ -29,9 +28,7 @@ from controllers.openapi.auth.data import (
|
||||
from controllers.openapi.auth.flow import When
|
||||
from libs.oauth_bearer import (
|
||||
AuthContext,
|
||||
InvalidBearerError,
|
||||
Scope,
|
||||
TokenExpiredError,
|
||||
TokenType,
|
||||
extract_bearer,
|
||||
get_authenticator,
|
||||
@@ -220,12 +217,7 @@ class PipelineRouter:
|
||||
if not token:
|
||||
raise Unauthorized("bearer required")
|
||||
|
||||
try:
|
||||
identity = get_authenticator().authenticate(token)
|
||||
except TokenExpiredError:
|
||||
raise SessionExpired()
|
||||
except InvalidBearerError:
|
||||
raise InvalidBearer()
|
||||
identity = get_authenticator().authenticate(token)
|
||||
|
||||
if allowed_token_types is not None and identity.token_type not in allowed_token_types:
|
||||
emit_wrong_surface(
|
||||
|
||||
@@ -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, CallerKind
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from extensions.ext_database import db
|
||||
from models.account import AccountStatus, TenantStatus
|
||||
from models.enums import AppStatus, EndUserType
|
||||
from models.account import TenantStatus
|
||||
from models.enums import 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 != AppStatus.NORMAL:
|
||||
if not app or app.status != "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 = CallerKind.ACCOUNT
|
||||
data.caller_kind = "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) != AccountStatus.ACTIVE:
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != "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 = CallerKind.END_USER
|
||||
data.caller_kind = "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, CallerKind
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
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 != CallerKind.ACCOUNT:
|
||||
if data.caller_kind != "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, CallerKind, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, 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 == CallerKind.ACCOUNT:
|
||||
if caller_kind == "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, CallerKind, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, 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 == CallerKind.ACCOUNT:
|
||||
if caller_kind == "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
|
||||
from core.rbac.entities import RBACPermission, RBACResourceScope, RBACResourceWhitelistScope
|
||||
|
||||
__all__ = ["RBACPermission", "RBACResourceScope"]
|
||||
__all__ = ["RBACPermission", "RBACResourceScope", "RBACResourceWhitelistScope"]
|
||||
|
||||
@@ -13,6 +13,14 @@ 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, ...)``,
|
||||
``Model(...).model_dump()``, or ``Model.model_validate(...).model_dump()`` return.
|
||||
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``
|
||||
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,7 +28,6 @@ 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[
|
||||
@@ -42,7 +41,6 @@ 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})
|
||||
@@ -111,22 +109,18 @@ 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, source: ModelValueSource) -> None:
|
||||
def add_known(self, model: str) -> 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) -> tuple[str, ModelValueSource] | None:
|
||||
def single_known_model(self) -> str | None:
|
||||
if self.has_unknown_assignment or len(self.known_models) != 1:
|
||||
return None
|
||||
model = next(iter(self.known_models))
|
||||
source: ModelValueSource = "constructor" if self.known_sources == {"constructor"} else "model_validate"
|
||||
return model, source
|
||||
return next(iter(self.known_models))
|
||||
|
||||
|
||||
def dotted_name(node: ast.AST) -> str | None:
|
||||
@@ -255,12 +249,6 @@ 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
|
||||
@@ -269,12 +257,6 @@ 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
|
||||
@@ -290,10 +272,6 @@ 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
|
||||
@@ -309,7 +287,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, tuple[str, ModelValueSource]] | None = None
|
||||
expr: ast.AST | None, variable_models: dict[str, str] | None = None
|
||||
) -> tuple[ActualKind, str | None]:
|
||||
if expr is None:
|
||||
return "none", None
|
||||
@@ -321,14 +299,10 @@ 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:
|
||||
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.
|
||||
# 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:
|
||||
return "model_dump_variable", model_name
|
||||
|
||||
model_dump_model = model_name_from_model_dump(expr)
|
||||
@@ -351,9 +325,7 @@ def actual_kind_from_expr(
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def actual_response_from_return(
|
||||
return_node: ast.Return, variable_models: dict[str, tuple[str, ModelValueSource]]
|
||||
) -> ActualResponse:
|
||||
def actual_response_from_return(return_node: ast.Return, variable_models: dict[str, str]) -> ActualResponse:
|
||||
status: int | None = 200
|
||||
body_expr = return_node.value
|
||||
|
||||
@@ -391,21 +363,18 @@ def target_names(target: ast.AST) -> Iterable[str]:
|
||||
|
||||
|
||||
def record_assignment(
|
||||
assignments: defaultdict[str, VariableAssignmentSummary],
|
||||
targets: Iterable[str],
|
||||
model_assignment: tuple[str, ModelValueSource] | None,
|
||||
assignments: defaultdict[str, VariableAssignmentSummary], targets: Iterable[str], model_name: str | None
|
||||
) -> None:
|
||||
for target in targets:
|
||||
if model_assignment is None:
|
||||
if model_name 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:
|
||||
model_name, source = model_assignment
|
||||
assignments[target].add_known(model_name, source)
|
||||
assignments[target].add_known(model_name)
|
||||
|
||||
|
||||
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple[str, ModelValueSource]]:
|
||||
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
|
||||
"""Infer local variables that are unambiguously assigned one response model."""
|
||||
|
||||
assignments: defaultdict[str, VariableAssignmentSummary] = defaultdict(VariableAssignmentSummary)
|
||||
@@ -416,10 +385,10 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple
|
||||
record_assignment(
|
||||
assignments,
|
||||
(name for target in targets for name in target_names(target)),
|
||||
model_value_from_model_value(value),
|
||||
model_name_from_model_value(value),
|
||||
)
|
||||
case ast.AnnAssign(target=target, value=value) if value is not None:
|
||||
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
|
||||
record_assignment(assignments, target_names(target), model_name_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)
|
||||
@@ -430,13 +399,9 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple
|
||||
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_value_from_model_value(value))
|
||||
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
|
||||
|
||||
return {
|
||||
name: assignment
|
||||
for name, summary in assignments.items()
|
||||
if (assignment := summary.single_known_model()) is not None
|
||||
}
|
||||
return {name: model for name, summary in assignments.items() if (model := summary.single_known_model()) is not None}
|
||||
|
||||
|
||||
def actual_responses_for_method(method: MethodNode) -> list[ActualResponse]:
|
||||
@@ -580,52 +545,13 @@ 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]:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
lines = source.splitlines()
|
||||
module = ast.parse(source, filename=str(file_path))
|
||||
module = ast.parse(file_path.read_text(encoding="utf-8"), 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)
|
||||
@@ -633,8 +559,6 @@ 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,6 +27,7 @@ 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,
|
||||
@@ -56,6 +57,7 @@ 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,
|
||||
|
||||
+1
-2
@@ -7,8 +7,7 @@ class ResponseModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="ignore",
|
||||
validate_by_name=True,
|
||||
validate_by_alias=True,
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
+10
-26
@@ -236,16 +236,6 @@ class TokenExpiredError(Exception):
|
||||
"""Hard-expire bookkeeping is the resolver's job before raising."""
|
||||
|
||||
|
||||
class NegativeCache(StrEnum):
|
||||
"""Negative cache markers. ``EXPIRED`` is distinct from ``INVALID`` so a
|
||||
retry inside ``NEGATIVE_TTL`` still reports expiry instead of collapsing
|
||||
into a generic unknown-token miss.
|
||||
"""
|
||||
|
||||
INVALID = "invalid"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Registry
|
||||
# ============================================================================
|
||||
@@ -353,15 +343,13 @@ class OAuthAccessTokenResolver:
|
||||
def _cache_key(self, token_hash: str) -> str:
|
||||
return TOKEN_CACHE_KEY_FMT.format(hash=token_hash)
|
||||
|
||||
def cache_get(self, token_hash: str) -> ResolvedRow | None | NegativeCache:
|
||||
def cache_get(self, token_hash: str) -> ResolvedRow | None | Literal["invalid"]:
|
||||
raw = self._redis.get(self._cache_key(token_hash))
|
||||
if raw is None:
|
||||
return None
|
||||
text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
|
||||
try:
|
||||
return NegativeCache(text)
|
||||
except ValueError:
|
||||
pass
|
||||
if text == "invalid":
|
||||
return "invalid"
|
||||
try:
|
||||
return ResolvedRow.from_cache(json.loads(text))
|
||||
except (ValueError, KeyError):
|
||||
@@ -375,8 +363,8 @@ class OAuthAccessTokenResolver:
|
||||
json.dumps(row.to_cache()),
|
||||
)
|
||||
|
||||
def cache_set_negative(self, token_hash: str, marker: NegativeCache = NegativeCache.INVALID) -> None:
|
||||
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, str(marker))
|
||||
def cache_set_negative(self, token_hash: str) -> None:
|
||||
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, "invalid")
|
||||
|
||||
def hard_expire(self, session: Session, row_id: uuid.UUID | str, token_hash: str) -> None:
|
||||
"""Atomic CAS — only the worker that flips revoked_at emits audit;
|
||||
@@ -397,7 +385,7 @@ class OAuthAccessTokenResolver:
|
||||
extra={"audit": True, "token_id": str(row_id)},
|
||||
)
|
||||
self._redis.delete(self._cache_key(token_hash))
|
||||
self.cache_set_negative(token_hash, NegativeCache.EXPIRED)
|
||||
self.cache_set_negative(token_hash)
|
||||
|
||||
|
||||
class _VariantResolver:
|
||||
@@ -407,11 +395,9 @@ class _VariantResolver:
|
||||
|
||||
def resolve(self, token_hash: str) -> ResolvedRow | None:
|
||||
cached = self._parent.cache_get(token_hash)
|
||||
if isinstance(cached, NegativeCache):
|
||||
if cached is NegativeCache.EXPIRED:
|
||||
raise TokenExpiredError("token_expired")
|
||||
if cached == "invalid":
|
||||
return None
|
||||
if cached is not None:
|
||||
if cached is not None and not isinstance(cached, str):
|
||||
if not self._matches_variant(cached):
|
||||
return None
|
||||
return cached
|
||||
@@ -427,7 +413,7 @@ class _VariantResolver:
|
||||
now = datetime.now(UTC)
|
||||
if row.expires_at is not None and row.expires_at <= now:
|
||||
self._parent.hard_expire(session, row.id, token_hash)
|
||||
raise TokenExpiredError("token_expired")
|
||||
return None
|
||||
|
||||
if not self._matches_variant_model(row):
|
||||
logger.error(
|
||||
@@ -486,7 +472,7 @@ def record_layer0_verdict(token_hash: str, tenant_id: str, verdict: bool) -> Non
|
||||
if raw is None:
|
||||
return
|
||||
text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
|
||||
if text in (NegativeCache.INVALID, NegativeCache.EXPIRED):
|
||||
if text == "invalid":
|
||||
return
|
||||
try:
|
||||
data = json.loads(text)
|
||||
@@ -615,8 +601,6 @@ def validate_bearer(*, accept: frozenset[Accepts]) -> Callable[[Callable[_DP, _D
|
||||
|
||||
try:
|
||||
ctx = get_authenticator().authenticate(token)
|
||||
except TokenExpiredError:
|
||||
raise Unauthorized("token_expired")
|
||||
except InvalidBearerError as e:
|
||||
raise Unauthorized(str(e))
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARN ", "WARNING ")
|
||||
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARNING ")
|
||||
_LOCATION_PREFIX = "-->"
|
||||
|
||||
|
||||
@@ -14,7 +13,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 ...`` / ``WARN ...`` / ``WARNING ...``)
|
||||
- diagnostic headline lines (``ERROR ...`` / ``WARNING ...``)
|
||||
- the following location line (``--> path:line:column``), when present
|
||||
"""
|
||||
|
||||
@@ -37,28 +36,11 @@ 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(render_diagnostics(raw_output, exit_code=args.status))
|
||||
sys.stdout.write(extract_diagnostics(raw_output))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -13432,6 +13432,7 @@ 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 |
|
||||
@@ -14542,8 +14543,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
|
||||
|
||||
@@ -16712,7 +16713,6 @@ 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,7 +17082,6 @@ 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 |
|
||||
@@ -17096,11 +17095,12 @@ 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,12 +990,6 @@ 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,6 +12,7 @@ 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
|
||||
|
||||
@@ -435,6 +436,7 @@ _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",
|
||||
]
|
||||
|
||||
@@ -649,17 +651,18 @@ class ReplaceRoleBindings(_RBACModel):
|
||||
|
||||
|
||||
class ReplaceMemberBindings(_RBACModel):
|
||||
scope: str = "specific"
|
||||
scope: RBACResourceWhitelistScope = RBACResourceWhitelistScope.SPECIFIC
|
||||
|
||||
@field_validator("scope")
|
||||
@classmethod
|
||||
def _normalize_scope(cls, value: Any) -> str:
|
||||
def _normalize_scope(cls, value: Any) -> RBACResourceWhitelistScope:
|
||||
scope = str(value or "").strip().lower()
|
||||
if scope in {"", "specific"}:
|
||||
return "specific"
|
||||
if scope in {"all", "only_me"}:
|
||||
return scope
|
||||
raise ValueError(f"invalid scope: {value}")
|
||||
if scope == "":
|
||||
return RBACResourceWhitelistScope.SPECIFIC
|
||||
try:
|
||||
return RBACResourceWhitelistScope(scope)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid scope: {value}") from exc
|
||||
|
||||
|
||||
class DeleteMemberBindings(_RBACModel):
|
||||
@@ -743,6 +746,7 @@ def _inner_call(
|
||||
account_id=account_id,
|
||||
json=json,
|
||||
params=params,
|
||||
timeout=dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ def mint_token(flask_app: Flask):
|
||||
prefix: str,
|
||||
subject_email: str,
|
||||
subject_issuer: str | None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> OAuthAccessToken:
|
||||
with flask_app.app_context():
|
||||
row = OAuthAccessToken(
|
||||
@@ -83,7 +82,7 @@ def mint_token(flask_app: Flask):
|
||||
subject_issuer=subject_issuer,
|
||||
client_id="difyctl",
|
||||
device_label="test-device",
|
||||
expires_at=expires_at or (datetime.now(UTC) + timedelta(hours=1)),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
@@ -112,21 +111,6 @@ def account_token(workspace_account, mint_token) -> str:
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_account_token(workspace_account, mint_token) -> str:
|
||||
account, _, _ = workspace_account
|
||||
token = "dfoa_" + uuid.uuid4().hex
|
||||
mint_token(
|
||||
token,
|
||||
account_id=account.id,
|
||||
prefix="dfoa_",
|
||||
subject_email=account.email,
|
||||
subject_issuer="dify:account",
|
||||
expires_at=datetime.now(UTC) - timedelta(minutes=1),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _flush_auth_redis(flask_app: Flask) -> Generator[None, None, None]:
|
||||
def _flush():
|
||||
|
||||
@@ -6,7 +6,6 @@ acceptance/rejection on app-scoped routes.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
@@ -17,50 +16,6 @@ from extensions.ext_database import db
|
||||
from models import App, Tenant
|
||||
|
||||
|
||||
def test_expired_token_returns_401_token_expired(
|
||||
test_client: FlaskClient,
|
||||
expired_account_token: str,
|
||||
) -> None:
|
||||
"""An expired bearer is distinguishable from an unknown one: 401 with the
|
||||
domain code ``token_expired`` (+ actionable hint), not a generic 401 or 500."""
|
||||
res = test_client.get(
|
||||
"/openapi/v1/account",
|
||||
headers={"Authorization": f"Bearer {expired_account_token}"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
assert res.json["code"] == "token_expired"
|
||||
assert res.json["hint"]
|
||||
|
||||
|
||||
def test_expired_token_replay_stays_token_expired(
|
||||
test_client: FlaskClient,
|
||||
expired_account_token: str,
|
||||
) -> None:
|
||||
"""The distinct ``expired`` negative-cache marker keeps the second hit (served
|
||||
from cache, inside NEGATIVE_TTL) reporting ``token_expired`` rather than
|
||||
collapsing into a generic unknown-token 401."""
|
||||
headers = {"Authorization": f"Bearer {expired_account_token}"}
|
||||
first = test_client.get("/openapi/v1/account", headers=headers)
|
||||
second = test_client.get("/openapi/v1/account", headers=headers)
|
||||
assert first.json["code"] == "token_expired"
|
||||
assert second.status_code == 401
|
||||
assert second.json["code"] == "token_expired"
|
||||
|
||||
|
||||
def test_unknown_token_returns_401_unauthorized_not_500(
|
||||
test_client: FlaskClient,
|
||||
workspace_account,
|
||||
) -> None:
|
||||
"""An unknown bearer is a clean 401 ``unauthorized`` — not the latent 500 the
|
||||
pipeline used to leak for unmapped InvalidBearerError."""
|
||||
res = test_client.get(
|
||||
"/openapi/v1/account",
|
||||
headers={"Authorization": "Bearer dfoa_" + uuid.uuid4().hex},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
assert res.json["code"] == "unauthorized"
|
||||
|
||||
|
||||
def test_info_accepts_account_bearer_with_apps_read_scope(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
|
||||
@@ -27,6 +27,7 @@ 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,7 +3,6 @@ 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)
|
||||
@@ -13,7 +12,6 @@ Covers:
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -22,10 +20,8 @@ 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[TrackedRow]) -> None:
|
||||
def _cleanup_tracked_rows(db_session: Session, tracked: list) -> 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
|
||||
@@ -56,7 +52,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[TrackedRow]
|
||||
_tracked: list
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_cleanup(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
@@ -88,22 +84,6 @@ 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."""
|
||||
|
||||
@@ -196,7 +176,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) -> None:
|
||||
def test_get_by_openid_returns_none_when_no_integrate_exists(self, db_session_with_containers: Session) -> None:
|
||||
"""get_by_openid returns None when no AccountIntegrate row matches."""
|
||||
result = Account.get_by_openid("github", f"github_{uuid4()}")
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ 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,6 +336,174 @@ 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,25 +77,6 @@ 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,
|
||||
@@ -136,38 +117,6 @@ 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,17 +22,23 @@ class TestSpecSchemaDefinitionsApi:
|
||||
assert status == 200
|
||||
assert resp == schema_definitions
|
||||
|
||||
def test_get_exception_returns_empty_list(self, caplog):
|
||||
def test_get_exception_returns_empty_list(self):
|
||||
api = spec_module.SpecSchemaDefinitionsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with patch.object(
|
||||
spec_module,
|
||||
"SchemaManager",
|
||||
side_effect=Exception("boom"),
|
||||
with (
|
||||
patch.object(
|
||||
spec_module,
|
||||
"SchemaManager",
|
||||
side_effect=Exception("boom"),
|
||||
),
|
||||
patch.object(
|
||||
spec_module.logger,
|
||||
"exception",
|
||||
) as log_exception,
|
||||
):
|
||||
resp, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert resp == []
|
||||
assert "boom" in caplog.text
|
||||
log_exception.assert_called_once()
|
||||
|
||||
@@ -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._AccessScope.SPECIFIC
|
||||
assert parsed.scope is rbac_mod.RBACResourceWhitelistScope.SPECIFIC
|
||||
|
||||
def test_resource_access_scope_coerce_null_account_ids(self):
|
||||
rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "all"})
|
||||
|
||||
@@ -321,56 +321,3 @@ def test_guard_no_external_identity_when_subject_email_absent(app):
|
||||
view()
|
||||
|
||||
assert received["data"].external_identity is None
|
||||
|
||||
|
||||
# --- auth-failure mapping (no raw 500 leak) ---
|
||||
|
||||
|
||||
def test_guard_expired_token_raises_session_expired_401(app):
|
||||
from controllers.openapi._errors import OpenApiErrorCode, SessionExpired
|
||||
from libs.oauth_bearer import TokenExpiredError
|
||||
|
||||
router = _make_router()
|
||||
|
||||
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
|
||||
with (
|
||||
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
|
||||
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
|
||||
patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE),
|
||||
):
|
||||
mock_auth.return_value.authenticate.side_effect = TokenExpiredError("token_expired")
|
||||
|
||||
@router.guard(scope=Scope.FULL)
|
||||
def view(*, auth_data):
|
||||
pass
|
||||
|
||||
with pytest.raises(SessionExpired) as exc:
|
||||
view()
|
||||
|
||||
assert exc.value.code == 401
|
||||
assert exc.value.error_code == OpenApiErrorCode.TOKEN_EXPIRED
|
||||
|
||||
|
||||
def test_guard_invalid_token_raises_unified_401_not_500(app):
|
||||
from controllers.openapi._errors import InvalidBearer, OpenApiErrorCode
|
||||
from libs.oauth_bearer import InvalidBearerError
|
||||
|
||||
router = _make_router()
|
||||
|
||||
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
|
||||
with (
|
||||
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
|
||||
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
|
||||
patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE),
|
||||
):
|
||||
mock_auth.return_value.authenticate.side_effect = InvalidBearerError("invalid_bearer")
|
||||
|
||||
@router.guard(scope=Scope.FULL)
|
||||
def view(*, auth_data):
|
||||
pass
|
||||
|
||||
with pytest.raises(InvalidBearer) as exc:
|
||||
view()
|
||||
|
||||
assert exc.value.code == 401
|
||||
assert exc.value.error_code == OpenApiErrorCode.UNAUTHORIZED
|
||||
|
||||
@@ -3,36 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
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="caller@example.com")
|
||||
account.id = _TEST_ACCOUNT_ID
|
||||
return account
|
||||
|
||||
|
||||
def test_app_run_request_has_no_response_mode_field():
|
||||
@@ -63,19 +40,15 @@ 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",
|
||||
GenerateService,
|
||||
SimpleNamespace(generate=generate_mock),
|
||||
)
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"):
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/run", method="POST"):
|
||||
_run_chat(
|
||||
_make_app(),
|
||||
_make_account(),
|
||||
SimpleNamespace(id="app-1", tenant_id="t-1"),
|
||||
SimpleNamespace(id="acct-1"),
|
||||
AppRunRequest(inputs={}, query="hello"),
|
||||
)
|
||||
_, kwargs = generate_mock.call_args
|
||||
@@ -107,11 +80,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.UUID(_TEST_ACCOUNT_ID),
|
||||
account_id=uuid.uuid4(),
|
||||
token_hash="test",
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
app=_make_app(),
|
||||
caller=_make_account(),
|
||||
app=SimpleNamespace(id="app-1", tenant_id="t-1"),
|
||||
caller=SimpleNamespace(id="acct-1"),
|
||||
caller_kind="account",
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -101,7 +100,7 @@ def test_accepts_validation_error_is_sanitized_and_structured(app):
|
||||
with pytest.raises(UnprocessableEntity) as exc_info:
|
||||
view()
|
||||
|
||||
data = cast(dict[str, Any], cast(Any, exc_info.value).data)
|
||||
data = exc_info.value.data
|
||||
assert data["message"] == "Request validation failed"
|
||||
assert isinstance(data["errors"], list)
|
||||
assert data["errors"]
|
||||
|
||||
@@ -33,7 +33,6 @@ from controllers.openapi._errors import (
|
||||
OpenApiErrorCode,
|
||||
OpenApiErrorFormatter,
|
||||
RecipientSurfaceMismatch,
|
||||
SessionExpired,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
@@ -354,20 +353,3 @@ class TestErrorCodeEnumRegistration:
|
||||
schema = model.__schema__
|
||||
assert schema["type"] == "string"
|
||||
assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode}
|
||||
|
||||
|
||||
class TestSessionExpired:
|
||||
def test_session_expired_emits_token_expired_401_with_hint(self):
|
||||
fmt = OpenApiErrorFormatter()
|
||||
e = SessionExpired()
|
||||
data = {"code": "unauthorized", "message": e.description, "status": 401}
|
||||
|
||||
wire = fmt.finalize(e, data, 401)
|
||||
|
||||
assert wire["code"] == OpenApiErrorCode.TOKEN_EXPIRED
|
||||
assert wire["status"] == 401
|
||||
assert wire["hint"]
|
||||
|
||||
def test_session_expired_code_is_401(self):
|
||||
assert SessionExpired.code == 401
|
||||
assert SessionExpired.error_code == OpenApiErrorCode.TOKEN_EXPIRED
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Resolver-level expiry signalling.
|
||||
|
||||
An expired token must be distinguishable from an unknown/revoked one: the
|
||||
resolver raises ``TokenExpiredError`` for expiry and returns ``None`` for
|
||||
everything else. The signal survives the negative-cache window via a distinct
|
||||
``expired`` marker so a retry inside ``NEGATIVE_TTL`` still reports expiry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.oauth_bearer import (
|
||||
OAuthAccessTokenResolver,
|
||||
TokenExpiredError,
|
||||
)
|
||||
|
||||
|
||||
def _row(expires_at: datetime):
|
||||
row = MagicMock()
|
||||
row.id = "11111111-1111-1111-1111-111111111111"
|
||||
row.account_id = "22222222-2222-2222-2222-222222222222"
|
||||
row.prefix = "dfoa_"
|
||||
row.subject_email = None
|
||||
row.subject_issuer = None
|
||||
row.client_id = None
|
||||
row.expires_at = expires_at
|
||||
return row
|
||||
|
||||
|
||||
def _resolver(redis: MagicMock, db_row=None) -> OAuthAccessTokenResolver:
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.one_or_none.return_value = db_row
|
||||
session.execute.return_value.rowcount = 1
|
||||
return OAuthAccessTokenResolver(session_factory=lambda: session, redis_client=redis)
|
||||
|
||||
|
||||
def test_resolve_raises_token_expired_for_expired_db_row():
|
||||
redis = MagicMock()
|
||||
redis.get.return_value = None # cache miss -> DB path
|
||||
past = datetime.now(UTC) - timedelta(minutes=1)
|
||||
resolver = _resolver(redis, db_row=_row(past))
|
||||
|
||||
with pytest.raises(TokenExpiredError):
|
||||
resolver.for_account().resolve("expiredhash")
|
||||
|
||||
|
||||
def test_resolve_raises_token_expired_for_expired_cache_marker():
|
||||
redis = MagicMock()
|
||||
redis.get.return_value = b"expired" # negative-cache replay
|
||||
resolver = _resolver(redis, db_row=None)
|
||||
|
||||
with pytest.raises(TokenExpiredError):
|
||||
resolver.for_account().resolve("expiredhash")
|
||||
|
||||
|
||||
def test_resolve_returns_none_for_invalid_cache_marker():
|
||||
redis = MagicMock()
|
||||
redis.get.return_value = b"invalid"
|
||||
resolver = _resolver(redis, db_row=None)
|
||||
|
||||
assert resolver.for_account().resolve("revokedhash") is None
|
||||
|
||||
|
||||
def test_resolve_returns_none_for_unknown_token():
|
||||
redis = MagicMock()
|
||||
redis.get.return_value = None # cache miss
|
||||
resolver = _resolver(redis, db_row=None) # no DB row
|
||||
|
||||
assert resolver.for_account().resolve("unknownhash") is None
|
||||
|
||||
|
||||
def test_hard_expire_caches_expired_marker_not_invalid():
|
||||
redis = MagicMock()
|
||||
redis.get.return_value = None
|
||||
past = datetime.now(UTC) - timedelta(minutes=1)
|
||||
resolver = _resolver(redis, db_row=_row(past))
|
||||
|
||||
with pytest.raises(TokenExpiredError):
|
||||
resolver.for_account().resolve("expiredhash")
|
||||
|
||||
setex_values = [call.args[2] for call in redis.setex.call_args_list]
|
||||
assert "expired" in setex_values
|
||||
assert "invalid" not in setex_values
|
||||
@@ -1,4 +1,4 @@
|
||||
from libs.pyrefly_diagnostics import extract_diagnostics, render_diagnostics
|
||||
from libs.pyrefly_diagnostics import extract_diagnostics
|
||||
|
||||
|
||||
def test_extract_diagnostics_keeps_only_summary_and_location_lines() -> None:
|
||||
@@ -40,37 +40,6 @@ 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"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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,6 +633,8 @@ 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,4 +1,3 @@
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -428,20 +427,16 @@ class TestWorkflowCollaborationService:
|
||||
repository.delete_leader.assert_not_called()
|
||||
|
||||
def test_broadcast_leader_change_logs_emit_errors(
|
||||
self,
|
||||
service: tuple[WorkflowCollaborationService, Mock, Mock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
) -> None:
|
||||
collaboration_service, repository, socketio = service
|
||||
repository.get_session_sids.return_value = ["sid-1", "sid-2"]
|
||||
socketio.emit.side_effect = [RuntimeError("boom"), None]
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with patch("services.workflow_collaboration_service.logging.exception") as exception_mock:
|
||||
collaboration_service.broadcast_leader_change("wf-1", "sid-2")
|
||||
|
||||
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()
|
||||
assert exception_mock.call_count == 1
|
||||
|
||||
def test_broadcast_online_users_sorts_and_emits(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
|
||||
@@ -89,7 +89,7 @@ async function pollWithRetry(
|
||||
|
||||
function expired(): BaseError {
|
||||
return new BaseError({
|
||||
code: ErrorCode.TokenExpired,
|
||||
code: ErrorCode.ExpiredToken,
|
||||
message: 'code expired before authorization',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(n) rc release')
|
||||
expect(output.data.text()).toContain('WARNING: This build is a rc release')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('error codes', () => {
|
||||
[ErrorCode.AuthExpired, ExitCode.Auth],
|
||||
[ErrorCode.TokenExpired, ExitCode.Auth],
|
||||
[ErrorCode.AccessDenied, ExitCode.Auth],
|
||||
[ErrorCode.ExpiredToken, ExitCode.Auth],
|
||||
[ErrorCode.VersionSkew, ExitCode.VersionCompat],
|
||||
[ErrorCode.UnsupportedEndpoint, ExitCode.VersionCompat],
|
||||
[ErrorCode.ConfigSchemaUnsupported, ExitCode.VersionCompat],
|
||||
|
||||
@@ -3,6 +3,7 @@ export const ErrorCode = {
|
||||
AuthExpired: 'auth_expired',
|
||||
TokenExpired: 'token_expired',
|
||||
AccessDenied: 'access_denied',
|
||||
ExpiredToken: 'expired_token',
|
||||
VersionSkew: 'version_skew',
|
||||
UnsupportedEndpoint: 'unsupported_endpoint',
|
||||
ConfigSchemaUnsupported: 'config_schema_unsupported',
|
||||
@@ -39,6 +40,7 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
|
||||
auth_expired: ExitCode.Auth,
|
||||
token_expired: ExitCode.Auth,
|
||||
access_denied: ExitCode.Auth,
|
||||
expired_token: ExitCode.Auth,
|
||||
version_skew: ExitCode.VersionCompat,
|
||||
unsupported_endpoint: ExitCode.VersionCompat,
|
||||
config_schema_unsupported: ExitCode.VersionCompat,
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
expect(err.code).toBe(ErrorCode.Server4xxOther)
|
||||
})
|
||||
|
||||
it('401 unauthorized classifies as AuthExpired with CLI login hint', async () => {
|
||||
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
|
||||
const err = await classified(401, {
|
||||
code: 'unauthorized',
|
||||
message: 'session expired or revoked',
|
||||
@@ -44,20 +44,6 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
|
||||
})
|
||||
|
||||
it('401 token_expired carries the structured TokenExpired code with the server message', async () => {
|
||||
const err = await classified(401, {
|
||||
code: 'token_expired',
|
||||
message: 'Your session has expired.',
|
||||
status: 401,
|
||||
hint: 'Re-authenticate to continue (e.g. re-run your login command).',
|
||||
})
|
||||
|
||||
expect(err.code).toBe(ErrorCode.TokenExpired)
|
||||
expect(err.exit()).toBe(4)
|
||||
expect(err.message).toBe('Your session has expired.')
|
||||
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
|
||||
})
|
||||
|
||||
it('unknown future server code is data, not behavior — status bucket decides', async () => {
|
||||
const err = await classified(409, {
|
||||
code: 'some_future_code',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ErrorCodeValue } from '@/errors/codes'
|
||||
import { zErrorBody, zOpenApiErrorCode } from '@dify/contracts/api/openapi/zod.gen'
|
||||
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
|
||||
import { BaseError, HttpClientError, newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { redactBearer } from './sanitize'
|
||||
@@ -24,16 +24,6 @@ const AUTH_EXPIRED_CLASS: StatusClass = {
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
// A 401 whose body carries the server's `token_expired` code is a known,
|
||||
// behavior-driving signal (not opaque data): the session lapsed rather than the
|
||||
// token being unknown/revoked, so wrappers get the distinct structured code.
|
||||
const TOKEN_EXPIRED_CLASS: StatusClass = {
|
||||
code: ErrorCode.TokenExpired,
|
||||
fallbackMessage: () => 'session expired',
|
||||
hint: AUTH_LOGIN_HINT,
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
const SERVER_5XX_CLASS: StatusClass = {
|
||||
code: ErrorCode.Server5xx,
|
||||
fallbackMessage: status => `server error (HTTP ${status})`,
|
||||
@@ -60,9 +50,9 @@ const ACCESS_DENIED_CLASS: StatusClass = {
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
function statusClass(status: number, serverError?: ErrorBody): StatusClass {
|
||||
function statusClass(status: number): StatusClass {
|
||||
if (status === 401)
|
||||
return serverError?.code === zOpenApiErrorCode.enum.token_expired ? TOKEN_EXPIRED_CLASS : AUTH_EXPIRED_CLASS
|
||||
return AUTH_EXPIRED_CLASS
|
||||
if (status === 403)
|
||||
return ACCESS_DENIED_CLASS
|
||||
if (status === 429)
|
||||
@@ -97,7 +87,7 @@ export async function classifyResponse(request: Request, response: Response): Pr
|
||||
|
||||
const serverError = parseServerError(raw)
|
||||
const status = response.status
|
||||
const c = statusClass(status, serverError)
|
||||
const c = statusClass(status)
|
||||
return new HttpClientError({
|
||||
code: c.code,
|
||||
message: serverError?.message ?? c.fallbackMessage(status),
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('renderVersionText', () => {
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a(n) rc release')
|
||||
expect(text).toContain('WARNING: This build is a 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(n) alpha release')
|
||||
expect(text).toContain('WARNING: This build is a 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(n) edge release')
|
||||
expect(text).toContain('WARNING: This build is a 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(n) rc release')
|
||||
expect(plain).toContain('WARNING: This build is a 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(n) rc release')
|
||||
expect(colored).toContain('WARNING: This build is a rc release')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { colorScheme } from '@/sys/io/color'
|
||||
|
||||
function prereleaseWarning(channel: Channel): readonly string[] {
|
||||
return [
|
||||
`WARNING: This build is a(n) ${channel} release. It is not stable`,
|
||||
`WARNING: This build is a ${channel} release. It is not stable`,
|
||||
' and may have bugs. For production use, install or wait for the stable channel.',
|
||||
]
|
||||
}
|
||||
|
||||
+4
-22
@@ -28,10 +28,6 @@ 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
|
||||
@@ -40,14 +36,6 @@ 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)"
|
||||
|
||||
@@ -56,7 +44,7 @@ run_pyrefly() {
|
||||
local pyrefly_status=$?
|
||||
set -e
|
||||
|
||||
uv run --directory api python libs/pyrefly_diagnostics.py --status "$pyrefly_status" < "$tmp_output"
|
||||
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
return "$pyrefly_status"
|
||||
}
|
||||
@@ -74,17 +62,11 @@ 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 \
|
||||
"${test_containers_args[@]}" \
|
||||
"--summary=none" \
|
||||
"--use-ignore-files=false" \
|
||||
"--config=$TEST_CONTAINERS_CONFIG" \
|
||||
|| status=$?
|
||||
fi
|
||||
|
||||
|
||||
@@ -891,14 +891,6 @@
|
||||
"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,6 +424,7 @@ 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
|
||||
@@ -1560,6 +1561,7 @@ 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,6 +203,7 @@ 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(),
|
||||
@@ -2126,6 +2127,7 @@ 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,6 +580,7 @@ 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
|
||||
@@ -598,6 +599,7 @@ 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
|
||||
@@ -1242,6 +1244,7 @@ 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
|
||||
@@ -2691,6 +2694,7 @@ 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,6 +358,7 @@ 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(),
|
||||
@@ -379,6 +380,7 @@ 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(),
|
||||
@@ -858,6 +860,7 @@ 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(),
|
||||
@@ -3546,6 +3549,7 @@ 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,7 +232,6 @@ export type SavedMessageItem = {
|
||||
}
|
||||
|
||||
export type InstalledAppInfoResponse = {
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
@@ -247,6 +246,7 @@ 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,7 +229,6 @@ 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(),
|
||||
@@ -267,6 +266,7 @@ 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(),
|
||||
|
||||
@@ -340,7 +340,6 @@ export type OpenApiErrorCode
|
||||
| 'rate_limit_error'
|
||||
| 'recipient_surface_mismatch'
|
||||
| 'request_entity_too_large'
|
||||
| 'token_expired'
|
||||
| 'too_many_files'
|
||||
| 'too_many_requests'
|
||||
| 'unauthorized'
|
||||
@@ -406,10 +405,6 @@ export type SessionRow = {
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
result: string
|
||||
}
|
||||
|
||||
export type SupportedAppType = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
|
||||
export type TaskStopResponse = {
|
||||
|
||||
@@ -423,7 +423,6 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'rate_limit_error',
|
||||
'recipient_surface_mismatch',
|
||||
'request_entity_too_large',
|
||||
'token_expired',
|
||||
'too_many_files',
|
||||
'too_many_requests',
|
||||
'unauthorized',
|
||||
@@ -502,13 +501,6 @@ export const zSessionListResponse = z.object({
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
export const zSimpleResultResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SupportedAppType
|
||||
*
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
zAccessServiceUpdateAccessPolicyBody,
|
||||
zAccessServiceUpdateAccessPolicyPath,
|
||||
zAccessServiceUpdateAccessPolicyResponse,
|
||||
zAccessSubjectServiceListAccessSubjectsQuery,
|
||||
zAccessSubjectServiceListAccessSubjectsResponse,
|
||||
zAppInstanceServiceCreateAppInstanceBody,
|
||||
zAppInstanceServiceCreateAppInstanceResponse,
|
||||
zAppInstanceServiceDeleteAppInstancePath,
|
||||
@@ -107,6 +109,21 @@ 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',
|
||||
@@ -713,6 +730,7 @@ export const webAppAuth = {
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
accessSubjectService,
|
||||
appInstanceService,
|
||||
accessService,
|
||||
deploymentService,
|
||||
|
||||
@@ -1038,7 +1038,6 @@ export type UpdateEnvironmentRequest = {
|
||||
environmentId?: string
|
||||
displayName: string
|
||||
description?: string
|
||||
cpuCount?: number
|
||||
}
|
||||
|
||||
export type UpdateEnvironmentResponse = {
|
||||
@@ -1368,7 +1367,6 @@ export type InfoConfigReply = {
|
||||
Branding?: BrandingInfo
|
||||
WebAppAuth?: WebAppAuthInfo
|
||||
PluginInstallationPermission?: PluginInstallationPermissionInfo
|
||||
EnableAppDeploy?: boolean
|
||||
}
|
||||
|
||||
export type InnerAdmission = {
|
||||
@@ -1515,6 +1513,11 @@ export type LimitFields = {
|
||||
appRunnerEnvCpus?: ResourceQuota
|
||||
}
|
||||
|
||||
export type ListAccessSubjectsReply = {
|
||||
subjects?: Array<Subject>
|
||||
pagination?: Pagination
|
||||
}
|
||||
|
||||
export type ListGroupAppsResponse = {
|
||||
items?: Array<GroupAppItem>
|
||||
total?: string
|
||||
@@ -2135,6 +2138,25 @@ 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,7 +912,6 @@ 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({
|
||||
@@ -2073,7 +2072,6 @@ export const zInfoConfigReply = z.object({
|
||||
Branding: zBrandingInfo.optional(),
|
||||
WebAppAuth: zWebAppAuthInfo.optional(),
|
||||
PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(),
|
||||
EnableAppDeploy: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const zWebOAuth2LoginReply = z.object({
|
||||
@@ -2245,6 +2243,11 @@ 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(),
|
||||
@@ -2265,6 +2268,26 @@ 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,21 +10,13 @@ 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, OpenApiResponse>
|
||||
responses?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type SwaggerDocument = JsonObject & {
|
||||
@@ -60,17 +52,6 @@ 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' },
|
||||
@@ -201,46 +182,6 @@ 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))
|
||||
@@ -274,7 +215,6 @@ const filterContractOperations = (document: SwaggerDocument) => {
|
||||
}
|
||||
|
||||
const normalizeApiSwagger = (document: SwaggerDocument) => {
|
||||
normalizeOpaqueContractResponses(document)
|
||||
filterContractOperations(document)
|
||||
addOperationIds(document)
|
||||
|
||||
@@ -440,20 +380,10 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
|
||||
'name': 'zod',
|
||||
'~resolvers': {
|
||||
string: (ctx) => {
|
||||
if (ctx.schema.format === 'binary')
|
||||
return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File')))
|
||||
if (ctx.schema.format !== 'binary')
|
||||
return undefined
|
||||
|
||||
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
|
||||
return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File')))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
default: ({
|
||||
AccessControl: ({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
|
||||
@@ -112,14 +112,9 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = () => <div data-testid="app-access-control" />
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
AccessControl: () => <div data-testid="app-access-control" />,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
|
||||
@@ -212,19 +212,14 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
AccessControl: ({ 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,7 +232,20 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when subscription management permission is granted', () => {
|
||||
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', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({
|
||||
isCurrentWorkspaceManager: false,
|
||||
@@ -241,8 +254,7 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when subscription management permission is missing', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { AccessTab } from '@/features/deployments/detail/access-tab'
|
||||
|
||||
export default function InstanceDetailAccessPage() {
|
||||
return <AccessTab />
|
||||
export default async function InstanceDetailAccessPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <AccessTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
|
||||
|
||||
export default function InstanceDetailApiTokensPage() {
|
||||
return <DeveloperApiTab />
|
||||
export default async function InstanceDetailApiTokensPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeveloperApiTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
|
||||
|
||||
export default function InstanceDetailInstancesPage() {
|
||||
return <DeployTab />
|
||||
export default async function InstanceDetailInstancesPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeployTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { InstanceDetail } from '@/features/deployments/detail'
|
||||
|
||||
export default function InstanceDetailLayout({ children }: {
|
||||
export default async function InstanceDetailLayout({ children, params }: {
|
||||
children: ReactNode
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
|
||||
return (
|
||||
<InstanceDetail>
|
||||
<InstanceDetail appInstanceId={appInstanceId}>
|
||||
{children}
|
||||
</InstanceDetail>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
|
||||
|
||||
export default function InstanceDetailOverviewPage() {
|
||||
return <OverviewTab />
|
||||
export default async function InstanceDetailOverviewPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <OverviewTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
|
||||
|
||||
export default function InstanceDetailReleasesPage() {
|
||||
return <VersionsTab />
|
||||
export default async function InstanceDetailReleasesPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <VersionsTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
|
||||
@@ -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,45 +1,43 @@
|
||||
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 { AccessControlItem } from '../access-control-item'
|
||||
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
|
||||
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', () => {
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.PUBLIC },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should keep the selected state for the active access type', () => {
|
||||
useAccessControlStore.setState({
|
||||
currentMenu: AccessMode.ORGANIZATION,
|
||||
})
|
||||
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('border-components-option-card-option-selected-border')
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
expect(option).toHaveAttribute('data-checked')
|
||||
})
|
||||
})
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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,24 +1,23 @@
|
||||
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 { 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'
|
||||
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
const mockMutate = vi.fn()
|
||||
const mockUseMutation = vi.hoisted(() => vi.fn())
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
|
||||
}))
|
||||
@@ -36,12 +35,19 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
vi.mock('@/service/access-control/use-app-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 {
|
||||
@@ -94,10 +100,12 @@ beforeAll(() => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseUpdateAccessMode.mockReturnValue({
|
||||
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
mockUseMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
mutate: mockMutate,
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
@@ -117,33 +125,39 @@ beforeEach(() => {
|
||||
// AccessControlItem handles selected vs. unselected styling and click state updates
|
||||
describe('AccessControlItem', () => {
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.PUBLIC },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
expect(option).toHaveClass('cursor-pointer')
|
||||
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should keep current menu when clicking the selected access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -180,32 +194,40 @@ 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', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
render(harness.element)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
|
||||
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state while pending', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers loading />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
|
||||
const { container } = render(<SpecificGroupsOrMembers />)
|
||||
render(harness.element)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fetched groups and members and support removal', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
},
|
||||
)
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
render(harness.element)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
@@ -234,8 +256,12 @@ 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(<AddMemberOrGroupDialog />)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -246,17 +272,21 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
it('should allow selecting members and expanding groups', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
|
||||
await user.click(expandButton)
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Member One/ }))
|
||||
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
|
||||
@@ -269,7 +299,11 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
|
||||
@@ -286,9 +320,9 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
|
||||
|
||||
expect(useAccessControlStore.getState().specificGroups).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(harness.getSnapshot().specificGroups).toEqual([])
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([])
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -301,7 +335,11 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -315,10 +353,6 @@ 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,
|
||||
@@ -332,21 +366,22 @@ describe('AccessControl', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
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(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(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+31
-21
@@ -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 useAccessControlStore from '@/context/access-control-store'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
}))
|
||||
|
||||
@@ -69,13 +69,6 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAccessControlStore.setState({
|
||||
appId: 'app-1',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: SubjectType.GROUP as never,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
@@ -88,7 +81,11 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
it('should open the search popover and display candidates', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -99,16 +96,20 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
it('should allow expanding groups and selecting members', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
|
||||
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Member One/ }))
|
||||
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should show the empty state when no candidates are returned', async () => {
|
||||
@@ -120,7 +121,11 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -128,9 +133,6 @@ 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,
|
||||
@@ -139,7 +141,15 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [baseGroup],
|
||||
},
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -149,6 +159,6 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' }))
|
||||
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,8 @@ 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,
|
||||
@@ -18,20 +17,24 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: mockWebappAuth },
|
||||
})
|
||||
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
const mockMutate = vi.fn()
|
||||
const mockUseMutation = vi.hoisted(() => vi.fn())
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
vi.mock('@/service/access-control/use-app-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()
|
||||
@@ -41,14 +44,13 @@ describe('AccessControl', () => {
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
}
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
mockUseMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: mockMutate,
|
||||
})
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
@@ -81,18 +83,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(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
})
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -116,4 +118,30 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
+33
-35
@@ -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 { SpecificGroupsOrMembers } from '../specific-groups-or-members'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../add-member-or-group-pop', () => ({
|
||||
default: () => <div data-testid="add-member-or-group-dialog" />,
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
}))
|
||||
|
||||
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
|
||||
@@ -36,50 +36,48 @@ describe('SpecificGroupsOrMembers', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
groups: [baseGroup],
|
||||
members: [baseMember],
|
||||
},
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
data: { pages: [] },
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the collapsed row when not in specific mode', () => {
|
||||
useAccessControlStore.setState({
|
||||
currentMenu: AccessMode.ORGANIZATION,
|
||||
})
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
render(harness.element)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading while whitelist subjects are pending', async () => {
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
it('should show loading when the selected subjects are pending', async () => {
|
||||
const harness = createAccessControlDraftHarness(<SpecificGroupsOrMembers loading />)
|
||||
render(harness.element)
|
||||
|
||||
const { container } = render(<SpecificGroupsOrMembers />)
|
||||
expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render fetched groups and members and support removal', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1' })
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
},
|
||||
)
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
render(harness.element)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
@@ -91,9 +89,9 @@ describe('SpecificGroupsOrMembers', () => {
|
||||
const memberRemove = removeButtons[1]!
|
||||
|
||||
fireEvent.click(groupRemove)
|
||||
expect(useAccessControlStore.getState().specificGroups).toEqual([])
|
||||
expect(harness.getSnapshot().specificGroups).toEqual([])
|
||||
|
||||
fireEvent.click(memberRemove)
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([])
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'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,7 +5,6 @@ import {
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
@@ -14,21 +13,17 @@ type DialogProps = {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const AccessControlDialog = ({
|
||||
export function AccessControlDialog({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => {
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}>
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
|
||||
<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-all',
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -38,5 +33,3 @@ const AccessControlDialog = ({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessControlDialog
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
export function AccessControlItem({ type, children }: 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 (
|
||||
<div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</RadioRoot>
|
||||
)
|
||||
}
|
||||
|
||||
AccessControlItem.displayName = 'AccessControlItem'
|
||||
|
||||
export default AccessControlItem
|
||||
|
||||
+21
-24
@@ -2,7 +2,10 @@
|
||||
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { AccessSubjectSelectionProps } from './types'
|
||||
import type { AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type {
|
||||
AccessControlGroup,
|
||||
Subject,
|
||||
} from '@/models/access-control'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
@@ -18,7 +21,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useSearchAccessSubjects } from '@/service/access-control/use-access-subjects'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control/use-app-access-control'
|
||||
import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options'
|
||||
import {
|
||||
getSubjectLabel,
|
||||
@@ -30,6 +33,8 @@ import {
|
||||
|
||||
type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & {
|
||||
disabled?: boolean
|
||||
breadcrumbGroups?: AccessControlGroup[]
|
||||
onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void
|
||||
}
|
||||
|
||||
export function AccessSubjectAddButton({
|
||||
@@ -37,17 +42,21 @@ export function AccessSubjectAddButton({
|
||||
selectedMembers,
|
||||
onChange,
|
||||
disabled,
|
||||
breadcrumbGroups,
|
||||
onBreadcrumbGroupsChange,
|
||||
}: AccessSubjectAddButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [breadcrumbGroups, setBreadcrumbGroups] = useState<AccessControlGroup[]>([])
|
||||
const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState<AccessControlGroup[]>([])
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups
|
||||
const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = breadcrumbGroups[breadcrumbGroups.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchAccessSubjects({
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({
|
||||
keyword: debouncedKeyword,
|
||||
groupId: lastAvailableGroup?.id,
|
||||
resultsPerPage: 10,
|
||||
@@ -59,7 +68,7 @@ export function AccessSubjectAddButton({
|
||||
members: selectedMembers,
|
||||
})
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || breadcrumbGroups.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,21 +83,13 @@ export function AccessSubjectAddButton({
|
||||
return () => observer?.disconnect()
|
||||
}, [fetchNextPage, hasMore, isFetchingNextPage, isLoading])
|
||||
|
||||
const closeMenu = () => {
|
||||
setKeyword('')
|
||||
setBreadcrumbGroups([])
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen && disabled)
|
||||
return
|
||||
if (!nextOpen) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
|
||||
setOpen(true)
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
@@ -121,10 +122,6 @@ export function AccessSubjectAddButton({
|
||||
icon={false}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (open)
|
||||
closeMenu()
|
||||
}}
|
||||
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">
|
||||
@@ -159,8 +156,8 @@ export function AccessSubjectAddButton({
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb
|
||||
selectedGroupsForBreadcrumb={breadcrumbGroups}
|
||||
onChange={setBreadcrumbGroups}
|
||||
selectedGroupsForBreadcrumb={selectedGroupsForBreadcrumb}
|
||||
onChange={setSelectedGroupsForBreadcrumb}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -174,7 +171,7 @@ export function AccessSubjectAddButton({
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onExpandGroup={group => setBreadcrumbGroups([...breadcrumbGroups, group])}
|
||||
onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
|
||||
/>
|
||||
)}
|
||||
</ComboboxList>
|
||||
@@ -1,371 +1,31 @@
|
||||
'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 {
|
||||
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 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)
|
||||
import {
|
||||
AccessSubjectAddButton,
|
||||
} from './access-subject-selector/add-button'
|
||||
import { useAccessControlStore } from './store'
|
||||
|
||||
export function AddMemberOrGroupDialog({ disabled = false }: {
|
||||
disabled?: boolean
|
||||
}) {
|
||||
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 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 (
|
||||
<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>
|
||||
<AccessSubjectAddButton
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
disabled={disabled}
|
||||
breadcrumbGroups={selectedGroupsForBreadcrumb}
|
||||
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
'use client'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { App } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
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'
|
||||
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'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
@@ -22,92 +20,113 @@ type AccessControlProps = {
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
export function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
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 setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
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(':')
|
||||
|
||||
useEffect(() => {
|
||||
setAppId(app.id)
|
||||
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [app, setAppId, setCurrentMenu])
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||
const handleConfirm = useCallback(async () => {
|
||||
function AccessControlForm({
|
||||
app,
|
||||
hideExternalTip,
|
||||
subjectsLoading,
|
||||
successMessage,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
app: App
|
||||
hideExternalTip: boolean
|
||||
subjectsLoading: boolean
|
||||
successMessage: string
|
||||
onClose: () => void
|
||||
onConfirm?: () => void
|
||||
}) {
|
||||
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())
|
||||
|
||||
function handleConfirm() {
|
||||
const submitData: {
|
||||
appId: string
|
||||
accessMode: AccessMode
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
subjects?: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[]
|
||||
} = { appId: app.id, accessMode: currentMenu }
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||
const subjects: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP })
|
||||
})
|
||||
specificMembers.forEach((member) => {
|
||||
subjects.push({
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
})
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
}
|
||||
await updateAccessMode(submitData)
|
||||
toast.success(t('accessControlDialog.updateSuccess', { ns: 'app' }))
|
||||
onConfirm?.()
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
updateAccessMode({
|
||||
body: submitData,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(successMessage)
|
||||
onConfirm?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<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>
|
||||
<AccessControlDialogContent
|
||||
hideExternalTip={hideExternalTip}
|
||||
saving={isPending}
|
||||
controlsDisabled={subjectsLoading || isPending}
|
||||
confirmDisabled={subjectsLoading}
|
||||
specificGroupsOrMembersProps={{
|
||||
loading: subjectsLoading,
|
||||
}}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
'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 Loading from '../../base/loading'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
|
||||
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
|
||||
import { useAccessControlStore } from './store'
|
||||
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
export type SpecificGroupsOrMembersProps = {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SpecificGroupsOrMembers({
|
||||
loading = false,
|
||||
}: SpecificGroupsOrMembersProps) {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
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 { 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">
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,109 +35,28 @@ export default function SpecificGroupsOrMembers() {
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1 p-3">
|
||||
<div className="flex grow items-center gap-x-1">
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<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 />
|
||||
<AddMemberOrGroupDialog disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<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>
|
||||
<AccessSubjectSelectionList
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
loading={loading}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
</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' })
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user