Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc68e02711 | ||
|
|
eb4ec93cea | ||
|
|
fa6f4b0ea5 | ||
|
|
267b34caaf | ||
|
|
6ab1dd06ac | ||
|
|
113d6d7e00 | ||
|
|
a66b1de477 | ||
|
|
16b698b54d | ||
|
|
4cd8b8c733 | ||
|
|
677ab01806 | ||
|
|
f4e832f35c | ||
|
|
1c5e1280cb | ||
|
|
0035d90e36 | ||
|
|
2047e0dc12 | ||
|
|
2e9c0a3c7a | ||
|
|
a246dc8b17 | ||
|
|
bb921bcc45 | ||
|
|
4f4ac27de2 |
@@ -12,6 +12,7 @@ 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. |
|
||||
@@ -23,7 +24,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: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
||||
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
|
||||
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
|
||||
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
|
||||
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
|
||||
@@ -32,6 +33,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
|
||||
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
|
||||
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
|
||||
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
|
||||
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
|
||||
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
|
||||
@@ -46,6 +49,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
|
||||
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
|
||||
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
|
||||
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
|
||||
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
|
||||
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
|
||||
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
|
||||
@@ -60,8 +64,10 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
- Type component signatures directly; do not use `FC` or `React.FC`.
|
||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
||||
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
|
||||
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
|
||||
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
|
||||
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
|
||||
@@ -83,11 +89,13 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
|
||||
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
|
||||
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
|
||||
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
|
||||
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
|
||||
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
|
||||
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
|
||||
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
|
||||
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
|
||||
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
|
||||
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
|
||||
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
|
||||
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
|
||||
@@ -96,7 +104,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
|
||||
## Boundaries And Overlays
|
||||
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds structure. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
|
||||
|
||||
@@ -53,6 +53,8 @@ jobs:
|
||||
|
||||
- name: Run Type Checks
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
PYREFLY_OUTPUT_FORMAT: github
|
||||
run: make type-check-core
|
||||
|
||||
- name: Dotenv check
|
||||
|
||||
@@ -167,12 +167,16 @@ register_schema_models(
|
||||
ChatMessagesQuery,
|
||||
MessageFeedbackPayload,
|
||||
FeedbackExportQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AnnotationCountResponse,
|
||||
SuggestedQuestionsResponse,
|
||||
MessageDetailResponse,
|
||||
MessageInfiniteScrollPaginationResponse,
|
||||
SimpleResultResponse,
|
||||
TextFileResponse,
|
||||
)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
|
||||
|
||||
@@ -13,6 +13,7 @@ from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
@@ -95,9 +96,12 @@ class TraceAppConfigApi(Resource):
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration already exists")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
"""Create a new trace app configuration"""
|
||||
@@ -125,9 +129,12 @@ class TraceAppConfigApi(Resource):
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def patch(self, app_model: App):
|
||||
"""Update an existing trace app configuration"""
|
||||
@@ -149,9 +156,12 @@ class TraceAppConfigApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
|
||||
@console_ns.response(204, "Tracing configuration deleted successfully")
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
|
||||
@get_app_model
|
||||
def delete(self, app_model: App):
|
||||
"""Delete an existing trace app configuration"""
|
||||
|
||||
@@ -85,6 +85,7 @@ def _published_app_filter():
|
||||
class InstalledAppInfoResponse(ResponseModel):
|
||||
id: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
mode: str | None = None
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
@@ -123,6 +124,7 @@ class InstalledAppResponse(ResponseModel):
|
||||
return {
|
||||
"id": _safe_primitive(getattr(value, "id", "")) or "",
|
||||
"name": _safe_primitive(getattr(value, "name", None)),
|
||||
"description": _safe_primitive(getattr(value, "description", None)),
|
||||
"mode": _safe_primitive(getattr(value, "mode", None)),
|
||||
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
|
||||
"icon": _safe_primitive(getattr(value, "icon", None)),
|
||||
|
||||
@@ -20,7 +20,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
|
||||
|
||||
# Register response/query models BEFORE importing controller modules so that
|
||||
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
|
||||
from controllers.common.fields import EventStreamResponse
|
||||
from controllers.common.fields import EventStreamResponse, SimpleResultResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.openapi._models import (
|
||||
AccountPayload,
|
||||
@@ -95,6 +95,7 @@ register_response_schema_models(
|
||||
openapi_ns,
|
||||
ErrorBody,
|
||||
EventStreamResponse,
|
||||
SimpleResultResponse,
|
||||
UsageInfo,
|
||||
MessageMetadata,
|
||||
AppListRow,
|
||||
|
||||
@@ -34,6 +34,7 @@ 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"
|
||||
@@ -223,6 +224,19 @@ class OpenApiErrorFormatter:
|
||||
return isinstance(part, (str, int)) and not isinstance(part, bool)
|
||||
|
||||
|
||||
class InvalidBearer(OpenApiError): # noqa: N818
|
||||
code = 401
|
||||
error_code = OpenApiErrorCode.UNAUTHORIZED
|
||||
description = "Invalid or unknown bearer token."
|
||||
|
||||
|
||||
class SessionExpired(OpenApiError): # noqa: N818
|
||||
code = 401
|
||||
error_code = OpenApiErrorCode.TOKEN_EXPIRED
|
||||
description = "Your session has expired."
|
||||
hint = "Re-authenticate to continue (e.g. re-run your login command)."
|
||||
|
||||
|
||||
class FilenameNotExists(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -61,7 +61,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _translate_service_errors() -> Iterator[None]:
|
||||
def _translate_service_errors() -> Generator[None, None, None]:
|
||||
try:
|
||||
yield
|
||||
except WorkflowNotFoundError as ex:
|
||||
@@ -166,6 +166,7 @@ class AppRunApi(Resource):
|
||||
surface="apps",
|
||||
)
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(stream_obj)
|
||||
|
||||
|
||||
|
||||
@@ -25,12 +25,13 @@ from controllers.openapi._models import (
|
||||
AppListRow,
|
||||
)
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
from models.model import AppMode
|
||||
from services.account_service import TenantService
|
||||
from services.app_service import AppListParams, AppService
|
||||
@@ -166,7 +167,9 @@ class AppListApi(Resource):
|
||||
# an empty set or list means the caller has no accessible apps.
|
||||
# End-users bypass RBAC here — their access is controlled by scope upstream.
|
||||
apply_rbac_filter = (
|
||||
dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None
|
||||
dify_config.RBAC_ENABLED
|
||||
and auth_data.caller_kind != CallerKind.END_USER
|
||||
and auth_data.account_id is not None
|
||||
)
|
||||
access_filter = AppAccessFilter.unrestricted()
|
||||
if apply_rbac_filter:
|
||||
@@ -203,7 +206,7 @@ class AppListApi(Resource):
|
||||
limit=query.limit,
|
||||
mode=query.mode.value if query.mode else "all", # type:ignore
|
||||
name=query.name,
|
||||
status="normal",
|
||||
status=AppStatus.NORMAL,
|
||||
# Visibility gate pushed into the query — pagination.total stays
|
||||
# consistent across pages because invisible rows never count.
|
||||
openapi_visible=True,
|
||||
|
||||
@@ -25,6 +25,7 @@ from controllers.openapi.auth.data import AuthData, Edition
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
from services.account_service import TenantService
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.app_permitted_service import list_permitted_apps
|
||||
@@ -62,7 +63,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
items: list[AppListRow] = []
|
||||
for app_id in page_result.app_ids:
|
||||
app = apps_by_id.get(app_id)
|
||||
if not app or app.status != "normal":
|
||||
if not app or app.status != AppStatus.NORMAL:
|
||||
continue
|
||||
tenant = tenants_by_id.get(str(app.tenant_id))
|
||||
items.append(
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
@@ -21,6 +20,11 @@ class Edition(StrEnum):
|
||||
SAAS = "saas"
|
||||
|
||||
|
||||
class CallerKind(StrEnum):
|
||||
ACCOUNT = "account"
|
||||
END_USER = "end_user"
|
||||
|
||||
|
||||
def current_edition() -> Edition:
|
||||
if dify_config.EDITION == "CLOUD":
|
||||
return Edition.SAAS
|
||||
@@ -78,9 +82,9 @@ class AuthData(BaseModel):
|
||||
tenant_role: TenantAccountRole | None = None
|
||||
|
||||
caller: Account | EndUser | None = None
|
||||
caller_kind: Literal["account", "end_user"] | None = None
|
||||
caller_kind: CallerKind | None = None
|
||||
|
||||
def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]:
|
||||
def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]:
|
||||
if self.app is None or self.caller is None or self.caller_kind is None:
|
||||
raise InternalServerError("pipeline_invariant_violated: app context missing")
|
||||
return self.app, self.caller, self.caller_kind
|
||||
|
||||
@@ -17,6 +17,7 @@ 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,
|
||||
@@ -28,7 +29,9 @@ 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,
|
||||
@@ -217,7 +220,12 @@ class PipelineRouter:
|
||||
if not token:
|
||||
raise Unauthorized("bearer required")
|
||||
|
||||
identity = get_authenticator().authenticate(token)
|
||||
try:
|
||||
identity = get_authenticator().authenticate(token)
|
||||
except TokenExpiredError:
|
||||
raise SessionExpired()
|
||||
except InvalidBearerError:
|
||||
raise InvalidBearer()
|
||||
|
||||
if allowed_token_types is not None and identity.token_type not in allowed_token_types:
|
||||
emit_wrong_surface(
|
||||
|
||||
@@ -5,10 +5,10 @@ import uuid
|
||||
from flask import request
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind
|
||||
from extensions.ext_database import db
|
||||
from models.account import TenantStatus
|
||||
from models.enums import EndUserType
|
||||
from models.account import AccountStatus, TenantStatus
|
||||
from models.enums import AppStatus, EndUserType
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.app_service import AppService
|
||||
from services.end_user_service import EndUserService
|
||||
@@ -24,7 +24,7 @@ def load_app(data: AuthData) -> None:
|
||||
except ValueError:
|
||||
raise NotFound("app not found")
|
||||
app = AppService.get_app_by_id(db.session, app_id)
|
||||
if not app or app.status != "normal":
|
||||
if not app or app.status != AppStatus.NORMAL:
|
||||
raise NotFound("app not found")
|
||||
data.app = app
|
||||
|
||||
@@ -65,7 +65,7 @@ def load_account(data: AuthData) -> None:
|
||||
if data.tenant:
|
||||
account.current_tenant = data.tenant
|
||||
data.caller = account
|
||||
data.caller_kind = "account"
|
||||
data.caller_kind = CallerKind.ACCOUNT
|
||||
|
||||
|
||||
def load_workspace_role(data: AuthData) -> None:
|
||||
@@ -73,7 +73,7 @@ def load_workspace_role(data: AuthData) -> None:
|
||||
return
|
||||
if data.tenant is None or data.account_id is None:
|
||||
return
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != "active":
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE:
|
||||
return
|
||||
role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id))
|
||||
if role is None:
|
||||
@@ -91,7 +91,7 @@ def resolve_external_user(data: AuthData) -> None:
|
||||
user_id=data.external_identity.email,
|
||||
)
|
||||
data.caller = end_user
|
||||
data.caller_kind = "end_user"
|
||||
data.caller_kind = CallerKind.END_USER
|
||||
|
||||
|
||||
def load_app_access_mode(data: AuthData) -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from services.account_service import AccountService, TenantService
|
||||
@@ -58,7 +58,7 @@ def check_rbac_permission(data: AuthData) -> None:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
# Only account callers are subject to RBAC; end_user access is scope-controlled.
|
||||
if data.caller_kind != "account":
|
||||
if data.caller_kind != CallerKind.ACCOUNT:
|
||||
return
|
||||
if data.account_id is None or data.tenant is None:
|
||||
raise Forbidden("rbac context missing")
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
|
||||
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from core.workflow.human_input_policy import (
|
||||
HumanInputSurface,
|
||||
is_recipient_type_allowed_for_surface,
|
||||
@@ -98,7 +98,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
|
||||
submission_user_id: str | None = None
|
||||
submission_end_user_id: str | None = None
|
||||
if caller_kind == "account":
|
||||
if caller_kind == CallerKind.ACCOUNT:
|
||||
submission_user_id = caller.id
|
||||
else:
|
||||
submission_end_user_id = caller.id
|
||||
|
||||
@@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, RBACRequirement
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
|
||||
@@ -70,7 +70,7 @@ class OpenApiWorkflowEventsApi(Resource):
|
||||
if workflow_run.app_id != app_model.id:
|
||||
raise NotFound("Workflow run not found")
|
||||
|
||||
if caller_kind == "account":
|
||||
if caller_kind == CallerKind.ACCOUNT:
|
||||
if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id:
|
||||
raise NotFound("Workflow run not found")
|
||||
else:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
This checker intentionally stays conservative. It only reports a hard schema
|
||||
mismatch when both sides are statically known for the same 2xx status code:
|
||||
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``
|
||||
or ``Model.model_validate(...).model_dump()`` return.
|
||||
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``,
|
||||
``Model(...).model_dump()``, or ``Model.model_validate(...).model_dump()`` return.
|
||||
|
||||
Raw dictionaries, raw lists, ``None`` responses, streaming helpers, missing
|
||||
response schemas, and returns with non-literal status codes are classified as
|
||||
@@ -28,6 +28,7 @@ from typing import Any, Literal
|
||||
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
|
||||
NO_BODY_STATUSES = {HTTPStatus.NO_CONTENT.value, HTTPStatus.RESET_CONTENT.value, HTTPStatus.NOT_MODIFIED.value}
|
||||
DEFAULT_CONTROLLER_DIRS = ("controllers/console", "controllers/service_api", "controllers/web")
|
||||
IGNORE_COMMENT_MARKERS = ("response-contract:ignore",)
|
||||
|
||||
type Classification = Literal["valid", "mismatch", "unknown", "refactorable"]
|
||||
type ActualKind = Literal[
|
||||
@@ -41,6 +42,7 @@ type ActualKind = Literal[
|
||||
"unknown",
|
||||
]
|
||||
type MethodNode = ast.FunctionDef | ast.AsyncFunctionDef
|
||||
type ModelValueSource = Literal["constructor", "model_validate"]
|
||||
|
||||
HTTP_STATUS_NAMES = {status.name: status.value for status in HTTPStatus}
|
||||
HTTP_STATUS_NAMES.update({f"HTTP_{status.value}_{status.name}": status.value for status in HTTPStatus})
|
||||
@@ -109,18 +111,22 @@ class VariableAssignmentSummary:
|
||||
"""Track whether a local name is safe to treat as one specific response model."""
|
||||
|
||||
known_models: set[str] = field(default_factory=set)
|
||||
known_sources: set[ModelValueSource] = field(default_factory=set)
|
||||
has_unknown_assignment: bool = False
|
||||
|
||||
def add_known(self, model: str) -> None:
|
||||
def add_known(self, model: str, source: ModelValueSource) -> None:
|
||||
self.known_models.add(model)
|
||||
self.known_sources.add(source)
|
||||
|
||||
def add_unknown(self) -> None:
|
||||
self.has_unknown_assignment = True
|
||||
|
||||
def single_known_model(self) -> str | None:
|
||||
def single_known_model(self) -> tuple[str, ModelValueSource] | None:
|
||||
if self.has_unknown_assignment or len(self.known_models) != 1:
|
||||
return None
|
||||
return next(iter(self.known_models))
|
||||
model = next(iter(self.known_models))
|
||||
source: ModelValueSource = "constructor" if self.known_sources == {"constructor"} else "model_validate"
|
||||
return model, source
|
||||
|
||||
|
||||
def dotted_name(node: ast.AST) -> str | None:
|
||||
@@ -249,6 +255,12 @@ def model_name_from_model_validate_call(node: ast.AST) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def model_value_from_model_validate_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
|
||||
if model_name := model_name_from_model_validate_call(node):
|
||||
return model_name, "model_validate"
|
||||
return None
|
||||
|
||||
|
||||
def model_name_from_constructor_call(node: ast.AST) -> str | None:
|
||||
if not isinstance(node, ast.Call):
|
||||
return None
|
||||
@@ -257,6 +269,12 @@ def model_name_from_constructor_call(node: ast.AST) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def model_value_from_constructor_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
|
||||
if model_name := model_name_from_constructor_call(node):
|
||||
return model_name, "constructor"
|
||||
return None
|
||||
|
||||
|
||||
def model_name_from_model_dump(node: ast.AST) -> str | None:
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute) or node.func.attr != "model_dump":
|
||||
return None
|
||||
@@ -272,6 +290,10 @@ def model_name_from_model_value(node: ast.AST) -> str | None:
|
||||
return model_name_from_model_validate_call(node) or model_name_from_constructor_call(node)
|
||||
|
||||
|
||||
def model_value_from_model_value(node: ast.AST) -> tuple[str, ModelValueSource] | None:
|
||||
return model_value_from_model_validate_call(node) or model_value_from_constructor_call(node)
|
||||
|
||||
|
||||
def model_name_from_dump_response(node: ast.AST) -> str | None:
|
||||
if not isinstance(node, ast.Call):
|
||||
return None
|
||||
@@ -287,7 +309,7 @@ def model_name_from_dump_response(node: ast.AST) -> str | None:
|
||||
|
||||
|
||||
def actual_kind_from_expr(
|
||||
expr: ast.AST | None, variable_models: dict[str, str] | None = None
|
||||
expr: ast.AST | None, variable_models: dict[str, tuple[str, ModelValueSource]] | None = None
|
||||
) -> tuple[ActualKind, str | None]:
|
||||
if expr is None:
|
||||
return "none", None
|
||||
@@ -299,10 +321,14 @@ def actual_kind_from_expr(
|
||||
if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute) and expr.func.attr == "model_dump":
|
||||
dumped_value = expr.func.value
|
||||
if isinstance(dumped_value, ast.Name) and variable_models:
|
||||
# A variable dump can match today, but it bypasses dump_response and
|
||||
# is easier to drift; keep it visible as refactorable.
|
||||
model_name = variable_models.get(dumped_value.id)
|
||||
if model_name:
|
||||
model_assignment = variable_models.get(dumped_value.id)
|
||||
if model_assignment:
|
||||
model_name, source = model_assignment
|
||||
if source == "constructor":
|
||||
return "model", model_name
|
||||
# A variable dump from model_validate can match today, but it
|
||||
# bypasses dump_response and is easier to drift; keep it visible
|
||||
# as refactorable.
|
||||
return "model_dump_variable", model_name
|
||||
|
||||
model_dump_model = model_name_from_model_dump(expr)
|
||||
@@ -325,7 +351,9 @@ def actual_kind_from_expr(
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def actual_response_from_return(return_node: ast.Return, variable_models: dict[str, str]) -> ActualResponse:
|
||||
def actual_response_from_return(
|
||||
return_node: ast.Return, variable_models: dict[str, tuple[str, ModelValueSource]]
|
||||
) -> ActualResponse:
|
||||
status: int | None = 200
|
||||
body_expr = return_node.value
|
||||
|
||||
@@ -363,18 +391,21 @@ def target_names(target: ast.AST) -> Iterable[str]:
|
||||
|
||||
|
||||
def record_assignment(
|
||||
assignments: defaultdict[str, VariableAssignmentSummary], targets: Iterable[str], model_name: str | None
|
||||
assignments: defaultdict[str, VariableAssignmentSummary],
|
||||
targets: Iterable[str],
|
||||
model_assignment: tuple[str, ModelValueSource] | None,
|
||||
) -> None:
|
||||
for target in targets:
|
||||
if model_name is None:
|
||||
if model_assignment is None:
|
||||
# Once a name receives an unknown value, later model_dump() calls on it
|
||||
# are no longer a reliable signal for the returned schema.
|
||||
assignments[target].add_unknown()
|
||||
else:
|
||||
assignments[target].add_known(model_name)
|
||||
model_name, source = model_assignment
|
||||
assignments[target].add_known(model_name, source)
|
||||
|
||||
|
||||
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
|
||||
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple[str, ModelValueSource]]:
|
||||
"""Infer local variables that are unambiguously assigned one response model."""
|
||||
|
||||
assignments: defaultdict[str, VariableAssignmentSummary] = defaultdict(VariableAssignmentSummary)
|
||||
@@ -385,10 +416,10 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
|
||||
record_assignment(
|
||||
assignments,
|
||||
(name for target in targets for name in target_names(target)),
|
||||
model_name_from_model_value(value),
|
||||
model_value_from_model_value(value),
|
||||
)
|
||||
case ast.AnnAssign(target=target, value=value) if value is not None:
|
||||
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
|
||||
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
|
||||
case ast.AugAssign(target=target) | ast.For(target=target) | ast.AsyncFor(target=target):
|
||||
# Mutation and loop targets overwrite prior values with runtime-dependent data.
|
||||
record_assignment(assignments, target_names(target), None)
|
||||
@@ -399,9 +430,13 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
|
||||
case ast.ExceptHandler(name=name) if name:
|
||||
assignments[name].add_unknown()
|
||||
case ast.NamedExpr(target=target, value=value):
|
||||
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
|
||||
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
|
||||
|
||||
return {name: model for name, summary in assignments.items() if (model := summary.single_known_model()) is not None}
|
||||
return {
|
||||
name: assignment
|
||||
for name, summary in assignments.items()
|
||||
if (assignment := summary.single_known_model()) is not None
|
||||
}
|
||||
|
||||
|
||||
def actual_responses_for_method(method: MethodNode) -> list[ActualResponse]:
|
||||
@@ -545,13 +580,52 @@ def iter_controller_files(paths: Iterable[Path]) -> Iterable[Path]:
|
||||
yield from sorted(child for child in path.rglob("*.py") if child.is_file())
|
||||
|
||||
|
||||
def node_start_lineno(node: ast.ClassDef | MethodNode) -> int:
|
||||
decorator_lines = [decorator.lineno for decorator in node.decorator_list]
|
||||
if decorator_lines:
|
||||
return min(decorator_lines)
|
||||
return node.lineno
|
||||
|
||||
|
||||
def line_has_ignore_marker(line: str) -> bool:
|
||||
_, marker, comment = line.partition("#")
|
||||
if not marker:
|
||||
return False
|
||||
normalized = comment.lower()
|
||||
return any(ignore_marker in normalized for ignore_marker in IGNORE_COMMENT_MARKERS)
|
||||
|
||||
|
||||
def node_has_ignore_comment(lines: Sequence[str], node: ast.ClassDef | MethodNode) -> bool:
|
||||
start = node_start_lineno(node)
|
||||
end = node.end_lineno or node.lineno
|
||||
if any(line_has_ignore_marker(line) for line in lines[start - 1 : end]):
|
||||
return True
|
||||
|
||||
line_index = start - 2
|
||||
while line_index >= 0:
|
||||
stripped = lines[line_index].strip()
|
||||
if not stripped:
|
||||
line_index -= 1
|
||||
continue
|
||||
if not stripped.startswith("#"):
|
||||
break
|
||||
if line_has_ignore_marker(lines[line_index]):
|
||||
return True
|
||||
line_index -= 1
|
||||
return False
|
||||
|
||||
|
||||
def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
|
||||
module = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
lines = source.splitlines()
|
||||
module = ast.parse(source, filename=str(file_path))
|
||||
checks: list[ContractCheck] = []
|
||||
|
||||
for node in module.body:
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
if node_has_ignore_comment(lines, node):
|
||||
continue
|
||||
|
||||
class_routes = routes_from_decorators(node.decorator_list)
|
||||
class_documented = response_docs_from_decorators(node.decorator_list)
|
||||
@@ -559,6 +633,8 @@ def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
|
||||
for item in node.body:
|
||||
if not isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) or item.name not in HTTP_METHODS:
|
||||
continue
|
||||
if node_has_ignore_comment(lines, item):
|
||||
continue
|
||||
|
||||
routes = routes_from_decorators(item.decorator_list) or class_routes
|
||||
if not routes:
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ class ResponseModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
validate_by_name=True,
|
||||
validate_by_alias=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
+26
-10
@@ -236,6 +236,16 @@ 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
|
||||
# ============================================================================
|
||||
@@ -343,13 +353,15 @@ 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 | Literal["invalid"]:
|
||||
def cache_get(self, token_hash: str) -> ResolvedRow | None | NegativeCache:
|
||||
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
|
||||
if text == "invalid":
|
||||
return "invalid"
|
||||
try:
|
||||
return NegativeCache(text)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return ResolvedRow.from_cache(json.loads(text))
|
||||
except (ValueError, KeyError):
|
||||
@@ -363,8 +375,8 @@ class OAuthAccessTokenResolver:
|
||||
json.dumps(row.to_cache()),
|
||||
)
|
||||
|
||||
def cache_set_negative(self, token_hash: str) -> None:
|
||||
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, "invalid")
|
||||
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 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;
|
||||
@@ -385,7 +397,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)
|
||||
self.cache_set_negative(token_hash, NegativeCache.EXPIRED)
|
||||
|
||||
|
||||
class _VariantResolver:
|
||||
@@ -395,9 +407,11 @@ class _VariantResolver:
|
||||
|
||||
def resolve(self, token_hash: str) -> ResolvedRow | None:
|
||||
cached = self._parent.cache_get(token_hash)
|
||||
if cached == "invalid":
|
||||
if isinstance(cached, NegativeCache):
|
||||
if cached is NegativeCache.EXPIRED:
|
||||
raise TokenExpiredError("token_expired")
|
||||
return None
|
||||
if cached is not None and not isinstance(cached, str):
|
||||
if cached is not None:
|
||||
if not self._matches_variant(cached):
|
||||
return None
|
||||
return cached
|
||||
@@ -413,7 +427,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)
|
||||
return None
|
||||
raise TokenExpiredError("token_expired")
|
||||
|
||||
if not self._matches_variant_model(row):
|
||||
logger.error(
|
||||
@@ -472,7 +486,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 == "invalid":
|
||||
if text in (NegativeCache.INVALID, NegativeCache.EXPIRED):
|
||||
return
|
||||
try:
|
||||
data = json.loads(text)
|
||||
@@ -601,6 +615,8 @@ def validate_bearer(*, accept: frozenset[Accepts]) -> Callable[[Callable[_DP, _D
|
||||
|
||||
try:
|
||||
ctx = get_authenticator().authenticate(token)
|
||||
except TokenExpiredError:
|
||||
raise Unauthorized("token_expired")
|
||||
except InvalidBearerError as e:
|
||||
raise Unauthorized(str(e))
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARNING ")
|
||||
_DIAGNOSTIC_PREFIXES = ("ERROR ", "WARN ", "WARNING ")
|
||||
_LOCATION_PREFIX = "-->"
|
||||
|
||||
|
||||
@@ -13,7 +14,7 @@ def extract_diagnostics(raw_output: str) -> str:
|
||||
|
||||
The full pyrefly output includes code excerpts and carets, which create noisy
|
||||
diffs. This helper keeps only:
|
||||
- diagnostic headline lines (``ERROR ...`` / ``WARNING ...``)
|
||||
- diagnostic headline lines (``ERROR ...`` / ``WARN ...`` / ``WARNING ...``)
|
||||
- the following location line (``--> path:line:column``), when present
|
||||
"""
|
||||
|
||||
@@ -36,11 +37,28 @@ def extract_diagnostics(raw_output: str) -> str:
|
||||
return "\n".join(diagnostics) + "\n"
|
||||
|
||||
|
||||
def render_diagnostics(raw_output: str, exit_code: int) -> str:
|
||||
"""Render concise diagnostics and fall back to raw output on unmatched failures."""
|
||||
|
||||
diagnostics = extract_diagnostics(raw_output)
|
||||
if diagnostics:
|
||||
return diagnostics
|
||||
|
||||
if exit_code != 0:
|
||||
return raw_output
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Read pyrefly output from stdin and print normalized diagnostics."""
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--status", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
raw_output = sys.stdin.read()
|
||||
sys.stdout.write(extract_diagnostics(raw_output))
|
||||
sys.stdout.write(render_diagnostics(raw_output, exit_code=args.status))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -2868,6 +2868,7 @@ Delete an existing tracing configuration for an application
|
||||
| ---- | ----------- |
|
||||
| 204 | Tracing configuration deleted successfully |
|
||||
| 400 | Invalid request parameters or configuration not found |
|
||||
| 403 | Insufficient permissions |
|
||||
|
||||
### [GET] /apps/{app_id}/trace-config
|
||||
Get tracing configuration for an application
|
||||
@@ -2909,6 +2910,7 @@ Update an existing tracing configuration for an application
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Tracing configuration updated successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)<br> |
|
||||
| 400 | Invalid request parameters or configuration not found | |
|
||||
| 403 | Insufficient permissions | |
|
||||
|
||||
### [POST] /apps/{app_id}/trace-config
|
||||
**Create a new trace app configuration**
|
||||
@@ -2933,6 +2935,7 @@ Create a new tracing configuration for an application
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Tracing configuration created successfully | **application/json**: [TraceAppConfigResponse](#traceappconfigresponse)<br> |
|
||||
| 400 | Invalid request parameters or configuration already exists | |
|
||||
| 403 | Insufficient permissions | |
|
||||
|
||||
### [POST] /apps/{app_id}/trigger-enable
|
||||
**Update app trigger (enable/disable)**
|
||||
@@ -13429,7 +13432,6 @@ Soft lifecycle state for Agent records.
|
||||
| created_at | integer | | No |
|
||||
| files | [ string ] | | Yes |
|
||||
| id | string | | Yes |
|
||||
| message_chain_id | string | | No |
|
||||
| message_id | string | | Yes |
|
||||
| observation | string | | No |
|
||||
| position | integer | | Yes |
|
||||
@@ -14540,8 +14542,8 @@ Enum class for configurate method of provider model.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| annotation_create_account | [SimpleAccount](#simpleaccount) | | No |
|
||||
| annotation_id | string | | Yes |
|
||||
| created_at | integer | | No |
|
||||
| id | string | | Yes |
|
||||
|
||||
#### ConversationDetail
|
||||
|
||||
@@ -16710,6 +16712,7 @@ Input field definition for snippet parameters.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -17079,6 +17082,7 @@ Enum class for large language model mode.
|
||||
| agent_thoughts | [ [AgentThought](#agentthought) ] | | No |
|
||||
| annotation | [ConversationAnnotation](#conversationannotation) | | No |
|
||||
| annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No |
|
||||
| answer | string | | Yes |
|
||||
| answer_tokens | integer | | No |
|
||||
| conversation_id | string | | Yes |
|
||||
| created_at | integer | | No |
|
||||
@@ -17092,12 +17096,11 @@ Enum class for large language model mode.
|
||||
| inputs | object | | Yes |
|
||||
| message | [JSONValue](#jsonvalue) | | No |
|
||||
| message_files | [ [MessageFile](#messagefile) ] | | No |
|
||||
| message_metadata_dict | [JSONValue](#jsonvalue) | | No |
|
||||
| message_tokens | integer | | No |
|
||||
| metadata | [JSONValue](#jsonvalue) | | No |
|
||||
| parent_message_id | string | | No |
|
||||
| provider_response_latency | number | | No |
|
||||
| query | string | | Yes |
|
||||
| re_sign_file_url_answer | string | | Yes |
|
||||
| status | string | | Yes |
|
||||
| workflow_run_id | string | | No |
|
||||
|
||||
|
||||
@@ -990,6 +990,12 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
|
||||
| last_used_at | string | | No |
|
||||
| prefix | string | | Yes |
|
||||
|
||||
#### SimpleResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### SupportedAppType
|
||||
|
||||
App types the ``app`` usage face (``get app``) lists and filters.
|
||||
|
||||
@@ -72,6 +72,7 @@ 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(
|
||||
@@ -82,7 +83,7 @@ def mint_token(flask_app: Flask):
|
||||
subject_issuer=subject_issuer,
|
||||
client_id="difyctl",
|
||||
device_label="test-device",
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
expires_at=expires_at or (datetime.now(UTC) + timedelta(hours=1)),
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
@@ -111,6 +112,21 @@ 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,6 +6,7 @@ acceptance/rejection on app-scoped routes.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,50 @@ 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,7 +27,6 @@ extend-select = ["ANN401", "ARG", "TID251"]
|
||||
"controllers/web/test_wraps.py" = ["ARG"]
|
||||
"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"]
|
||||
"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"]
|
||||
"models/test_account.py" = ["ARG"]
|
||||
"models/test_conversation_message_inputs.py" = ["ARG"]
|
||||
"models/test_conversation_status_count.py" = ["ARG"]
|
||||
"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"]
|
||||
|
||||
@@ -3,6 +3,7 @@ Integration tests for Account and Tenant model methods that interact with the da
|
||||
|
||||
Migrated from unit_tests/models/test_account_models.py, replacing
|
||||
@patch("models.account.db") mock patches with real PostgreSQL operations.
|
||||
Also absorbs unit_tests/models/test_account.py role helper coverage.
|
||||
|
||||
Covers:
|
||||
- Account.current_tenant setter (sets _current_tenant and role from TenantAccountJoin)
|
||||
@@ -12,6 +13,7 @@ Covers:
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -20,8 +22,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models.account import Account, AccountIntegrate, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
|
||||
TrackedRow = Account | AccountIntegrate | Tenant | TenantAccountJoin
|
||||
|
||||
def _cleanup_tracked_rows(db_session: Session, tracked: list) -> None:
|
||||
|
||||
def _cleanup_tracked_rows(db_session: Session, tracked: list[TrackedRow]) -> None:
|
||||
"""Delete rows tracked during the test so committed state does not leak into the DB.
|
||||
|
||||
Rolls back any pending (uncommitted) session state first, then issues DELETE
|
||||
@@ -52,7 +56,7 @@ def _build_account(email_prefix: str = "account") -> Account:
|
||||
class _DBTrackingTestBase:
|
||||
"""Base class providing a tracker list and shared row factories for account/tenant tests."""
|
||||
|
||||
_tracked: list
|
||||
_tracked: list[TrackedRow]
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_cleanup(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
@@ -84,6 +88,22 @@ class _DBTrackingTestBase:
|
||||
return join
|
||||
|
||||
|
||||
class TestTenantAccountRole:
|
||||
"""Tests for TenantAccountRole helper methods."""
|
||||
|
||||
def test_account_is_privileged_role(self) -> None:
|
||||
assert TenantAccountRole.ADMIN == "admin"
|
||||
assert TenantAccountRole.OWNER == "owner"
|
||||
assert TenantAccountRole.EDITOR == "editor"
|
||||
assert TenantAccountRole.NORMAL == "normal"
|
||||
|
||||
assert TenantAccountRole.is_privileged_role(TenantAccountRole.ADMIN)
|
||||
assert TenantAccountRole.is_privileged_role(TenantAccountRole.OWNER)
|
||||
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.NORMAL)
|
||||
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.EDITOR)
|
||||
assert not TenantAccountRole.is_privileged_role(cast(TenantAccountRole, ""))
|
||||
|
||||
|
||||
class TestAccountCurrentTenantSetter(_DBTrackingTestBase):
|
||||
"""Integration tests for Account.current_tenant property setter."""
|
||||
|
||||
@@ -176,7 +196,7 @@ class TestAccountGetByOpenId(_DBTrackingTestBase):
|
||||
assert result is not None
|
||||
assert result.id == account.id
|
||||
|
||||
def test_get_by_openid_returns_none_when_no_integrate_exists(self, db_session_with_containers: Session) -> None:
|
||||
def test_get_by_openid_returns_none_when_no_integrate_exists(self) -> None:
|
||||
"""get_by_openid returns None when no AccountIntegrate row matches."""
|
||||
result = Account.get_by_openid("github", f"github_{uuid4()}")
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ project-excludes = [
|
||||
"libs/broadcast_channel/redis/test_streams_channel.py",
|
||||
"libs/test_auto_renew_redis_lock_integration.py",
|
||||
"libs/test_rate_limiter_integration.py",
|
||||
"models/test_account.py",
|
||||
"models/test_conversation_message_inputs.py",
|
||||
"models/test_types_enum_text.py",
|
||||
"repositories/test_sqlalchemy_api_workflow_node_execution_repository.py",
|
||||
|
||||
@@ -77,6 +77,25 @@ class AnnotationApi(Resource):
|
||||
assert "prefer dump_response" in checks[0].reason
|
||||
|
||||
|
||||
def test_constructor_variable_model_dump_is_valid(tmp_path: Path):
|
||||
checks = _checks_for_source(
|
||||
tmp_path,
|
||||
"""
|
||||
@ns.route("/annotations")
|
||||
class AnnotationApi(Resource):
|
||||
@ns.response(201, "Created", ns.models[AnnotationResponse.__name__])
|
||||
def post(self):
|
||||
response = AnnotationResponse(id="new", name=name)
|
||||
return response.model_dump(mode="json"), 201
|
||||
""",
|
||||
)
|
||||
|
||||
assert len(checks) == 1
|
||||
assert checks[0].classification == "valid"
|
||||
assert checks[0].actual[0].kind == "model"
|
||||
assert checks[0].actual[0].model == "AnnotationResponse"
|
||||
|
||||
|
||||
def test_variable_model_dump_with_wrong_documented_schema_is_mismatch(tmp_path: Path):
|
||||
checks = _checks_for_source(
|
||||
tmp_path,
|
||||
@@ -117,6 +136,38 @@ class StreamApi(Resource):
|
||||
assert {actual.model for actual in checks[0].actual} == {"StreamResponse"}
|
||||
|
||||
|
||||
def test_response_contract_ignore_comment_skips_route_method(tmp_path: Path):
|
||||
checks = _checks_for_source(
|
||||
tmp_path,
|
||||
"""
|
||||
@ns.route("/binary")
|
||||
class BinaryApi(Resource):
|
||||
# response-contract:ignore binary response
|
||||
@ns.response(200, "Binary file")
|
||||
def get(self):
|
||||
return send_file(path)
|
||||
|
||||
|
||||
# response-contract:ignore compact Flask response
|
||||
@ns.route("/compact")
|
||||
class CompactApi(Resource):
|
||||
def get(self):
|
||||
return make_response({"url": "https://example.com"})
|
||||
|
||||
|
||||
@ns.route("/regular")
|
||||
class RegularApi(Resource):
|
||||
@ns.response(200, "OK", ns.models[RegularResponse.__name__])
|
||||
def get(self):
|
||||
return dump_response(RegularResponse, {})
|
||||
""",
|
||||
)
|
||||
|
||||
assert len(checks) == 1
|
||||
assert checks[0].class_name == "RegularApi"
|
||||
assert checks[0].classification == "valid"
|
||||
|
||||
|
||||
def test_main_is_report_only_by_default_for_mismatches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
module = _load_lint_response_contracts_module()
|
||||
controller_path = tmp_path / "controllers" / "sample.py"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common import wraps as common_wraps
|
||||
from controllers.console import console_ns
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console.app import ops_trace as ops_trace_module
|
||||
from controllers.console.app import wraps as app_wraps
|
||||
from libs import login as login_lib
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
|
||||
|
||||
def _make_account(role: TenantAccountRole) -> Account:
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.id = "account-123" # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.role = role
|
||||
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[assignment]
|
||||
account._get_current_object = lambda: account # type: ignore[attr-defined]
|
||||
return account
|
||||
|
||||
|
||||
def _make_app() -> SimpleNamespace:
|
||||
return SimpleNamespace(id="app-123", tenant_id="tenant-123", status="normal", mode="chat")
|
||||
|
||||
|
||||
def _patch_console_guards(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account: Account,
|
||||
app_model: SimpleNamespace,
|
||||
*,
|
||||
rbac_enabled: bool = False,
|
||||
) -> None:
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
monkeypatch.setattr(login_lib.dify_config, "RBAC_ENABLED", rbac_enabled)
|
||||
monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(common_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model)
|
||||
|
||||
|
||||
def _patch_payload(payload: dict[str, object] | None):
|
||||
if payload is None:
|
||||
return nullcontext()
|
||||
return patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "path", "payload", "service_method_name", "service_result"),
|
||||
[
|
||||
(
|
||||
"post",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"create_tracing_app_config",
|
||||
{"id": "trace-config-1"},
|
||||
),
|
||||
(
|
||||
"patch",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"update_tracing_app_config",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"delete",
|
||||
"/console/api/apps/app-123/trace-config?tracing_provider=mlflow",
|
||||
None,
|
||||
"delete_tracing_app_config",
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_config_mutations_require_edit_permission(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method_name: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None,
|
||||
service_method_name: str,
|
||||
service_result: object,
|
||||
) -> None:
|
||||
app.config.setdefault("RESTX_MASK_HEADER", "X-Fields")
|
||||
account = _make_account(TenantAccountRole.NORMAL)
|
||||
_patch_console_guards(monkeypatch, account, _make_app())
|
||||
service_mock = MagicMock(return_value=service_result)
|
||||
monkeypatch.setattr(ops_trace_module.OpsService, service_method_name, service_mock)
|
||||
|
||||
with app.test_request_context(path, method=method_name.upper(), json=payload):
|
||||
with _patch_payload(payload):
|
||||
with pytest.raises(Forbidden):
|
||||
getattr(ops_trace_module.TraceAppConfigApi(), method_name)(app_id="app-123")
|
||||
|
||||
service_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "path", "payload", "service_method_name", "service_result"),
|
||||
[
|
||||
(
|
||||
"post",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"create_tracing_app_config",
|
||||
{"id": "trace-config-1"},
|
||||
),
|
||||
(
|
||||
"patch",
|
||||
"/console/api/apps/app-123/trace-config",
|
||||
{"tracing_provider": "mlflow", "tracing_config": {"endpoint": "https://trace.example.com"}},
|
||||
"update_tracing_app_config",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"delete",
|
||||
"/console/api/apps/app-123/trace-config?tracing_provider=mlflow",
|
||||
None,
|
||||
"delete_tracing_app_config",
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_config_mutations_require_rbac_permission(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method_name: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None,
|
||||
service_method_name: str,
|
||||
service_result: object,
|
||||
) -> None:
|
||||
app.config.setdefault("RESTX_MASK_HEADER", "X-Fields")
|
||||
account = _make_account(TenantAccountRole.NORMAL)
|
||||
_patch_console_guards(monkeypatch, account, _make_app(), rbac_enabled=True)
|
||||
monkeypatch.setattr(common_wraps.db, "session", SimpleNamespace(scalar=lambda _stmt: "other-account"))
|
||||
monkeypatch.setattr(common_wraps.RBACService.CheckAccess, "check", MagicMock(return_value=False))
|
||||
service_mock = MagicMock(return_value=service_result)
|
||||
monkeypatch.setattr(ops_trace_module.OpsService, service_method_name, service_mock)
|
||||
|
||||
with app.test_request_context(path, method=method_name.upper(), json=payload):
|
||||
with _patch_payload(payload):
|
||||
with pytest.raises(Forbidden):
|
||||
getattr(ops_trace_module.TraceAppConfigApi(), method_name)(app_id="app-123")
|
||||
|
||||
service_mock.assert_not_called()
|
||||
@@ -22,23 +22,17 @@ class TestSpecSchemaDefinitionsApi:
|
||||
assert status == 200
|
||||
assert resp == schema_definitions
|
||||
|
||||
def test_get_exception_returns_empty_list(self):
|
||||
def test_get_exception_returns_empty_list(self, caplog):
|
||||
api = spec_module.SpecSchemaDefinitionsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
spec_module,
|
||||
"SchemaManager",
|
||||
side_effect=Exception("boom"),
|
||||
),
|
||||
patch.object(
|
||||
spec_module.logger,
|
||||
"exception",
|
||||
) as log_exception,
|
||||
with patch.object(
|
||||
spec_module,
|
||||
"SchemaManager",
|
||||
side_effect=Exception("boom"),
|
||||
):
|
||||
resp, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert resp == []
|
||||
log_exception.assert_called_once()
|
||||
assert "boom" in caplog.text
|
||||
|
||||
@@ -321,3 +321,56 @@ 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,13 +3,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.openapi._models import AppRunRequest
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
|
||||
_TEST_APP_ID = str(uuid.uuid4())
|
||||
_TEST_TENANT_ID = str(uuid.uuid4())
|
||||
_TEST_ACCOUNT_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
def _make_app() -> App:
|
||||
app = App()
|
||||
app.id = _TEST_APP_ID
|
||||
app.tenant_id = _TEST_TENANT_ID
|
||||
app.name = "Streaming app"
|
||||
app.mode = AppMode.CHAT
|
||||
app.enable_site = False
|
||||
app.enable_api = True
|
||||
return app
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
account = Account(name="OpenAPI caller", email="caller@example.com")
|
||||
account.id = _TEST_ACCOUNT_ID
|
||||
return account
|
||||
|
||||
|
||||
def test_app_run_request_has_no_response_mode_field():
|
||||
@@ -40,15 +63,19 @@ def test_run_chat_always_calls_generate_with_streaming_true(
|
||||
from controllers.openapi.app_run import _run_chat
|
||||
|
||||
generate_mock = Mock(return_value=iter([]))
|
||||
|
||||
class GenerateService:
|
||||
generate = generate_mock
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.app_run"],
|
||||
"AppGenerateService",
|
||||
SimpleNamespace(generate=generate_mock),
|
||||
GenerateService,
|
||||
)
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/run", method="POST"):
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/run", method="POST"):
|
||||
_run_chat(
|
||||
SimpleNamespace(id="app-1", tenant_id="t-1"),
|
||||
SimpleNamespace(id="acct-1"),
|
||||
_make_app(),
|
||||
_make_account(),
|
||||
AppRunRequest(inputs={}, query="hello"),
|
||||
)
|
||||
_, kwargs = generate_mock.call_args
|
||||
@@ -80,11 +107,11 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel
|
||||
|
||||
auth_data = AuthData.model_construct(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
account_id=uuid.uuid4(),
|
||||
account_id=uuid.UUID(_TEST_ACCOUNT_ID),
|
||||
token_hash="test",
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
app=SimpleNamespace(id="app-1", tenant_id="t-1"),
|
||||
caller=SimpleNamespace(id="acct-1"),
|
||||
app=_make_app(),
|
||||
caller=_make_account(),
|
||||
caller_kind="account",
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ view function decorated with @accepts/@returns, driven inside a request context.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -100,7 +101,7 @@ def test_accepts_validation_error_is_sanitized_and_structured(app):
|
||||
with pytest.raises(UnprocessableEntity) as exc_info:
|
||||
view()
|
||||
|
||||
data = exc_info.value.data
|
||||
data = cast(dict[str, Any], cast(Any, exc_info.value).data)
|
||||
assert data["message"] == "Request validation failed"
|
||||
assert isinstance(data["errors"], list)
|
||||
assert data["errors"]
|
||||
|
||||
@@ -33,6 +33,7 @@ from controllers.openapi._errors import (
|
||||
OpenApiErrorCode,
|
||||
OpenApiErrorFormatter,
|
||||
RecipientSurfaceMismatch,
|
||||
SessionExpired,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
@@ -353,3 +354,20 @@ 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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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
|
||||
from libs.pyrefly_diagnostics import extract_diagnostics, render_diagnostics
|
||||
|
||||
|
||||
def test_extract_diagnostics_keeps_only_summary_and_location_lines() -> None:
|
||||
@@ -40,6 +40,37 @@ def test_extract_diagnostics_handles_error_without_location_line() -> None:
|
||||
assert diagnostics == "ERROR unexpected pyrefly output format [bad-format]\n"
|
||||
|
||||
|
||||
def test_extract_diagnostics_keeps_warn_headlines_and_location_lines() -> None:
|
||||
# Arrange
|
||||
raw_output = """INFO Checking project configured at `/tmp/project/pyrefly.toml`
|
||||
WARN Skipping include pattern `/tmp/project/tests` because it is matched by `project-excludes`.
|
||||
--> tests/test_containers_integration_tests/pyrefly.toml:3:1
|
||||
"""
|
||||
|
||||
# Act
|
||||
diagnostics = extract_diagnostics(raw_output)
|
||||
|
||||
# Assert
|
||||
assert diagnostics == (
|
||||
"WARN Skipping include pattern `/tmp/project/tests` because it is matched by `project-excludes`.\n"
|
||||
" --> tests/test_containers_integration_tests/pyrefly.toml:3:1\n"
|
||||
)
|
||||
|
||||
|
||||
def test_render_diagnostics_falls_back_to_raw_output_for_nonzero_exit_without_matches() -> None:
|
||||
# Arrange
|
||||
raw_output = (
|
||||
"INFO Checking project configured at `/tmp/project/pyrefly.toml`\n"
|
||||
"No Python files matched pattern `/tmp/project/tests/test_containers_integration_tests`\n"
|
||||
)
|
||||
|
||||
# Act
|
||||
diagnostics = render_diagnostics(raw_output, exit_code=1)
|
||||
|
||||
# Assert
|
||||
assert diagnostics == raw_output
|
||||
|
||||
|
||||
def test_extract_diagnostics_returns_empty_for_non_error_output() -> None:
|
||||
# Arrange
|
||||
raw_output = "INFO Checking project configured at `/tmp/project/pyrefly.toml`\n"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
|
||||
def test_account_is_privileged_role():
|
||||
assert TenantAccountRole.ADMIN == "admin"
|
||||
assert TenantAccountRole.OWNER == "owner"
|
||||
assert TenantAccountRole.EDITOR == "editor"
|
||||
assert TenantAccountRole.NORMAL == "normal"
|
||||
|
||||
assert TenantAccountRole.is_privileged_role(TenantAccountRole.ADMIN)
|
||||
assert TenantAccountRole.is_privileged_role(TenantAccountRole.OWNER)
|
||||
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.NORMAL)
|
||||
assert not TenantAccountRole.is_privileged_role(TenantAccountRole.EDITOR)
|
||||
assert not TenantAccountRole.is_privileged_role("")
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -427,16 +428,20 @@ class TestWorkflowCollaborationService:
|
||||
repository.delete_leader.assert_not_called()
|
||||
|
||||
def test_broadcast_leader_change_logs_emit_errors(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
self,
|
||||
service: tuple[WorkflowCollaborationService, Mock, Mock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
collaboration_service, repository, socketio = service
|
||||
repository.get_session_sids.return_value = ["sid-1", "sid-2"]
|
||||
socketio.emit.side_effect = [RuntimeError("boom"), None]
|
||||
|
||||
with patch("services.workflow_collaboration_service.logging.exception") as exception_mock:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
collaboration_service.broadcast_leader_change("wf-1", "sid-2")
|
||||
|
||||
assert exception_mock.call_count == 1
|
||||
error_records = [record for record in caplog.records if record.levelno == logging.ERROR]
|
||||
assert len(error_records) == 1
|
||||
assert "Failed to emit leader status to session sid-1" in error_records[0].getMessage()
|
||||
|
||||
def test_broadcast_online_users_sorts_and_emits(
|
||||
self, service: tuple[WorkflowCollaborationService, Mock, Mock]
|
||||
|
||||
@@ -89,7 +89,7 @@ async function pollWithRetry(
|
||||
|
||||
function expired(): BaseError {
|
||||
return new BaseError({
|
||||
code: ErrorCode.ExpiredToken,
|
||||
code: ErrorCode.TokenExpired,
|
||||
message: 'code expired before authorization',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,6 @@ describe('Version command', () => {
|
||||
if (output?.kind !== 'formatted')
|
||||
throw new Error('expected formatted output')
|
||||
|
||||
expect(output.data.text()).toContain('WARNING: This build is a rc release')
|
||||
expect(output.data.text()).toContain('WARNING: This build is a(n) rc release')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,6 @@ describe('error codes', () => {
|
||||
[ErrorCode.AuthExpired, ExitCode.Auth],
|
||||
[ErrorCode.TokenExpired, ExitCode.Auth],
|
||||
[ErrorCode.AccessDenied, ExitCode.Auth],
|
||||
[ErrorCode.ExpiredToken, ExitCode.Auth],
|
||||
[ErrorCode.VersionSkew, ExitCode.VersionCompat],
|
||||
[ErrorCode.UnsupportedEndpoint, ExitCode.VersionCompat],
|
||||
[ErrorCode.ConfigSchemaUnsupported, ExitCode.VersionCompat],
|
||||
|
||||
@@ -3,7 +3,6 @@ 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',
|
||||
@@ -40,7 +39,6 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
|
||||
auth_expired: ExitCode.Auth,
|
||||
token_expired: ExitCode.Auth,
|
||||
access_denied: ExitCode.Auth,
|
||||
expired_token: ExitCode.Auth,
|
||||
version_skew: ExitCode.VersionCompat,
|
||||
unsupported_endpoint: ExitCode.VersionCompat,
|
||||
config_schema_unsupported: ExitCode.VersionCompat,
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
expect(err.code).toBe(ErrorCode.Server4xxOther)
|
||||
})
|
||||
|
||||
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
|
||||
it('401 unauthorized classifies as AuthExpired with CLI login hint', async () => {
|
||||
const err = await classified(401, {
|
||||
code: 'unauthorized',
|
||||
message: 'session expired or revoked',
|
||||
@@ -44,6 +44,20 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
|
||||
})
|
||||
|
||||
it('401 token_expired carries the structured TokenExpired code with the server message', async () => {
|
||||
const err = await classified(401, {
|
||||
code: 'token_expired',
|
||||
message: 'Your session has expired.',
|
||||
status: 401,
|
||||
hint: 'Re-authenticate to continue (e.g. re-run your login command).',
|
||||
})
|
||||
|
||||
expect(err.code).toBe(ErrorCode.TokenExpired)
|
||||
expect(err.exit()).toBe(4)
|
||||
expect(err.message).toBe('Your session has expired.')
|
||||
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
|
||||
})
|
||||
|
||||
it('unknown future server code is data, not behavior — status bucket decides', async () => {
|
||||
const err = await classified(409, {
|
||||
code: 'some_future_code',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ErrorCodeValue } from '@/errors/codes'
|
||||
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
|
||||
import { zErrorBody, zOpenApiErrorCode } from '@dify/contracts/api/openapi/zod.gen'
|
||||
import { BaseError, HttpClientError, newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { redactBearer } from './sanitize'
|
||||
@@ -24,6 +24,16 @@ 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})`,
|
||||
@@ -50,9 +60,9 @@ const ACCESS_DENIED_CLASS: StatusClass = {
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
function statusClass(status: number): StatusClass {
|
||||
function statusClass(status: number, serverError?: ErrorBody): StatusClass {
|
||||
if (status === 401)
|
||||
return AUTH_EXPIRED_CLASS
|
||||
return serverError?.code === zOpenApiErrorCode.enum.token_expired ? TOKEN_EXPIRED_CLASS : AUTH_EXPIRED_CLASS
|
||||
if (status === 403)
|
||||
return ACCESS_DENIED_CLASS
|
||||
if (status === 429)
|
||||
@@ -87,7 +97,7 @@ export async function classifyResponse(request: Request, response: Response): Pr
|
||||
|
||||
const serverError = parseServerError(raw)
|
||||
const status = response.status
|
||||
const c = statusClass(status)
|
||||
const c = statusClass(status, serverError)
|
||||
return new HttpClientError({
|
||||
code: c.code,
|
||||
message: serverError?.message ?? c.fallbackMessage(status),
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('renderVersionText', () => {
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a rc release')
|
||||
expect(text).toContain('WARNING: This build is a(n) rc release')
|
||||
expect(text).toContain('install or wait for the stable channel')
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('renderVersionText', () => {
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a alpha release')
|
||||
expect(text).toContain('WARNING: This build is a(n) alpha release')
|
||||
expect(text).toContain('install or wait for the stable channel')
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('renderVersionText', () => {
|
||||
}
|
||||
const text = renderVersionText(report)
|
||||
|
||||
expect(text).toContain('WARNING: This build is a edge release')
|
||||
expect(text).toContain('WARNING: This build is a(n) edge release')
|
||||
expect(text).toContain('install or wait for the stable channel')
|
||||
})
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('renderVersionText', () => {
|
||||
// RC warning) ran, yet the output is byte-clean.
|
||||
expect(plain).not.toMatch(ANSI_RE)
|
||||
expect(plain).toContain('Compatibility: incompatible')
|
||||
expect(plain).toContain('WARNING: This build is a rc release')
|
||||
expect(plain).toContain('WARNING: This build is a(n) rc release')
|
||||
})
|
||||
|
||||
describe('with picocolors stubbed to always emit ANSI', () => {
|
||||
@@ -183,7 +183,7 @@ describe('renderVersionText', () => {
|
||||
expect(colored).toMatch(ANSI_RE)
|
||||
expect(colored).toContain('Compatibility: incompatible')
|
||||
// prerelease warning lines also routed through yellow.
|
||||
expect(colored).toContain('WARNING: This build is a rc release')
|
||||
expect(colored).toContain('WARNING: This build is a(n) rc release')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { colorScheme } from '@/sys/io/color'
|
||||
|
||||
function prereleaseWarning(channel: Channel): readonly string[] {
|
||||
return [
|
||||
`WARNING: This build is a ${channel} release. It is not stable`,
|
||||
`WARNING: This build is a(n) ${channel} release. It is not stable`,
|
||||
' and may have bugs. For production use, install or wait for the stable channel.',
|
||||
]
|
||||
}
|
||||
|
||||
+22
-4
@@ -28,6 +28,10 @@ pyrefly_args=(
|
||||
"--project-excludes=tests/"
|
||||
)
|
||||
|
||||
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
|
||||
pyrefly_args+=("--output-format=github")
|
||||
fi
|
||||
|
||||
if [[ -f "$EXCLUDES_FILE" ]]; then
|
||||
while IFS= read -r exclude; do
|
||||
[[ -z "$exclude" || "${exclude:0:1}" == "#" ]] && continue
|
||||
@@ -36,6 +40,14 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
|
||||
fi
|
||||
|
||||
run_pyrefly() {
|
||||
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
|
||||
set +e
|
||||
"$@"
|
||||
local pyrefly_status=$?
|
||||
set -e
|
||||
return "$pyrefly_status"
|
||||
fi
|
||||
|
||||
local tmp_output
|
||||
tmp_output="$(mktemp)"
|
||||
|
||||
@@ -44,7 +56,7 @@ run_pyrefly() {
|
||||
local pyrefly_status=$?
|
||||
set -e
|
||||
|
||||
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
|
||||
uv run --directory api python libs/pyrefly_diagnostics.py --status "$pyrefly_status" < "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
return "$pyrefly_status"
|
||||
}
|
||||
@@ -62,11 +74,17 @@ fi
|
||||
run_pyrefly "${pyrefly_command[@]}" || status=$?
|
||||
|
||||
if (( ${#target_paths[@]} == 0 )); then
|
||||
test_containers_args=(
|
||||
"--summary=none"
|
||||
"--use-ignore-files=false"
|
||||
"--config=$TEST_CONTAINERS_CONFIG"
|
||||
)
|
||||
if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then
|
||||
test_containers_args+=("--output-format=github")
|
||||
fi
|
||||
run_pyrefly \
|
||||
uv run --directory api --dev pyrefly check \
|
||||
"--summary=none" \
|
||||
"--use-ignore-files=false" \
|
||||
"--config=$TEST_CONTAINERS_CONFIG" \
|
||||
"${test_containers_args[@]}" \
|
||||
|| status=$?
|
||||
fi
|
||||
|
||||
|
||||
@@ -458,6 +458,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-access-control/access-control-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/sections.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -7120,6 +7128,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/__tests__/index.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/__tests__/use-app-access-control.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -7145,6 +7158,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/index.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control/use-app-access-control.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@@ -269,6 +269,7 @@ export type MessageDetailResponse = {
|
||||
agent_thoughts?: Array<AgentThought>
|
||||
annotation?: ConversationAnnotation | null
|
||||
annotation_hit_history?: ConversationAnnotationHitHistory | null
|
||||
answer: string
|
||||
answer_tokens?: number | null
|
||||
conversation_id: string
|
||||
created_at?: number | null
|
||||
@@ -284,12 +285,11 @@ export type MessageDetailResponse = {
|
||||
}
|
||||
message?: JsonValue | null
|
||||
message_files?: Array<MessageFile>
|
||||
message_metadata_dict?: JsonValue | null
|
||||
message_tokens?: number | null
|
||||
metadata?: JsonValue | null
|
||||
parent_message_id?: string | null
|
||||
provider_response_latency?: number | null
|
||||
query: string
|
||||
re_sign_file_url_answer: string
|
||||
status: string
|
||||
workflow_run_id?: string | null
|
||||
}
|
||||
@@ -723,7 +723,6 @@ export type AgentThought = {
|
||||
created_at?: number | null
|
||||
files: Array<string>
|
||||
id: string
|
||||
message_chain_id?: string | null
|
||||
message_id: string
|
||||
observation?: string | null
|
||||
position: number
|
||||
@@ -743,8 +742,8 @@ export type ConversationAnnotation = {
|
||||
|
||||
export type ConversationAnnotationHitHistory = {
|
||||
annotation_create_account?: SimpleAccount | null
|
||||
annotation_id: string
|
||||
created_at?: number | null
|
||||
id: string
|
||||
}
|
||||
|
||||
export type HumanInputContent = {
|
||||
|
||||
@@ -570,7 +570,6 @@ export const zAgentThought = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
files: z.array(z.string()),
|
||||
id: z.string(),
|
||||
message_chain_id: z.string().nullish(),
|
||||
message_id: z.string(),
|
||||
observation: z.string().nullish(),
|
||||
position: z.int(),
|
||||
@@ -1056,8 +1055,8 @@ export const zConversationAnnotation = z.object({
|
||||
*/
|
||||
export const zConversationAnnotationHitHistory = z.object({
|
||||
annotation_create_account: zSimpleAccount.nullish(),
|
||||
annotation_id: z.string(),
|
||||
created_at: z.int().nullish(),
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2035,6 +2034,7 @@ export const zMessageDetailResponse = z.object({
|
||||
agent_thoughts: z.array(zAgentThought).optional(),
|
||||
annotation: zConversationAnnotation.nullish(),
|
||||
annotation_hit_history: zConversationAnnotationHitHistory.nullish(),
|
||||
answer: z.string(),
|
||||
answer_tokens: z.int().nullish(),
|
||||
conversation_id: z.string(),
|
||||
created_at: z.int().nullish(),
|
||||
@@ -2048,12 +2048,11 @@ export const zMessageDetailResponse = z.object({
|
||||
inputs: z.record(z.string(), zJsonValue),
|
||||
message: zJsonValue.nullish(),
|
||||
message_files: z.array(zMessageFile).optional(),
|
||||
message_metadata_dict: zJsonValue.nullish(),
|
||||
message_tokens: z.int().nullish(),
|
||||
metadata: zJsonValue.nullish(),
|
||||
parent_message_id: z.string().nullish(),
|
||||
provider_response_latency: z.number().nullish(),
|
||||
query: z.string(),
|
||||
re_sign_file_url_answer: z.string(),
|
||||
status: z.string(),
|
||||
workflow_run_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
@@ -472,6 +472,7 @@ export type MessageDetailResponse = {
|
||||
agent_thoughts?: Array<AgentThought>
|
||||
annotation?: ConversationAnnotation | null
|
||||
annotation_hit_history?: ConversationAnnotationHitHistory | null
|
||||
answer: string
|
||||
answer_tokens?: number | null
|
||||
conversation_id: string
|
||||
created_at?: number | null
|
||||
@@ -487,12 +488,11 @@ export type MessageDetailResponse = {
|
||||
}
|
||||
message?: JsonValue | null
|
||||
message_files?: Array<MessageFile>
|
||||
message_metadata_dict?: JsonValue | null
|
||||
message_tokens?: number | null
|
||||
metadata?: JsonValue | null
|
||||
parent_message_id?: string | null
|
||||
provider_response_latency?: number | null
|
||||
query: string
|
||||
re_sign_file_url_answer: string
|
||||
status: string
|
||||
workflow_run_id?: string | null
|
||||
}
|
||||
@@ -1498,7 +1498,6 @@ export type AgentThought = {
|
||||
created_at?: number | null
|
||||
files: Array<string>
|
||||
id: string
|
||||
message_chain_id?: string | null
|
||||
message_id: string
|
||||
observation?: string | null
|
||||
position: number
|
||||
@@ -1518,8 +1517,8 @@ export type ConversationAnnotation = {
|
||||
|
||||
export type ConversationAnnotationHitHistory = {
|
||||
annotation_create_account?: SimpleAccount | null
|
||||
annotation_id: string
|
||||
created_at?: number | null
|
||||
id: string
|
||||
}
|
||||
|
||||
export type HumanInputContent = {
|
||||
@@ -4496,6 +4495,7 @@ export type DeleteAppsByAppIdTraceConfigData = {
|
||||
|
||||
export type DeleteAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdTraceConfigResponses = {
|
||||
@@ -4538,6 +4538,7 @@ export type PatchAppsByAppIdTraceConfigData = {
|
||||
|
||||
export type PatchAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type PatchAppsByAppIdTraceConfigResponses = {
|
||||
@@ -4558,6 +4559,7 @@ export type PostAppsByAppIdTraceConfigData = {
|
||||
|
||||
export type PostAppsByAppIdTraceConfigErrors = {
|
||||
400: unknown
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdTraceConfigResponses = {
|
||||
|
||||
@@ -1150,7 +1150,6 @@ export const zAgentThought = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
files: z.array(z.string()),
|
||||
id: z.string(),
|
||||
message_chain_id: z.string().nullish(),
|
||||
message_id: z.string(),
|
||||
observation: z.string().nullish(),
|
||||
position: z.int(),
|
||||
@@ -1371,8 +1370,8 @@ export const zConversationAnnotation = z.object({
|
||||
*/
|
||||
export const zConversationAnnotationHitHistory = z.object({
|
||||
annotation_create_account: zSimpleAccount.nullish(),
|
||||
annotation_id: z.string(),
|
||||
created_at: z.int().nullish(),
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -3455,6 +3454,7 @@ export const zMessageDetailResponse = z.object({
|
||||
agent_thoughts: z.array(zAgentThought).optional(),
|
||||
annotation: zConversationAnnotation.nullish(),
|
||||
annotation_hit_history: zConversationAnnotationHitHistory.nullish(),
|
||||
answer: z.string(),
|
||||
answer_tokens: z.int().nullish(),
|
||||
conversation_id: z.string(),
|
||||
created_at: z.int().nullish(),
|
||||
@@ -3468,12 +3468,11 @@ export const zMessageDetailResponse = z.object({
|
||||
inputs: z.record(z.string(), zJsonValue),
|
||||
message: zJsonValue.nullish(),
|
||||
message_files: z.array(zMessageFile).optional(),
|
||||
message_metadata_dict: zJsonValue.nullish(),
|
||||
message_tokens: z.int().nullish(),
|
||||
metadata: zJsonValue.nullish(),
|
||||
parent_message_id: z.string().nullish(),
|
||||
provider_response_latency: z.number().nullish(),
|
||||
query: z.string(),
|
||||
re_sign_file_url_answer: z.string(),
|
||||
status: z.string(),
|
||||
workflow_run_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
@@ -232,6 +232,7 @@ export type SavedMessageItem = {
|
||||
}
|
||||
|
||||
export type InstalledAppInfoResponse = {
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
@@ -246,7 +247,6 @@ export type AgentThought = {
|
||||
created_at?: number | null
|
||||
files: Array<string>
|
||||
id: string
|
||||
message_chain_id?: string | null
|
||||
message_id: string
|
||||
observation?: string | null
|
||||
position: number
|
||||
|
||||
@@ -229,6 +229,7 @@ export const zParameters = z.object({
|
||||
* InstalledAppInfoResponse
|
||||
*/
|
||||
export const zInstalledAppInfoResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
@@ -266,7 +267,6 @@ export const zAgentThought = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
files: z.array(z.string()),
|
||||
id: z.string(),
|
||||
message_chain_id: z.string().nullish(),
|
||||
message_id: z.string(),
|
||||
observation: z.string().nullish(),
|
||||
position: z.int(),
|
||||
|
||||
@@ -340,6 +340,7 @@ export type OpenApiErrorCode
|
||||
| 'rate_limit_error'
|
||||
| 'recipient_surface_mismatch'
|
||||
| 'request_entity_too_large'
|
||||
| 'token_expired'
|
||||
| 'too_many_files'
|
||||
| 'too_many_requests'
|
||||
| 'unauthorized'
|
||||
@@ -405,6 +406,10 @@ export type SessionRow = {
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
result: string
|
||||
}
|
||||
|
||||
export type SupportedAppType = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
|
||||
export type TaskStopResponse = {
|
||||
|
||||
@@ -423,6 +423,7 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'rate_limit_error',
|
||||
'recipient_surface_mismatch',
|
||||
'request_entity_too_large',
|
||||
'token_expired',
|
||||
'too_many_files',
|
||||
'too_many_requests',
|
||||
'unauthorized',
|
||||
@@ -501,6 +502,13 @@ export const zSessionListResponse = z.object({
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
export const zSimpleResultResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SupportedAppType
|
||||
*
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
zAccessServiceUpdateAccessPolicyBody,
|
||||
zAccessServiceUpdateAccessPolicyPath,
|
||||
zAccessServiceUpdateAccessPolicyResponse,
|
||||
zAccessSubjectServiceListAccessSubjectsQuery,
|
||||
zAccessSubjectServiceListAccessSubjectsResponse,
|
||||
zAppInstanceServiceCreateAppInstanceBody,
|
||||
zAppInstanceServiceCreateAppInstanceResponse,
|
||||
zAppInstanceServiceDeleteAppInstancePath,
|
||||
@@ -109,21 +107,6 @@ import {
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const listAccessSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessSubjectService_ListAccessSubjects',
|
||||
path: '/enterprise/access-subjects',
|
||||
tags: ['AccessSubjectService'],
|
||||
})
|
||||
.input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() }))
|
||||
.output(zAccessSubjectServiceListAccessSubjectsResponse)
|
||||
|
||||
export const accessSubjectService = {
|
||||
listAccessSubjects,
|
||||
}
|
||||
|
||||
export const listAppInstanceSummaries = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@@ -730,7 +713,6 @@ export const webAppAuth = {
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
accessSubjectService,
|
||||
appInstanceService,
|
||||
accessService,
|
||||
deploymentService,
|
||||
|
||||
@@ -1038,6 +1038,7 @@ export type UpdateEnvironmentRequest = {
|
||||
environmentId?: string
|
||||
displayName: string
|
||||
description?: string
|
||||
cpuCount?: number
|
||||
}
|
||||
|
||||
export type UpdateEnvironmentResponse = {
|
||||
@@ -1367,6 +1368,7 @@ export type InfoConfigReply = {
|
||||
Branding?: BrandingInfo
|
||||
WebAppAuth?: WebAppAuthInfo
|
||||
PluginInstallationPermission?: PluginInstallationPermissionInfo
|
||||
EnableAppDeploy?: boolean
|
||||
}
|
||||
|
||||
export type InnerAdmission = {
|
||||
@@ -1513,11 +1515,6 @@ export type LimitFields = {
|
||||
appRunnerEnvCpus?: ResourceQuota
|
||||
}
|
||||
|
||||
export type ListAccessSubjectsReply = {
|
||||
subjects?: Array<Subject>
|
||||
pagination?: Pagination
|
||||
}
|
||||
|
||||
export type ListGroupAppsResponse = {
|
||||
items?: Array<GroupAppItem>
|
||||
total?: string
|
||||
@@ -2138,25 +2135,6 @@ export type Pagination = {
|
||||
totalPages?: number
|
||||
}
|
||||
|
||||
export type AccessSubjectServiceListAccessSubjectsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
keyword?: string
|
||||
groupId?: string
|
||||
pageNumber?: number
|
||||
resultsPerPage?: number
|
||||
}
|
||||
url: '/enterprise/access-subjects'
|
||||
}
|
||||
|
||||
export type AccessSubjectServiceListAccessSubjectsResponses = {
|
||||
200: ListAccessSubjectsReply
|
||||
}
|
||||
|
||||
export type AccessSubjectServiceListAccessSubjectsResponse
|
||||
= AccessSubjectServiceListAccessSubjectsResponses[keyof AccessSubjectServiceListAccessSubjectsResponses]
|
||||
|
||||
export type AppInstanceServiceListAppInstanceSummariesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -912,6 +912,7 @@ export const zUpdateEnvironmentRequest = z.object({
|
||||
environmentId: z.string().optional(),
|
||||
displayName: z.string(),
|
||||
description: z.string().optional(),
|
||||
cpuCount: z.number().optional(),
|
||||
})
|
||||
|
||||
export const zUpdateEnvironmentResponse = z.object({
|
||||
@@ -2072,6 +2073,7 @@ export const zInfoConfigReply = z.object({
|
||||
Branding: zBrandingInfo.optional(),
|
||||
WebAppAuth: zWebAppAuthInfo.optional(),
|
||||
PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(),
|
||||
EnableAppDeploy: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const zWebOAuth2LoginReply = z.object({
|
||||
@@ -2243,11 +2245,6 @@ export const zListRollbackTargetsResponse = z.object({
|
||||
pagination: zPagination,
|
||||
})
|
||||
|
||||
export const zListAccessSubjectsReply = z.object({
|
||||
subjects: z.array(zSubject).optional(),
|
||||
pagination: zPagination.optional(),
|
||||
})
|
||||
|
||||
export const zListMembersReply = z.object({
|
||||
data: z.array(zAccountDetail).optional(),
|
||||
pagination: zPagination.optional(),
|
||||
@@ -2268,26 +2265,6 @@ export const zListWorkspacesReply = z.object({
|
||||
pagination: zPagination.optional(),
|
||||
})
|
||||
|
||||
export const zAccessSubjectServiceListAccessSubjectsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
groupId: z.string().optional(),
|
||||
pageNumber: z
|
||||
.int()
|
||||
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
|
||||
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
|
||||
.optional(),
|
||||
resultsPerPage: z
|
||||
.int()
|
||||
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
|
||||
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
|
||||
.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
export const zAccessSubjectServiceListAccessSubjectsResponse = zListAccessSubjectsReply
|
||||
|
||||
export const zAppInstanceServiceListAppInstanceSummariesQuery = z.object({
|
||||
pageNumber: z
|
||||
.int()
|
||||
|
||||
@@ -10,13 +10,21 @@ type SwaggerSchema = JsonObject & {
|
||||
$ref?: string
|
||||
}
|
||||
|
||||
type OpenApiMediaType = JsonObject & {
|
||||
schema?: SwaggerSchema
|
||||
}
|
||||
|
||||
type OpenApiResponse = JsonObject & {
|
||||
content?: Record<string, OpenApiMediaType>
|
||||
}
|
||||
|
||||
type OpenApiComponents = JsonObject & {
|
||||
schemas?: Record<string, SwaggerSchema>
|
||||
}
|
||||
|
||||
type SwaggerOperation = JsonObject & {
|
||||
operationId?: string
|
||||
responses?: Record<string, unknown>
|
||||
responses?: Record<string, OpenApiResponse>
|
||||
}
|
||||
|
||||
type SwaggerDocument = JsonObject & {
|
||||
@@ -52,6 +60,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
|
||||
|
||||
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
|
||||
const pydanticDecimalStringPattern = '^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$'
|
||||
const codegenSafeDecimalStringPattern = '^(?![-+.]*$)[+-]?0*\\d*\\.?\\d*$'
|
||||
|
||||
const opaqueJsonContent = (): Record<string, OpenApiMediaType> => ({
|
||||
'application/json': {
|
||||
schema: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const apiSpecs: ApiSpec[] = [
|
||||
{ filename: 'console-openapi.json', name: 'console' },
|
||||
@@ -182,6 +201,46 @@ const addOperationIds = (document: SwaggerDocument) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isOpaqueContractResponse = (response: OpenApiResponse) => {
|
||||
const content = response.content
|
||||
if (!isObject(content))
|
||||
return false
|
||||
|
||||
return Object.entries(content).some(([mediaType, media]) => {
|
||||
if (!isObject(media))
|
||||
return false
|
||||
|
||||
return (mediaType === 'application/json' || mediaType === 'text/event-stream') && !('schema' in media)
|
||||
})
|
||||
}
|
||||
|
||||
const hasOpaqueContractSuccessResponse = (operation: SwaggerOperation) => {
|
||||
return Object.entries(operation.responses ?? {}).some(([status, response]) => {
|
||||
return /^2\d\d$/.test(status) && isObject(response) && isOpaqueContractResponse(response)
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeOpaqueContractResponses = (document: SwaggerDocument) => {
|
||||
// Some backend endpoints has no schema (e.g. external) and will trap heyapi here
|
||||
// So we forge an opaque schema here
|
||||
for (const pathItem of Object.values(document.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(pathItem)) {
|
||||
if (!operationMethods.has(method) || !isObject(operation))
|
||||
continue
|
||||
|
||||
const swaggerOperation = operation as SwaggerOperation
|
||||
if (!hasOpaqueContractSuccessResponse(swaggerOperation))
|
||||
continue
|
||||
|
||||
Object.values(swaggerOperation.responses ?? {})
|
||||
.filter(response => isObject(response) && isOpaqueContractResponse(response))
|
||||
.forEach((response) => {
|
||||
response.content = opaqueJsonContent()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasSuccessResponse = (operation: SwaggerOperation) => {
|
||||
return Object.entries(operation.responses ?? {}).some(([status, response]) => {
|
||||
if (!/^2\d\d$/.test(status))
|
||||
@@ -215,6 +274,7 @@ const filterContractOperations = (document: SwaggerDocument) => {
|
||||
}
|
||||
|
||||
const normalizeApiSwagger = (document: SwaggerDocument) => {
|
||||
normalizeOpaqueContractResponses(document)
|
||||
filterContractOperations(document)
|
||||
addOperationIds(document)
|
||||
|
||||
@@ -380,10 +440,20 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
|
||||
'name': 'zod',
|
||||
'~resolvers': {
|
||||
string: (ctx) => {
|
||||
if (ctx.schema.format !== 'binary')
|
||||
return undefined
|
||||
if (ctx.schema.format === 'binary')
|
||||
return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File')))
|
||||
|
||||
return $(ctx.symbols.z).attr('custom').call().generic($.type.or($.type('Blob'), $.type('File')))
|
||||
if (ctx.schema.pattern === pydanticDecimalStringPattern) {
|
||||
// the pydantic generated regex will emit error like
|
||||
// regexp/no-useless-assertions, so patch the regex here
|
||||
return $(ctx.symbols.z)
|
||||
.attr('string')
|
||||
.call()
|
||||
.attr('regex')
|
||||
.call($.regexp(codegenSafeDecimalStringPattern))
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
AccessControl: ({
|
||||
default: ({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
|
||||
@@ -112,9 +112,14 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
AccessControl: () => <div data-testid="app-access-control" />,
|
||||
}))
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = () => <div data-testid="app-access-control" />
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
|
||||
@@ -212,14 +212,19 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
AccessControl: ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
vi.mock('@/app/components/app/app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
|
||||
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { AccessTab } from '@/features/deployments/detail/access-tab'
|
||||
|
||||
export default async function InstanceDetailAccessPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <AccessTab appInstanceId={appInstanceId} />
|
||||
export default function InstanceDetailAccessPage() {
|
||||
return <AccessTab />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
|
||||
|
||||
export default async function InstanceDetailApiTokensPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeveloperApiTab appInstanceId={appInstanceId} />
|
||||
export default function InstanceDetailApiTokensPage() {
|
||||
return <DeveloperApiTab />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
|
||||
|
||||
export default async function InstanceDetailInstancesPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeployTab appInstanceId={appInstanceId} />
|
||||
export default function InstanceDetailInstancesPage() {
|
||||
return <DeployTab />
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { InstanceDetail } from '@/features/deployments/detail'
|
||||
|
||||
export default async function InstanceDetailLayout({ children, params }: {
|
||||
export default function InstanceDetailLayout({ children }: {
|
||||
children: ReactNode
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
|
||||
return (
|
||||
<InstanceDetail appInstanceId={appInstanceId}>
|
||||
<InstanceDetail>
|
||||
{children}
|
||||
</InstanceDetail>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
|
||||
|
||||
export default async function InstanceDetailOverviewPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <OverviewTab appInstanceId={appInstanceId} />
|
||||
export default function InstanceDetailOverviewPage() {
|
||||
return <OverviewTab />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
|
||||
|
||||
export default async function InstanceDetailReleasesPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <VersionsTab appInstanceId={appInstanceId} />
|
||||
export default function InstanceDetailReleasesPage() {
|
||||
return <VersionsTab />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { AccessControlDialog } from '../access-control-dialog'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
|
||||
describe('AccessControlDialog', () => {
|
||||
it('should render dialog content when visible', () => {
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AccessControlItem } from '../access-control-item'
|
||||
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
import AccessControlItem from '../access-control-item'
|
||||
|
||||
describe('AccessControlItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.PUBLIC,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.PUBLIC },
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should keep the selected state for the active access type', () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
render(harness.element)
|
||||
useAccessControlStore.setState({
|
||||
currentMenu: AccessMode.ORGANIZATION,
|
||||
})
|
||||
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
expect(option).toHaveAttribute('data-checked')
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('border-components-option-card-option-selected-border')
|
||||
})
|
||||
})
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useAccessControlStore } from '../store'
|
||||
|
||||
export function AccessControlRadioGroupHarness({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const currentMenu = useAccessControlStore(state => state.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(state => state.setCurrentMenu)
|
||||
|
||||
return (
|
||||
<RadioGroup<AccessMode> value={currentMenu} onValueChange={setCurrentMenu}>
|
||||
{children}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AccessControlDraft, AccessControlStore } from '../store'
|
||||
import { createElement } from 'react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAccessControlStore } from '../store'
|
||||
import { AccessControlDraftProvider } from '../store-provider'
|
||||
|
||||
const emptyDraft = {
|
||||
appId: '',
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
} satisfies Required<AccessControlDraft>
|
||||
|
||||
function draftKey(draft: AccessControlDraft) {
|
||||
return [
|
||||
draft.appId ?? '',
|
||||
draft.currentMenu,
|
||||
draft.specificGroups?.map(group => group.id).join(',') ?? '',
|
||||
draft.specificMembers?.map(member => member.id).join(',') ?? '',
|
||||
draft.selectedGroupsForBreadcrumb?.map(group => group.id).join(',') ?? '',
|
||||
].join(':')
|
||||
}
|
||||
|
||||
function completeDraft(initialDraft: Partial<AccessControlDraft> = {}): Required<AccessControlDraft> {
|
||||
return {
|
||||
...emptyDraft,
|
||||
...initialDraft,
|
||||
}
|
||||
}
|
||||
|
||||
function SnapshotProbe({ onSnapshot }: {
|
||||
onSnapshot: (snapshot: AccessControlStore) => void
|
||||
}) {
|
||||
onSnapshot(useAccessControlStore(state => state))
|
||||
return null
|
||||
}
|
||||
|
||||
export function createAccessControlDraftHarness(
|
||||
children: ReactNode,
|
||||
initialDraft?: Partial<AccessControlDraft>,
|
||||
) {
|
||||
const draft = completeDraft(initialDraft)
|
||||
let snapshot: AccessControlStore = {
|
||||
appId: draft.appId,
|
||||
specificGroups: draft.specificGroups,
|
||||
setSpecificGroups: () => undefined,
|
||||
specificMembers: draft.specificMembers,
|
||||
setSpecificMembers: () => undefined,
|
||||
currentMenu: draft.currentMenu,
|
||||
setCurrentMenu: () => undefined,
|
||||
selectedGroupsForBreadcrumb: draft.selectedGroupsForBreadcrumb,
|
||||
setSelectedGroupsForBreadcrumb: () => undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
element: createElement(
|
||||
AccessControlDraftProvider,
|
||||
{
|
||||
draftKey: draftKey(draft),
|
||||
initialDraft: draft,
|
||||
},
|
||||
createElement(SnapshotProbe, {
|
||||
onSnapshot: nextSnapshot => snapshot = nextSnapshot,
|
||||
}),
|
||||
children,
|
||||
),
|
||||
getSnapshot: () => snapshot,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { AccessControlDialog } from '../access-control-dialog'
|
||||
import { AccessControlItem } from '../access-control-item'
|
||||
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
|
||||
import { AccessControl } from '../index'
|
||||
import { SpecificGroupsOrMembers } from '../specific-groups-or-members'
|
||||
import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
import AccessControlItem from '../access-control-item'
|
||||
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
|
||||
import AccessControl from '../index'
|
||||
import SpecificGroupsOrMembers from '../specific-groups-or-members'
|
||||
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
const mockMutate = vi.fn()
|
||||
const mockUseMutation = vi.hoisted(() => vi.fn())
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
|
||||
}))
|
||||
@@ -35,19 +36,12 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (...args: unknown[]) => mockUseMutation(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
@@ -100,12 +94,10 @@ beforeAll(() => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
mockUseMutation.mockReturnValue({
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseUpdateAccessMode.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: mockMutate,
|
||||
mutateAsync: mockMutateAsync,
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
@@ -125,39 +117,33 @@ beforeEach(() => {
|
||||
// AccessControlItem handles selected vs. unselected styling and click state updates
|
||||
describe('AccessControlItem', () => {
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.PUBLIC },
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('cursor-pointer')
|
||||
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should keep current menu when clicking the selected access type', () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AccessControlRadioGroupHarness>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>
|
||||
</AccessControlRadioGroupHarness>,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
render(harness.element)
|
||||
|
||||
const option = screen.getByRole('radio', { name: 'Organization Only' })
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,40 +180,32 @@ describe('AccessControlDialog', () => {
|
||||
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
|
||||
describe('SpecificGroupsOrMembers', () => {
|
||||
it('should render collapsed view when not in specific selection mode', () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
|
||||
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state while pending', async () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers loading />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
render(harness.element)
|
||||
const { container } = render(<SpecificGroupsOrMembers />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render fetched groups and members and support removal', async () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
},
|
||||
)
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
@@ -256,12 +234,8 @@ describe('SpecificGroupsOrMembers', () => {
|
||||
describe('AddMemberOrGroupDialog', () => {
|
||||
it('should open search popover and display candidates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -272,21 +246,17 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
it('should allow selecting members and expanding groups', async () => {
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
|
||||
await user.click(expandButton)
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Member One/ }))
|
||||
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
|
||||
@@ -299,11 +269,7 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
|
||||
@@ -320,9 +286,9 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
|
||||
|
||||
expect(harness.getSnapshot().specificGroups).toEqual([])
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([])
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificGroups).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -335,11 +301,7 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -353,6 +315,10 @@ describe('AccessControl', () => {
|
||||
const onClose = vi.fn()
|
||||
const onConfirm = vi.fn()
|
||||
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
|
||||
useAccessControlStore.setState({
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
})
|
||||
const app = {
|
||||
id: 'app-id-1',
|
||||
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
@@ -366,22 +332,21 @@ describe('AccessControl', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: baseMember.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
|
||||
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
|
||||
],
|
||||
})
|
||||
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+21
-31
@@ -1,9 +1,9 @@
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { AddMemberOrGroupDialog } from '../add-member-or-group-pop'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
|
||||
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
}))
|
||||
|
||||
@@ -69,6 +69,13 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAccessControlStore.setState({
|
||||
appId: 'app-1',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: SubjectType.GROUP as never,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
@@ -81,11 +88,7 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
it('should open the search popover and display candidates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -96,20 +99,16 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
it('should allow expanding groups and selecting members', async () => {
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
|
||||
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Member One/ }))
|
||||
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([baseMember])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should show the empty state when no candidates are returned', async () => {
|
||||
@@ -121,11 +120,7 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -133,6 +128,9 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
it('should keep breadcrumbs visible when the current group has no candidates', async () => {
|
||||
useAccessControlStore.setState({
|
||||
selectedGroupsForBreadcrumb: [baseGroup],
|
||||
})
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
@@ -141,15 +139,7 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<AddMemberOrGroupDialog />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [baseGroup],
|
||||
},
|
||||
)
|
||||
render(harness.element)
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
@@ -159,6 +149,6 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' }))
|
||||
|
||||
expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AccessControl } from '../index'
|
||||
import AccessControl from '../index'
|
||||
|
||||
let mockWebappAuth = {
|
||||
enabled: true,
|
||||
@@ -17,24 +18,20 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: mockWebappAuth },
|
||||
})
|
||||
|
||||
const mockMutate = vi.fn()
|
||||
const mockUseMutation = vi.hoisted(() => vi.fn())
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (...args: unknown[]) => mockUseMutation(...args),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AccessControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -44,13 +41,14 @@ describe('AccessControl', () => {
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
}
|
||||
mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
mockUseMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: mockMutate,
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
@@ -83,18 +81,18 @@ describe('AccessControl', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAccessControlStore.getState().appId).toBe(app.id)
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
})
|
||||
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -118,30 +116,4 @@ describe('AccessControl', () => {
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prevent confirming specific access before subjects are loaded', () => {
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
render(
|
||||
<AccessControl
|
||||
app={{ id: 'app-id-3', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as App}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
|
||||
const organizationOption = screen.getByRole('radio', {
|
||||
name: 'app.accessControlDialog.accessItems.organization',
|
||||
})
|
||||
|
||||
expect(confirmButton).toBeDisabled()
|
||||
expect(organizationOption).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+35
-33
@@ -1,17 +1,17 @@
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { SpecificGroupsOrMembers } from '../specific-groups-or-members'
|
||||
import { createAccessControlDraftHarness } from './access-control-test-utils'
|
||||
import SpecificGroupsOrMembers from '../specific-groups-or-members'
|
||||
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
vi.mock('../add-member-or-group-pop', () => ({
|
||||
default: () => <div data-testid="add-member-or-group-dialog" />,
|
||||
}))
|
||||
|
||||
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
|
||||
@@ -36,48 +36,50 @@ describe('SpecificGroupsOrMembers', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
data: { pages: [] },
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
groups: [baseGroup],
|
||||
members: [baseMember],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the collapsed row when not in specific mode', () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{ currentMenu: AccessMode.ORGANIZATION },
|
||||
)
|
||||
useAccessControlStore.setState({
|
||||
currentMenu: AccessMode.ORGANIZATION,
|
||||
})
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading when the selected subjects are pending', async () => {
|
||||
const harness = createAccessControlDraftHarness(<SpecificGroupsOrMembers loading />)
|
||||
render(harness.element)
|
||||
it('should show loading while whitelist subjects are pending', async () => {
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled()
|
||||
const { container } = render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render fetched groups and members and support removal', async () => {
|
||||
const harness = createAccessControlDraftHarness(
|
||||
<SpecificGroupsOrMembers />,
|
||||
{
|
||||
appId: 'app-1',
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
},
|
||||
)
|
||||
useAccessControlStore.setState({ appId: 'app-1' })
|
||||
|
||||
render(harness.element)
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
@@ -89,9 +91,9 @@ describe('SpecificGroupsOrMembers', () => {
|
||||
const memberRemove = removeButtons[1]!
|
||||
|
||||
fireEvent.click(groupRemove)
|
||||
expect(harness.getSnapshot().specificGroups).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificGroups).toEqual([])
|
||||
|
||||
fireEvent.click(memberRemove)
|
||||
expect(harness.getSnapshot().specificMembers).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AccessControlItem } from './access-control-item'
|
||||
import { SpecificGroupsOrMembers, WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
import { useAccessControlStore } from './store'
|
||||
|
||||
type AccessControlDialogContentProps = {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
accessLabel?: ReactNode
|
||||
hideExternal?: boolean
|
||||
hideExternalTip?: boolean
|
||||
saving?: boolean
|
||||
controlsDisabled?: boolean
|
||||
confirmDisabled?: boolean
|
||||
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function AccessControlDialogContent({
|
||||
title,
|
||||
description,
|
||||
accessLabel,
|
||||
hideExternal = false,
|
||||
hideExternalTip = false,
|
||||
saving = false,
|
||||
controlsDisabled = false,
|
||||
confirmDisabled = false,
|
||||
specificGroupsOrMembersProps,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AccessControlDialogContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{title ?? t('accessControlDialog.title', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{description ?? t('accessControlDialog.description', { ns: 'app' })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<RadioGroup<AccessMode>
|
||||
value={currentMenu}
|
||||
onValueChange={setCurrentMenu}
|
||||
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
|
||||
aria-labelledby="access-control-options-label"
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
<div className="leading-6">
|
||||
<p id="access-control-options-label" className="system-sm-medium text-text-tertiary">
|
||||
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
|
||||
</AccessControlItem>
|
||||
{!hideExternal && (
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
{!hideExternalTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
)}
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</RadioGroup>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
@@ -13,17 +14,21 @@ type DialogProps = {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function AccessControlDialog({
|
||||
const AccessControlDialog = ({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) {
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => {
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
|
||||
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
|
||||
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -33,3 +38,5 @@ export function AccessControlDialog({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessControlDialog
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
'use client'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
|
||||
export function AccessControlItem({ type, children }: PropsWithChildren<{
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
type: AccessMode
|
||||
}>) {
|
||||
}>
|
||||
|
||||
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
if (currentMenu !== type) {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer rounded-[10px] border
|
||||
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||
onClick={() => setCurrentMenu(type)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioRoot<AccessMode>
|
||||
value={type}
|
||||
variant="unstyled"
|
||||
render={<div />}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
|
||||
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
|
||||
)}
|
||||
<div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</RadioRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
AccessControlItem.displayName = 'AccessControlItem'
|
||||
|
||||
export default AccessControlItem
|
||||
|
||||
@@ -1,31 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
AccessSubjectAddButton,
|
||||
} from './access-subject-selector/add-button'
|
||||
import { useAccessControlStore } from './store'
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import Loading from '../../base/loading'
|
||||
|
||||
export function AddMemberOrGroupDialog({ disabled = false }: {
|
||||
disabled?: boolean
|
||||
}) {
|
||||
export default function AddMemberOrGroupDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||
const pages = data?.pages ?? []
|
||||
const subjects = pages.flatMap(page => page.subjects ?? [])
|
||||
const selectedSubjects = [
|
||||
...specificGroups.map(groupToSubject),
|
||||
...specificMembers.map(memberToSubject),
|
||||
]
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && hasMore)
|
||||
fetchNextPage()
|
||||
}, { root: scrollRootRef.current, rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, fetchNextPage, hasMore])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: Subject[]) => {
|
||||
const nextGroups: AccessControlGroup[] = []
|
||||
const nextMembers: AccessControlAccount[] = []
|
||||
|
||||
for (const subject of nextSubjects) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
nextGroups.push((subject as SubjectGroup).groupData)
|
||||
else
|
||||
nextMembers.push((subject as SubjectAccount).accountData)
|
||||
}
|
||||
|
||||
setSpecificGroups(nextGroups)
|
||||
setSpecificMembers(nextMembers)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessSubjectAddButton
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
disabled={disabled}
|
||||
breadcrumbGroups={selectedGroupsForBreadcrumb}
|
||||
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb />
|
||||
</div>
|
||||
)}
|
||||
{hasResults
|
||||
? (
|
||||
<>
|
||||
<ComboboxList className="max-h-none p-1">
|
||||
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
function SubjectItem({ subject }: { subject: Subject }) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
|
||||
|
||||
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb() {
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = (index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
setSelectedGroupsForBreadcrumb(newGroups)
|
||||
}
|
||||
const handleReset = () => {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}
|
||||
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
|
||||
{hasBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
|
||||
)}
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>/</span>
|
||||
{isLastGroup
|
||||
? <span>{group.name}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => handleBreadCrumbClick(index)}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
}
|
||||
function GroupItem({ group, subject }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||
|
||||
const handleExpandClick = () => {
|
||||
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<BaseItem subject={subject}>
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</BaseItem>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
onClick={handleExpandClick}
|
||||
>
|
||||
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
|
||||
<RiArrowRightSLine className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
subject: Subject
|
||||
}
|
||||
function MemberItem({ member, subject }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||
return (
|
||||
<BaseItem subject={subject} className="pr-3">
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
(
|
||||
{t('you', { ns: 'common' })}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className, subject }: BaseItemProps) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionBox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
|
||||
checked
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
|
||||
)}
|
||||
>
|
||||
{checked && <span className="i-ri-check-line size-3" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
'use client'
|
||||
import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AccessControlDialog } from './access-control-dialog'
|
||||
import { AccessControlDialogContent } from './access-control-dialog-content'
|
||||
import { useAccessControlStore } from './store'
|
||||
import { AccessControlDraftProvider } from './store-provider'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
@@ -20,113 +22,92 @@ type AccessControlProps = {
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export function AccessControl(props: AccessControlProps) {
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const hideExternalTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
const initialAccessMode = app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
const whiteListSubjectsQuery = useAppWhiteListSubjects(
|
||||
app.id,
|
||||
initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
)
|
||||
const initialSpecificGroups = whiteListSubjectsQuery.data?.groups ?? []
|
||||
const initialSpecificMembers = whiteListSubjectsQuery.data?.members ?? []
|
||||
const draftKey = [
|
||||
app.id,
|
||||
initialAccessMode,
|
||||
initialSpecificGroups.map(group => group.id).join(','),
|
||||
initialSpecificMembers.map(member => member.id).join(','),
|
||||
].join(':')
|
||||
|
||||
return (
|
||||
<AccessControlDraftProvider
|
||||
draftKey={draftKey}
|
||||
initialDraft={{
|
||||
appId: app.id,
|
||||
currentMenu: initialAccessMode,
|
||||
specificGroups: initialSpecificGroups,
|
||||
specificMembers: initialSpecificMembers,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
}}
|
||||
>
|
||||
<AccessControlForm
|
||||
app={app}
|
||||
hideExternalTip={hideExternalTip}
|
||||
subjectsLoading={initialAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && whiteListSubjectsQuery.isPending}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
successMessage={t('accessControlDialog.updateSuccess', { ns: 'app' })}
|
||||
/>
|
||||
</AccessControlDraftProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessControlForm({
|
||||
app,
|
||||
hideExternalTip,
|
||||
subjectsLoading,
|
||||
successMessage,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
app: App
|
||||
hideExternalTip: boolean
|
||||
subjectsLoading: boolean
|
||||
successMessage: string
|
||||
onClose: () => void
|
||||
onConfirm?: () => void
|
||||
}) {
|
||||
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const { isPending, mutate: updateAccessMode } = useMutation(consoleQuery.explore.updateAppAccessMode.mutationOptions())
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
|
||||
function handleConfirm() {
|
||||
useEffect(() => {
|
||||
setAppId(app.id)
|
||||
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [app, setAppId, setCurrentMenu])
|
||||
|
||||
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const submitData: {
|
||||
appId: string
|
||||
accessMode: AccessMode
|
||||
subjects?: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[]
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
} = { appId: app.id, accessMode: currentMenu }
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[] = []
|
||||
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP })
|
||||
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||
})
|
||||
specificMembers.forEach((member) => {
|
||||
subjects.push({
|
||||
subjectId: member.id,
|
||||
subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
})
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
}
|
||||
updateAccessMode({
|
||||
body: submitData,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(successMessage)
|
||||
onConfirm?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await updateAccessMode(submitData)
|
||||
toast.success(t('accessControlDialog.updateSuccess', { ns: 'app' }))
|
||||
onConfirm?.()
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<AccessControlDialogContent
|
||||
hideExternalTip={hideExternalTip}
|
||||
saving={isPending}
|
||||
controlsDisabled={subjectsLoading || isPending}
|
||||
confirmDisabled={subjectsLoading}
|
||||
specificGroupsOrMembersProps={{
|
||||
loading: subjectsLoading,
|
||||
}}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiBuildingLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers />
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
|
||||
</div>
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<RiGlobalLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
'use client'
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import { Infotip } from '../../base/infotip'
|
||||
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
|
||||
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
|
||||
import { useAccessControlStore } from './store'
|
||||
import Loading from '../../base/loading'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
|
||||
export type SpecificGroupsOrMembersProps = {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SpecificGroupsOrMembers({
|
||||
loading = false,
|
||||
}: SpecificGroupsOrMembersProps) {
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
useEffect(() => {
|
||||
setSpecificGroups(data?.groups ?? [])
|
||||
setSpecificMembers(data?.members ?? [])
|
||||
}, [data, setSpecificGroups, setSpecificMembers])
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return (
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,28 +39,109 @@ export function SpecificGroupsOrMembers({
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1 p-3">
|
||||
<div className="flex grow items-center gap-x-1">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<AddMemberOrGroupDialog disabled={loading} />
|
||||
<AddMemberOrGroupDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<AccessSubjectSelectionList
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
loading={loading}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
|
||||
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers() {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const handleRemoveGroup = useCallback(() => {
|
||||
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||
}, [group, setSpecificGroups, specificGroups])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
|
||||
onRemove={handleRemoveGroup}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{group.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const handleRemoveMember = useCallback(() => {
|
||||
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||
}, [member, setSpecificMembers, specificMembers])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{member.name}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
|
||||
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AccessControlDraft, AccessControlStoreApi } from './store'
|
||||
import { useRef } from 'react'
|
||||
import { AccessControlStoreContext, createAccessControlStore } from './store'
|
||||
|
||||
export function AccessControlDraftProvider({
|
||||
children,
|
||||
draftKey,
|
||||
initialDraft,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
draftKey: string
|
||||
initialDraft: AccessControlDraft
|
||||
}) {
|
||||
const storeRef = useRef<{
|
||||
draftKey: string
|
||||
store: AccessControlStoreApi
|
||||
} | undefined>(undefined)
|
||||
|
||||
if (!storeRef.current || storeRef.current.draftKey !== draftKey) {
|
||||
storeRef.current = {
|
||||
draftKey,
|
||||
store: createAccessControlStore(initialDraft),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessControlStoreContext value={storeRef.current.store}>
|
||||
{children}
|
||||
</AccessControlStoreContext>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { AccessControlAccount, AccessControlGroup, AccessMode } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { createContext, use } from 'react'
|
||||
import { useStore } from 'zustand'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
|
||||
export type AccessControlDraft = {
|
||||
appId?: App['id']
|
||||
currentMenu: AccessMode
|
||||
specificGroups?: AccessControlGroup[]
|
||||
specificMembers?: AccessControlAccount[]
|
||||
selectedGroupsForBreadcrumb?: AccessControlGroup[]
|
||||
}
|
||||
|
||||
export type AccessControlStore = {
|
||||
appId: App['id']
|
||||
specificGroups: AccessControlGroup[]
|
||||
setSpecificGroups: (specificGroups: AccessControlGroup[]) => void
|
||||
specificMembers: AccessControlAccount[]
|
||||
setSpecificMembers: (specificMembers: AccessControlAccount[]) => void
|
||||
currentMenu: AccessMode
|
||||
setCurrentMenu: (currentMenu: AccessMode) => void
|
||||
selectedGroupsForBreadcrumb: AccessControlGroup[]
|
||||
setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void
|
||||
}
|
||||
|
||||
export type AccessControlStoreApi = StoreApi<AccessControlStore>
|
||||
|
||||
export function createAccessControlStore(initialDraft: AccessControlDraft) {
|
||||
return createStore<AccessControlStore>(set => ({
|
||||
appId: initialDraft.appId ?? '',
|
||||
specificGroups: initialDraft.specificGroups ?? [],
|
||||
setSpecificGroups: specificGroups => set({ specificGroups }),
|
||||
specificMembers: initialDraft.specificMembers ?? [],
|
||||
setSpecificMembers: specificMembers => set({ specificMembers }),
|
||||
currentMenu: initialDraft.currentMenu,
|
||||
setCurrentMenu: currentMenu => set({ currentMenu }),
|
||||
selectedGroupsForBreadcrumb: initialDraft.selectedGroupsForBreadcrumb ?? [],
|
||||
setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }),
|
||||
}))
|
||||
}
|
||||
|
||||
export const AccessControlStoreContext = createContext<AccessControlStoreApi | undefined>(undefined)
|
||||
|
||||
export function useAccessControlStore<T>(selector: (state: AccessControlStore) => T) {
|
||||
const store = use(AccessControlStoreContext)
|
||||
if (!store)
|
||||
throw new Error('useAccessControlStore must be used inside AccessControlDraftProvider')
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
@@ -137,14 +137,19 @@ vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
: null),
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button onClick={() => void onConfirm()}>confirm-access-control</button>
|
||||
<button onClick={onClose}>close-access-control</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool', () => ({
|
||||
WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
|
||||
|
||||
@@ -38,7 +38,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
|
||||
@@ -46,7 +46,7 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { AccessControl } from '../app-access-control'
|
||||
import AccessControl from '../app-access-control'
|
||||
import {
|
||||
PublisherAccessSection,
|
||||
PublisherActionsSection,
|
||||
|
||||
@@ -25,14 +25,19 @@ vi.mock('../customize', () => ({
|
||||
default: () => <div data-testid="customize-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
AccessControl: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button type="button" onClick={onClose}>close-access</button>
|
||||
<button type="button" onClick={onConfirm}>confirm-access</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
describe('app-card-sections', () => {
|
||||
const t = (key: string) => key
|
||||
|
||||
@@ -80,14 +80,19 @@ vi.mock('../customize', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => isShow ? <button data-testid="customize-modal" onClick={onClose}>customize-modal</button> : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
AccessControl: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
vi.mock('../../app-access-control', () => {
|
||||
const MockAccessControl = ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button onClick={() => void onConfirm()}>confirm-access-control</button>
|
||||
<button onClick={onClose}>close-access-control</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
default: MockAccessControl,
|
||||
AccessControl: MockAccessControl,
|
||||
}
|
||||
})
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
Object.defineProperty(window, 'open', {
|
||||
|
||||
@@ -37,7 +37,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import ShareQRCode from '@/app/components/base/qrcode'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AccessControl } from '../app-access-control'
|
||||
import AccessControl from '../app-access-control'
|
||||
import CustomizeModal from './customize'
|
||||
import EmbeddedModal from './embedded'
|
||||
import SettingsModal from './settings'
|
||||
|
||||
@@ -72,7 +72,7 @@ const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-mod
|
||||
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control').then(mod => mod.AccessControl), {
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
} from '../types'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
|
||||
@@ -30,6 +32,7 @@ import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
|
||||
import { useChatWithHistoryContext } from './context'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
appParams,
|
||||
appPrevChatTree,
|
||||
@@ -174,7 +177,17 @@ const ChatWrapper = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [hasSent, setHasSent] = useState(false)
|
||||
const [prevConversationId, setPrevConversationId] = useState(currentConversationId)
|
||||
if (prevConversationId !== currentConversationId) {
|
||||
setPrevConversationId(currentConversationId)
|
||||
if (!currentConversationId)
|
||||
setHasSent(false)
|
||||
}
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
if (!currentConversationId)
|
||||
setHasSent(true)
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
@@ -223,6 +236,60 @@ const ChatWrapper = () => {
|
||||
}, [isInstalledApp])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
const [descExpanded, setDescExpanded] = useState(false)
|
||||
|
||||
const description = appData?.site.description
|
||||
const [showDescToggle, setShowDescToggle] = useState(false)
|
||||
const handleDescRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setShowDescToggle(!!node && node.scrollHeight > node.clientHeight)
|
||||
}, [])
|
||||
|
||||
const descriptionNode = useMemo(() => {
|
||||
if (!description || currentConversationId || hasSent)
|
||||
return null
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
|
||||
<div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md">
|
||||
<div className={cn('p-6', isMobile && 'p-4')}>
|
||||
<div
|
||||
ref={handleDescRef}
|
||||
className={cn(
|
||||
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
|
||||
!descExpanded && 'line-clamp-3',
|
||||
descExpanded && 'max-h-32 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
{!descExpanded && showDescToggle && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
||||
)}
|
||||
</div>
|
||||
{showDescToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
|
||||
onClick={() => setDescExpanded(v => !v)}
|
||||
>
|
||||
{descExpanded
|
||||
? (
|
||||
<>
|
||||
<RiArrowUpSLine className="size-3" />
|
||||
{t('chat.collapse', { ns: 'share' })}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-3" />
|
||||
{t('chat.expand', { ns: 'share' })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t])
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
@@ -332,6 +399,7 @@ const ChatWrapper = () => {
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{descriptionNode}
|
||||
{chatNode}
|
||||
{welcome}
|
||||
</>
|
||||
|
||||
@@ -98,6 +98,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
app_id: id,
|
||||
site: {
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon_background: app.icon_background,
|
||||
|
||||
@@ -8,7 +8,9 @@ import type {
|
||||
} from '../types'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
|
||||
@@ -32,6 +34,7 @@ import { useEmbeddedChatbotContext } from './context'
|
||||
import { isDify } from './utils'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
appData,
|
||||
appParams,
|
||||
@@ -177,7 +180,17 @@ const ChatWrapper = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [hasSent, setHasSent] = useState(false)
|
||||
const [prevConversationId, setPrevConversationId] = useState(currentConversationId)
|
||||
if (prevConversationId !== currentConversationId) {
|
||||
setPrevConversationId(currentConversationId)
|
||||
if (!currentConversationId)
|
||||
setHasSent(false)
|
||||
}
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
if (!currentConversationId)
|
||||
setHasSent(true)
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
@@ -219,6 +232,75 @@ const ChatWrapper = () => {
|
||||
|
||||
const isTryApp = appSourceType === AppSourceType.tryApp
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId && !isTryApp) // try app always use the new chat
|
||||
const [descExpanded, setDescExpanded] = useState(false)
|
||||
|
||||
const description = appData?.site.description
|
||||
const [showDescToggle, setShowDescToggle] = useState(false)
|
||||
const descRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const el = descRef.current
|
||||
if (!el)
|
||||
return
|
||||
if (el.offsetWidth > 0) {
|
||||
setShowDescToggle(el.scrollHeight > el.clientHeight)
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
if (!entries[0] || entries[0].contentRect.width === 0)
|
||||
return
|
||||
setShowDescToggle(el.scrollHeight > el.clientHeight)
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [description])
|
||||
|
||||
const descriptionNode = useMemo(() => {
|
||||
if (!description || currentConversationId || hasSent)
|
||||
return null
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
|
||||
<div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md">
|
||||
<div className={cn('p-6', isMobile && 'p-4')}>
|
||||
<div
|
||||
ref={descRef}
|
||||
className={cn(
|
||||
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
|
||||
!descExpanded && 'line-clamp-3',
|
||||
descExpanded && 'max-h-32 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
{!descExpanded && showDescToggle && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
||||
)}
|
||||
</div>
|
||||
{showDescToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
|
||||
onClick={() => setDescExpanded(v => !v)}
|
||||
>
|
||||
{descExpanded
|
||||
? (
|
||||
<>
|
||||
<RiArrowUpSLine className="size-3" />
|
||||
{t('chat.collapse', { ns: 'share' })}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-3" />
|
||||
{t('chat.expand', { ns: 'share' })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t])
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
@@ -318,6 +400,7 @@ const ChatWrapper = () => {
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{descriptionNode}
|
||||
{chatNode}
|
||||
{welcome}
|
||||
</>
|
||||
|
||||
+5
-1
@@ -109,7 +109,11 @@ describe('InputField', () => {
|
||||
const scrollBody = panel?.children[1]
|
||||
const footer = panel?.lastElementChild
|
||||
|
||||
expect(panel).toHaveClass('max-h-(--shortcut-popup-max-height)', 'overflow-hidden')
|
||||
// The max-height falls back to a viewport unit so the panel stays bounded
|
||||
// (and the footer/actions reachable via the internal scroll) even when it is
|
||||
// rendered outside the shortcuts popup that defines --shortcut-popup-max-height,
|
||||
// e.g. inside the edit dialog. See issue #37979.
|
||||
expect(panel).toHaveClass('max-h-[var(--shortcut-popup-max-height,80dvh)]', 'overflow-hidden')
|
||||
expect(header).toHaveClass('shrink-0', 'pb-2')
|
||||
expect(scrollBody).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto')
|
||||
expect(footer).toHaveClass('shrink-0', 'bg-components-panel-bg')
|
||||
|
||||
@@ -216,7 +216,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="flex max-h-(--shortcut-popup-max-height) w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="flex max-h-[var(--shortcut-popup-max-height,80dvh)] w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="shrink-0 p-3 pb-2">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@ const InstalledApp = ({
|
||||
app_id: id,
|
||||
site: {
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon_background: app.icon_background,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MoreLikeThisConfig, PromptConfig, TextToSpeechConfig } from '@/mod
|
||||
import type { AppData, CustomConfigValueType, SiteInfo } from '@/models/share'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
@@ -38,6 +39,11 @@ const TextGeneration: FC<Props> = ({
|
||||
appData,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [descExpanded, setDescExpanded] = useState(false)
|
||||
const [showDescToggle, setShowDescToggle] = useState(false)
|
||||
const handleDescRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setShowDescToggle(!!node && node.scrollHeight > node.clientHeight)
|
||||
}, [])
|
||||
const media = useBreakpoints()
|
||||
const isPC = media === MediaType.pc
|
||||
|
||||
@@ -211,7 +217,42 @@ const TextGeneration: FC<Props> = ({
|
||||
<div className="grow truncate system-md-semibold text-text-secondary">{siteInfo.title}</div>
|
||||
</div>
|
||||
{siteInfo.description && (
|
||||
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
|
||||
<div>
|
||||
<div
|
||||
ref={handleDescRef}
|
||||
className={cn(
|
||||
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
|
||||
!descExpanded && 'line-clamp-3',
|
||||
descExpanded && 'max-h-32 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
{siteInfo.description}
|
||||
{!descExpanded && showDescToggle && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
||||
)}
|
||||
</div>
|
||||
{showDescToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
|
||||
onClick={() => setDescExpanded(v => !v)}
|
||||
>
|
||||
{descExpanded
|
||||
? (
|
||||
<>
|
||||
<RiArrowUpSLine className="size-3" />
|
||||
{t('chat.collapse', { ns: 'share' })}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-3" />
|
||||
{t('chat.expand', { ns: 'share' })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* form */}
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function IntegrationsPage({
|
||||
return
|
||||
}
|
||||
|
||||
window.open(getMarketplaceUrl(marketplaceUrlPath), '_blank', 'noopener,noreferrer')
|
||||
window.open(getMarketplaceUrl(marketplaceUrlPath, undefined, { source: window.location.origin }), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
const handleSelectSection = (nextSection: IntegrationSection) => {
|
||||
if (onSectionChange) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { SiteInfo } from '@/models/share'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SavedItems from '@/app/components/app/text-generate/saved-items'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
@@ -69,6 +71,11 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
visionConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [descExpanded, setDescExpanded] = useState(false)
|
||||
const [showDescToggle, setShowDescToggle] = useState(false)
|
||||
const handleDescRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setShowDescToggle(!!node && node.scrollHeight > node.clientHeight)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@@ -93,7 +100,42 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
|
||||
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
|
||||
</div>
|
||||
{siteInfo.description && (
|
||||
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
|
||||
<div>
|
||||
<div
|
||||
ref={handleDescRef}
|
||||
className={cn(
|
||||
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
|
||||
!descExpanded && 'line-clamp-3',
|
||||
descExpanded && 'max-h-32 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
{siteInfo.description}
|
||||
{!descExpanded && showDescToggle && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
||||
)}
|
||||
</div>
|
||||
{showDescToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
|
||||
onClick={() => setDescExpanded(v => !v)}
|
||||
>
|
||||
{descExpanded
|
||||
? (
|
||||
<>
|
||||
<RiArrowUpSLine className="size-3" />
|
||||
{t('chat.collapse', { ns: 'share' })}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiArrowDownSLine className="size-3" />
|
||||
{t('chat.expand', { ns: 'share' })}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TabsList className="w-full">
|
||||
<TabsTab value="create">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user