Compare commits

..
Author SHA1 Message Date
Xiyuan ChenGareArcautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
64e29f2c9c fix(api): register rbac-migrate-dataset-permissions CLI command (#38204)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-30 01:36:46 -07:00
GareArc a70fcd315a Revert "feat(agent-v2): sync nightly updates to main (2026-06-25) (#37915)"
This reverts commit 52c106b532.
2026-06-30 01:20:58 -07:00
非法操作andGareArc f12602c046 fix: editor can view the logs (#38165) 2026-06-30 00:18:26 -07:00
wangxiaoleiandGareArc d2e1f08026 feat: support dataset permission migrate to rbac (#38166) 2026-06-30 00:00:36 -07:00
Wu TianweiandGareArc c49b4e20bf fix: update documentation links in permission set and role modals (#38188) 2026-06-29 23:26:33 -07:00
Wu TianweiandGareArc 85cc5d16be chore(i18n): update role management permission keys for multiple languages (#38186) 2026-06-29 23:26:33 -07:00
wangxiaoleiandGareArc 2c8869f688 perf: make command rbac-migrate-member-roles use less mem and make it… (#38151) 2026-06-29 23:26:32 -07:00
盐粒 YanliGareArcJoelyyhYansong Zhangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3afd571743 feat(agent-v2): sync nightly updates to main (2026-06-25) (#37915)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-29 04:05:09 -07:00
非法操作andGareArc ddc83de791 fix: editor should not query billing subscriptions (#38157) 2026-06-29 03:10:48 -07:00
非法操作andGareArc b59125f2f0 fix: agent app log detail modal not display well (#38014) 2026-06-29 03:10:45 -07:00
Xiyuan ChenandGareArc 6292119848 fix(api): wire dedicated timeout into inner RBAC requests (#38150) 2026-06-29 00:43:36 -07:00
非法操作andGareArc d59c76bfdc fix: plugin installation task popover layout when some failed too long (#38000) 2026-06-28 19:41:36 -07:00
4c591d51cb fix(web): keep HITL input-field save button visible in the edit dialog (#38007)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:42:33 -07:00
-LAN-GareArcautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
370fc1e81f fix(api): require edit access for trace config changes (#37973)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 19:37:33 -07:00
217 changed files with 2642 additions and 3278 deletions
+2 -10
View File
@@ -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.
-2
View File
@@ -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
+2 -1
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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",
)
)
+6
View File
@@ -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):
"""
+1 -5
View File
@@ -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)),
+2 -8
View File
@@ -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):
+1 -2
View File
@@ -20,7 +20,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
# Register response/query models BEFORE importing controller modules so that
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
from controllers.common.fields import EventStreamResponse, 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,
-14
View File
@@ -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
+2 -3
View File
@@ -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)
+3 -6
View File
@@ -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(
+3 -7
View File
@@ -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
+1 -9
View File
@@ -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(
+7 -7
View File
@@ -5,10 +5,10 @@ import uuid
from flask import request
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
from controllers.openapi.auth.data import AuthData, 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:
+2 -2
View File
@@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.openapi.auth.data import AuthData, 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")
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, 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
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.openapi import openapi_ns
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, 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:
+2 -2
View File
@@ -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"]
+8
View File
@@ -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``.
+20 -96
View File
@@ -2,8 +2,8 @@
This checker intentionally stays conservative. It only reports a hard schema
mismatch when both sides are statically known for the same 2xx status code:
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``,
``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:
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+3 -21
View File
@@ -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
+4 -4
View File
@@ -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 |
-6
View File
@@ -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.
+11 -7
View File
@@ -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]
+1 -1
View File
@@ -89,7 +89,7 @@ async function pollWithRetry(
function expired(): BaseError {
return new BaseError({
code: ErrorCode.TokenExpired,
code: ErrorCode.ExpiredToken,
message: 'code expired before authorization',
})
}
+1 -1
View File
@@ -158,6 +158,6 @@ describe('Version command', () => {
if (output?.kind !== 'formatted')
throw new Error('expected formatted output')
expect(output.data.text()).toContain('WARNING: This build is a(n) rc release')
expect(output.data.text()).toContain('WARNING: This build is a rc release')
})
})
+1
View File
@@ -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],
+2
View File
@@ -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,
+1 -15
View File
@@ -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',
+4 -14
View File
@@ -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),
+5 -5
View File
@@ -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')
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { colorScheme } from '@/sys/io/color'
function prereleaseWarning(channel: Channel): readonly string[] {
return [
`WARNING: This build is a(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
View File
@@ -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
-8
View File
@@ -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()
+4 -74
View File
@@ -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')
})
})
@@ -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()
})
@@ -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()
})
})
@@ -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
@@ -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