Merge remote-tracking branch 'origin/refactor/suspense-boundary' into feat/ui-onboarding-rewrite

This commit is contained in:
yyh
2026-05-29 20:23:54 +08:00
106 changed files with 1908 additions and 938 deletions

View File

@ -1,4 +1,4 @@
name: Deploy Agent Dev
name: Deploy SaaS
permissions:
contents: read
@ -7,7 +7,7 @@ on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/agent-dev"
- "deploy/saas"
types:
- completed
@ -16,13 +16,13 @@ jobs:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent-dev'
github.event.workflow_run.head_branch == 'deploy/saas'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.AGENT_DEV_SSH_HOST }}
host: ${{ secrets.SAAS_DEV_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
${{ vars.SSH_SCRIPT_SAAS_DEV || secrets.SSH_SCRIPT_SAAS_DEV }}

View File

@ -95,6 +95,51 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04
permissions:
checks: write
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
web/**
cli/**
e2e/**
sdks/nodejs-client/**
packages/**
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
eslint.config.mjs
.github/workflows/style.yml
.github/actions/setup-web/**
- name: Setup web environment
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
@ -105,28 +150,14 @@ jobs:
restore-keys: |
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
- name: Web style check
- name: Style check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: .
run: vp run lint:ci
- name: Web tsslint
- name: Type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: .
run: vp run type-check
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: vp run knip
- name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5

View File

@ -6,7 +6,7 @@ from flask_restx import Resource
from flask_restx.api import HTTPStatus
from pydantic import BaseModel, Field, TypeAdapter
from controllers.common.schema import register_schema_models
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console.wraps import edit_permission_required
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import validate_app_token
@ -32,8 +32,19 @@ class AnnotationReplyActionPayload(BaseModel):
embedding_model_name: str = Field(description="Embedding model name")
class AnnotationListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, description="Number of annotations per page")
keyword: str = Field(default="", description="Keyword to search annotations")
register_schema_models(
service_api_ns, AnnotationCreatePayload, AnnotationReplyActionPayload, Annotation, AnnotationList
service_api_ns,
AnnotationCreatePayload,
AnnotationReplyActionPayload,
AnnotationListQuery,
Annotation,
AnnotationList,
)
@ -100,6 +111,7 @@ class AnnotationReplyActionStatusApi(Resource):
class AnnotationListApi(Resource):
@service_api_ns.doc("list_annotations")
@service_api_ns.doc(description="List annotations for the application")
@service_api_ns.doc(params=query_params_from_model(AnnotationListQuery))
@service_api_ns.doc(
responses={
200: "Annotations retrieved successfully",
@ -114,18 +126,18 @@ class AnnotationListApi(Resource):
@validate_app_token
def get(self, app_model: App):
"""List annotations for the application."""
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
keyword = request.args.get("keyword", default="", type=str)
query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_model.id, page, limit, keyword)
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(
app_model.id, query.page, query.limit, query.keyword
)
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
response = AnnotationList(
data=annotation_models,
has_more=len(annotation_list) == limit,
limit=limit,
has_more=len(annotation_list) == query.limit,
limit=query.limit,
total=total,
page=page,
page=query.page,
)
return response.model_dump(mode="json")

View File

@ -562,15 +562,16 @@ class WorkflowResponseConverter:
outputs, outputs_truncated = self._truncate_mapping(encoded_outputs)
metadata = self._merge_metadata(event.execution_metadata, snapshot)
if isinstance(event, QueueNodeSucceededEvent):
status = WorkflowNodeExecutionStatus.SUCCEEDED
error_message = event.error
elif isinstance(event, QueueNodeFailedEvent):
status = WorkflowNodeExecutionStatus.FAILED
error_message = event.error
else:
status = WorkflowNodeExecutionStatus.EXCEPTION
error_message = event.error
match event:
case QueueNodeSucceededEvent():
status = WorkflowNodeExecutionStatus.SUCCEEDED
error_message = event.error
case QueueNodeFailedEvent():
status = WorkflowNodeExecutionStatus.FAILED
error_message = event.error
case _:
status = WorkflowNodeExecutionStatus.EXCEPTION
error_message = event.error
return NodeFinishStreamResponse(
task_id=task_id,

View File

@ -91,26 +91,28 @@ class AppGeneratorTTSPublisher:
)
future_queue.put(futures_result)
break
elif isinstance(message.event, QueueAgentMessageEvent | QueueLLMChunkEvent):
message_content = message.event.chunk.delta.message.content
if not message_content:
continue
match message_content:
case str():
self.msg_text += message_content
case list():
for content in message_content:
if not isinstance(content, TextPromptMessageContent):
continue
self.msg_text += content.data
elif isinstance(message.event, QueueTextChunkEvent):
self.msg_text += message.event.text
elif isinstance(message.event, QueueNodeSucceededEvent):
if message.event.outputs is None:
continue
output = message.event.outputs.get("output", "")
if isinstance(output, str):
self.msg_text += output
else:
match message.event:
case QueueAgentMessageEvent() | QueueLLMChunkEvent():
message_content = message.event.chunk.delta.message.content
if not message_content:
continue
match message_content:
case str():
self.msg_text += message_content
case list():
for content in message_content:
if not isinstance(content, TextPromptMessageContent):
continue
self.msg_text += content.data
case QueueTextChunkEvent():
self.msg_text += message.event.text
case QueueNodeSucceededEvent():
if message.event.outputs is None:
continue
output = message.event.outputs.get("output", "")
if isinstance(output, str):
self.msg_text += output
self.last_message = message
sentence_arr, text_tmp = self._extract_sentence(self.msg_text)
if len(sentence_arr) >= min(self.max_sentence, 7):

View File

@ -54,36 +54,39 @@ class Blob(BaseModel):
def as_string(self) -> str:
"""Read data as a string."""
if self.data is None and self.path:
return Path(str(self.path)).read_text(encoding=self.encoding)
elif isinstance(self.data, bytes):
return self.data.decode(self.encoding)
elif isinstance(self.data, str):
return self.data
else:
raise ValueError(f"Unable to get string for blob {self}")
match self.data:
case None if self.path:
return Path(str(self.path)).read_text(encoding=self.encoding)
case bytes():
return self.data.decode(self.encoding)
case str():
return self.data
case _:
raise ValueError(f"Unable to get string for blob {self}")
def as_bytes(self) -> bytes:
"""Read data as bytes."""
if isinstance(self.data, bytes):
return self.data
elif isinstance(self.data, str):
return self.data.encode(self.encoding)
elif self.data is None and self.path:
return Path(str(self.path)).read_bytes()
else:
raise ValueError(f"Unable to get bytes for blob {self}")
match self.data:
case bytes():
return self.data
case str():
return self.data.encode(self.encoding)
case None if self.path:
return Path(str(self.path)).read_bytes()
case _:
raise ValueError(f"Unable to get bytes for blob {self}")
@contextlib.contextmanager
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
"""Read data as a byte stream."""
if isinstance(self.data, bytes):
yield BytesIO(self.data)
elif self.data is None and self.path:
with open(str(self.path), "rb") as f:
yield f
else:
raise NotImplementedError(f"Unable to convert blob {self}")
match self.data:
case bytes():
yield BytesIO(self.data)
case None if self.path:
with open(str(self.path), "rb") as f:
yield f
case _:
raise NotImplementedError(f"Unable to convert blob {self}")
@classmethod
def from_path(

View File

@ -112,6 +112,14 @@ List annotations for the application
List annotations for the application
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Keyword to search annotations | No | string |
| limit | query | Number of annotations per page | No | integer |
| page | query | Page number | No | integer |
##### Responses
| Code | Description | Schema |
@ -2169,6 +2177,14 @@ Returns a list of available models for the specified model type.
| page | integer | | Yes |
| total | integer | | Yes |
#### AnnotationListQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Keyword to search annotations | No |
| limit | integer | Number of annotations per page | No |
| page | integer | Page number | No |
#### AnnotationReplyActionPayload
| Name | Type | Description | Required |

View File

@ -2329,15 +2329,15 @@ class DocumentService:
# if knowledge_config.data_source:
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
# # type: ignore
#
# count = len(upload_file_list)
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# for notion_info in notion_info_list: # type: ignore
# for notion_info in notion_info_list:
# count = count + len(notion_info.pages)
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# count = len(website_info.urls) # type: ignore
# count = len(website_info.urls)
# batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT)
# if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1:
@ -2349,7 +2349,7 @@ class DocumentService:
# # if dataset is empty, update dataset data_source_type
# if not dataset.data_source_type:
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type # type: ignore
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type
# if not dataset.indexing_technique:
# if knowledge_config.indexing_technique not in Dataset.INDEXING_TECHNIQUE_LIST:
@ -2386,7 +2386,7 @@ class DocumentService:
# knowledge_config.retrieval_model.model_dump()
# if knowledge_config.retrieval_model
# else default_retrieval_model
# ) # type: ignore
# )
# documents = []
# if knowledge_config.original_document_id:
@ -2425,8 +2425,8 @@ class DocumentService:
# position = DocumentService.get_documents_position(dataset.id)
# document_ids = []
# duplicate_document_ids = []
# if knowledge_config.data_source.info_list.data_source_type == "upload_file": # type: ignore
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids # type: ignore
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
# for file_id in upload_file_list:
# file = (
# db.session.query(UploadFile)
@ -2452,7 +2452,7 @@ class DocumentService:
# name=file_name,
# ).first()
# if document:
# document.dataset_process_rule_id = dataset_process_rule.id # type: ignore
# document.dataset_process_rule_id = dataset_process_rule.id
# document.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
# document.created_from = created_from
# document.doc_form = knowledge_config.doc_form
@ -2466,8 +2466,8 @@ class DocumentService:
# continue
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
@ -2482,8 +2482,8 @@ class DocumentService:
# document_ids.append(document.id)
# documents.append(document)
# position += 1
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import": # type: ignore
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# if not notion_info_list:
# raise ValueError("No notion info list found.")
# exist_page_ids = []
@ -2523,8 +2523,8 @@ class DocumentService:
# truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
@ -2544,8 +2544,8 @@ class DocumentService:
# # delete not selected documents
# if len(exist_document) > 0:
# clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl": # type: ignore
# website_info = knowledge_config.data_source.info_list.website_info_list # type: ignore
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# if not website_info:
# raise ValueError("No website info list found.")
# urls = website_info.urls
@ -2563,8 +2563,8 @@ class DocumentService:
# document_name = url
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,

View File

@ -19,10 +19,12 @@ from unittest.mock import Mock
import pytest
from flask import Flask
from flask_restx.api import HTTPStatus
from pydantic import ValidationError
from controllers.service_api.app.annotation import (
AnnotationCreatePayload,
AnnotationListApi,
AnnotationListQuery,
AnnotationReplyActionApi,
AnnotationReplyActionPayload,
AnnotationReplyActionStatusApi,
@ -106,6 +108,28 @@ class TestAnnotationReplyActionPayload:
assert payload.score_threshold == 0.0
class TestAnnotationListQuery:
def test_defaults(self) -> None:
query = AnnotationListQuery.model_validate({})
assert query.page == 1
assert query.limit == 20
assert query.keyword == ""
def test_valid_numeric_strings(self) -> None:
query = AnnotationListQuery.model_validate({"page": "2", "limit": "5", "keyword": "refund"})
assert query.page == 2
assert query.limit == 5
assert query.keyword == "refund"
@pytest.mark.parametrize("field", ["page", "limit"])
@pytest.mark.parametrize("value", ["abc", "1.5", "1e2", "", "0", "-1"])
def test_invalid_explicit_pagination_value(self, field: str, value: str) -> None:
with pytest.raises(ValidationError):
AnnotationListQuery.model_validate({field: value})
# ---------------------------------------------------------------------------
# Model and Error Pattern Tests
# ---------------------------------------------------------------------------
@ -232,22 +256,55 @@ class TestAnnotationReplyActionStatusApi:
class TestAnnotationListApi:
def test_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_uses_defaults(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
monkeypatch.setattr(
AppAnnotationService,
"get_annotation_list_by_app_id",
lambda *_args, **_kwargs: ([annotation], 1),
)
get_mock = Mock(return_value=([annotation], 1))
monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock)
api = AnnotationListApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app")
with app.test_request_context("/apps/annotations?page=1&limit=1", method="GET"):
with app.test_request_context("/apps/annotations", method="GET"):
response = handler(api, app_model=app_model)
assert response["page"] == 1
assert response["limit"] == 20
get_mock.assert_called_once_with("app", 1, 20, "")
def test_get_accepts_valid_numeric_strings(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
get_mock = Mock(return_value=([annotation], 1))
monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock)
api = AnnotationListApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app")
with app.test_request_context("/apps/annotations?page=2&limit=5&keyword=refund", method="GET"):
response = handler(api, app_model=app_model)
assert response["total"] == 1
assert response["page"] == 2
assert response["limit"] == 5
get_mock.assert_called_once_with("app", 2, 5, "refund")
@pytest.mark.parametrize("query_string", ["page=abc&limit=5", "page=1&limit=abc", "page=&limit=5", "limit=0"])
def test_get_rejects_invalid_explicit_pagination_value(
self, app: Flask, monkeypatch: pytest.MonkeyPatch, query_string: str
) -> None:
get_mock = Mock(return_value=([], 0))
monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock)
api = AnnotationListApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app")
with app.test_request_context(f"/apps/annotations?{query_string}", method="GET"):
with pytest.raises(ValidationError):
handler(api, app_model=app_model)
get_mock.assert_not_called()
def test_create(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)

View File

@ -40,7 +40,7 @@ export type PollSuccess = {
subject_type?: string
subject_email?: string
subject_issuer?: string
account?: PollAccount
account?: PollAccount | null
workspaces?: readonly PollWorkspace[]
default_workspace_id?: string
token_id?: string

View File

@ -106,6 +106,8 @@ describe('runLogin', () => {
expect(bundle.account).toBeUndefined()
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
expect(stored).toBe('dfoe_test')
expect(io.outBuf()).toContain('external SSO')
expect(io.outBuf()).toContain('sso@dify.ai')
})

View File

@ -99,7 +99,7 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorS
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
const display = bareHost(host)
if (s.account !== undefined && s.account.email !== '') {
if (s.account && s.account.email !== '') {
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
const ws = findDefaultWorkspace(s)
if (ws !== undefined)
@ -139,11 +139,11 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos
token_id: s.token_id,
tokens: { bearer: s.token },
}
if (s.account !== undefined) {
if (s.account) {
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (s.account === undefined || s.account.id === '')) {
&& (!s.account || s.account.id === '')) {
bundle.external_subject = {
email: s.subject_email,
issuer: s.subject_issuer ?? '',

View File

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runCreateMember } from './run.js'
@ -24,7 +24,7 @@ export default class CreateMember extends DifyCommand {
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
}
async run(argv: string[]) {

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runDeleteMember } from './run.js'
@ -23,7 +23,7 @@ export default class DeleteMember extends DifyCommand {
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
}

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runDescribeApp } from './run.js'
@ -20,7 +20,7 @@ export default class DescribeApp extends DifyCommand {
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'refresh': Flags.boolean({ description: 'bypass app-info cache and fetch fresh', default: false }),
}

View File

@ -1,6 +1,6 @@
import type { AppMode } from '@dify/contracts/api/openapi/types.gen'
import { Args, Flags } from '../../../framework/flags.js'
import { table } from '../../../framework/output.js'
import { OutputFormat, table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetApp } from './run.js'
@ -42,7 +42,7 @@ export default class GetApp extends DifyCommand {
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
}
async run(argv: string[]) {

View File

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { table } from '../../../framework/output.js'
import { OutputFormat, table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetMember } from './run.js'
@ -23,7 +23,7 @@ export default class GetMember extends DifyCommand {
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
}
async run(argv: string[]) {

View File

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { raw, table } from '../../../framework/output.js'
import { OutputFormat, raw, table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetWorkspace } from './run.js'
@ -15,7 +15,7 @@ export default class GetWorkspace extends DifyCommand {
static override flags = {
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
}
async run(argv: string[]) {

View File

@ -1,4 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { resumeApp } from './run.js'
@ -25,7 +26,7 @@ export default class ResumeApp extends DifyCommand {
'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }),
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'http-retry': httpRetryFlag,
}

View File

@ -1,4 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { agentGuide } from './guide.js'
@ -32,7 +33,7 @@ export default class RunApp extends DifyCommand {
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'Output format (json|yaml|text)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
}
async run(argv: string[]): Promise<void> {

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runSetMember } from './run.js'
@ -27,7 +27,7 @@ export default class SetMember extends DifyCommand {
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
}
async run(argv: string[]) {

View File

@ -1,5 +1,5 @@
import { Flags } from '../../framework/flags.js'
import { formatted, raw, stringifyOutput } from '../../framework/output.js'
import { formatted, OutputFormat, raw, stringifyOutput } from '../../framework/output.js'
import { colorEnabled } from '../../sys/io/color.js'
import { realStreams } from '../../sys/io/streams'
import { versionInfo } from '../../version/info.js'
@ -20,11 +20,7 @@ export default class Version extends DifyCommand {
]
static override flags = {
'output': Flags.string({
char: 'o',
description: 'output format (text|json|yaml)',
default: '',
}),
'output': Flags.outputFormat({ options: [OutputFormat.TEXT, OutputFormat.JSON, OutputFormat.YAML], default: '' }),
'client': Flags.boolean({ description: 'skip server probe' }),
'short': Flags.boolean({ description: 'print only the client semver' }),
'check-compat': Flags.boolean({

View File

@ -8,8 +8,8 @@ import {
} from './codes.js'
describe('error codes', () => {
it('has 18 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(18)
it('has correct number codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(Object.keys(CODE_TO_EXIT_MAP).length)
})
it('has the expected ExitCode buckets', () => {

View File

@ -17,6 +17,7 @@ export const ErrorCode = {
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
Unknown: 'unknown',
IllegalArgumentError: 'illegal_argument',
} as const
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
@ -50,6 +51,7 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
server_4xx_other: ExitCode.Generic,
client_error: ExitCode.Generic,
unknown: ExitCode.Generic,
illegal_argument: ExitCode.Usage,
}
export function exitFor(code: string): ExitCodeValue {

View File

@ -0,0 +1,34 @@
import type { FlagDefinition } from './types.js'
import { describe, expect, it } from 'vitest'
import { OutputFormatNotSupportedError, UnsupportedArgValueError } from './errors.js'
describe('OutputFormatNotSupportedError', () => {
it('states the offending format in the message', () => {
const err = new OutputFormatNotSupportedError('csv')
expect(err.message).toBe('format csv is not supported by this command')
})
})
describe('UnsupportedArgValueError', () => {
it('includes both long and short option labels when a char exists', () => {
const def: FlagDefinition = { type: 'string', description: 'output', char: 'o', options: ['json', 'yaml'] }
const err = new UnsupportedArgValueError('output', def, 'csv')
expect(err.message).toBe('illegal value csv for flag --output / -o')
})
it('omits the short option label when the flag has no char', () => {
const def: FlagDefinition = { type: 'string', description: 'app mode', options: ['chat', 'workflow'] }
const err = new UnsupportedArgValueError('mode', def, 'chatbot')
expect(err.message).toBe('illegal value chatbot for flag --mode')
})
it('lists supported values in the hint', () => {
const def: FlagDefinition = { type: 'string', description: 'app mode', options: ['chat', 'workflow'] }
expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe('supported value: chat, workflow')
})
it('leaves the hint empty when the flag declares no options', () => {
const def: FlagDefinition = { type: 'string', description: 'app mode' }
expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe('')
})
})

View File

@ -0,0 +1,23 @@
import type { FlagDefinition } from './types'
import { BaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
export class OutputFormatNotSupportedError extends BaseError {
constructor(format: string) {
super({
code: ErrorCode.IllegalArgumentError,
message: `format ${format} is not supported by this command`,
})
}
}
export class UnsupportedArgValueError extends BaseError {
constructor(flagName: string, flagDef: FlagDefinition, givenValue: string) {
const flagLabel = flagDef.char ? `--${flagName} / -${flagDef.char}` : `--${flagName}`
super({
code: ErrorCode.IllegalArgumentError,
message: `illegal value ${givenValue} for flag ${flagLabel}`,
hint: flagDef.options ? `supported value: ${flagDef.options.join(', ')}` : '',
})
}
}

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { UnsupportedArgValueError } from './errors.js'
import { Args, Flags, parseArgv } from './flags.js'
const meta = {
@ -190,13 +191,13 @@ describe('parseArgv', () => {
it('rejects an invalid option value (space form)', () => {
expect(() => parseArgv(['--mode', 'chatbot'], metaWithOptions)).toThrow(
'--mode must be one of: chat, workflow, completion',
UnsupportedArgValueError,
)
})
it('rejects an invalid option value (= form)', () => {
expect(() => parseArgv(['--mode=chatbot'], metaWithOptions)).toThrow(
'--mode must be one of: chat, workflow, completion',
UnsupportedArgValueError,
)
})
})

View File

@ -1,6 +1,12 @@
import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types.js'
import { UnsupportedArgValueError } from './errors.js'
function stringFlag<const Opts extends { description: string, char?: string, default?: string, multiple?: boolean, helpGroup?: string, options?: readonly string[] }>(
function stringFlag<const Opts extends {
description: string
char?: string
default?: string
options?: readonly string[]
}>(
opts: Opts,
): FlagDefinition<string> {
return {
@ -10,7 +16,19 @@ function stringFlag<const Opts extends { description: string, char?: string, def
}
}
function stringRepeatedFlag<const Opts extends { description: string, char?: string, default?: string[], multiple?: boolean, helpGroup?: string }>(
function outputFormatFlag<const Opts extends { options: readonly string[], default?: string }>(
opts: Opts,
): FlagDefinition<string> {
return {
type: 'string',
description: `output format (${opts.options.join('|')})`,
char: 'o',
multiple: false,
...opts,
}
}
function stringRepeatedFlag<const Opts extends { description: string, char?: string, default?: string[], multiple?: boolean }>(
opts: Opts,
): FlagDefinition<string[]> {
return {
@ -20,11 +38,11 @@ function stringRepeatedFlag<const Opts extends { description: string, char?: str
}
}
function booleanFlag(opts: { description: string, char?: string, default?: boolean, helpGroup?: string }): FlagDefinition<boolean> {
function booleanFlag(opts: { description: string, char?: string, default?: boolean }): FlagDefinition<boolean> {
return { type: 'boolean', ...opts }
}
function integerFlag<const Opts extends { description: string, char?: string, default?: number, helpGroup?: string }>(
function integerFlag<const Opts extends { description: string, char?: string, default?: number }>(
opts: Opts,
): FlagDefinition<Opts extends { default: number } ? number : number | undefined> {
return { type: 'integer', ...opts } as FlagDefinition<Opts extends { default: number } ? number : number | undefined>
@ -35,6 +53,7 @@ export const Flags = {
stringArray: stringRepeatedFlag,
boolean: booleanFlag,
integer: integerFlag,
outputFormat: outputFormatFlag,
}
function stringArg<const Opts extends { description: string, required?: boolean }>(
@ -91,7 +110,32 @@ function resolveByChar(char: string, meta: CommandMeta): [name: string, def: Fla
function validateFlagOptions(name: string, raw: string, def: FlagDefinition): void {
if (def.options !== undefined && !def.options.includes(raw))
throw new Error(`--${name} must be one of: ${def.options.join(', ')}`)
throw new UnsupportedArgValueError(name, def, raw)
}
type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined }
function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
if (token.startsWith('--')) {
const eqIdx = token.indexOf('=')
const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2)
const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined
const def = meta.flags[name]
if (!def)
throw new Error(`unknown flag: --${name}`)
return { name, def, label: `--${name}`, inlineRaw }
}
if (token.length === 2 && token[1] !== undefined) {
const char = token[1]
const resolved = resolveByChar(char, meta)
if (!resolved)
throw new Error(`unknown flag: -${char}`)
const [name, def] = resolved
return { name, def, label: `-${char}`, inlineRaw: undefined }
}
return null
}
export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } {
@ -110,63 +154,38 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P
continue
}
if (!pastDoubleDash && token.startsWith('--')) {
const eqIdx = token.indexOf('=')
let name: string
let rawValue: string | undefined
if (eqIdx !== -1) {
name = token.slice(2, eqIdx)
rawValue = token.slice(eqIdx + 1)
}
else {
name = token.slice(2)
rawValue = undefined
}
const def = meta.flags[name]
if (!def)
throw new Error(`unknown flag: --${name}`)
if (def.type === 'boolean') {
flags[name] = rawValue === undefined ? true : coerceFlagValue(rawValue, def)
}
else if (rawValue !== undefined) {
validateFlagOptions(name, rawValue, def)
accumulateFlagValue(flags, name, coerceFlagValue(rawValue, def), def)
}
else {
i++
const next = i < argv.length ? argv[i] : undefined
if (next === undefined || next.startsWith('-'))
throw new Error(`flag --${name} expects a value`)
validateFlagOptions(name, next, def)
accumulateFlagValue(flags, name, coerceFlagValue(next, def), def)
}
if (pastDoubleDash || !token.startsWith('-')) {
positional.push(token)
continue
}
else if (!pastDoubleDash && token.startsWith('-') && token.length === 2 && token[1] !== undefined) {
const char = token[1]
const resolved = resolveByChar(char, meta)
if (!resolved)
throw new Error(`unknown flag: -${char}`)
const [flagName, def] = resolved
if (def.type === 'boolean') {
flags[flagName] = true
}
else {
i++
const next = i < argv.length ? argv[i] : undefined
if (next === undefined || next.startsWith('-'))
throw new Error(`flag -${char} expects a value`)
const resolved = resolveToken(token, meta)
if (!resolved) {
positional.push(token)
continue
}
accumulateFlagValue(flags, flagName, coerceFlagValue(next, def), def)
}
const { name, def, label, inlineRaw } = resolved
if (def.type === 'boolean') {
flags[name] = inlineRaw === undefined ? true : coerceFlagValue(inlineRaw, def)
continue
}
let raw: string
if (inlineRaw !== undefined) {
raw = inlineRaw
}
else {
positional.push(token)
i++
const next = i < argv.length ? argv[i] : undefined
if (next === undefined || next.startsWith('-'))
throw new Error(`flag ${label} expects a value`)
raw = next
}
validateFlagOptions(name, raw, def)
accumulateFlagValue(flags, name, coerceFlagValue(raw, def), def)
}
const args: ParsedArgs = {}

View File

@ -1,5 +1,6 @@
import type { FormattedPrintable, NamePrintable, TablePrintable } from './output.js'
import { describe, expect, it } from 'vitest'
import { OutputFormatNotSupportedError } from './errors.js'
import {
formatted,
raw,
@ -99,13 +100,12 @@ describe('stringifyOutput — formatted', () => {
json: () => ({}),
}
const out = formatted({ format: 'name', data: noName })
expect(() => stringifyOutput(out)).toThrow('name output requires data.name()')
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
})
it('unknown format: throws with allowed list', () => {
const out = formatted({ format: 'csv', data: makeFormatted({}) })
expect(() => stringifyOutput(out)).toThrow(/not supported/)
expect(() => stringifyOutput(out)).toThrow(/json, name, text, yaml/)
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
})
})
@ -175,13 +175,12 @@ describe('stringifyOutput — table', () => {
json: () => [],
}
const out = table({ format: 'name', data: noName })
expect(() => stringifyOutput(out)).toThrow('name output requires data.name()')
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
})
it('unknown format: throws with allowed list', () => {
const out = table({ format: 'csv', data: makeTable({}) })
expect(() => stringifyOutput(out)).toThrow(/not supported/)
expect(() => stringifyOutput(out)).toThrow(/json, name, wide, yaml/)
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
})
it('table renders column padding correctly', () => {

View File

@ -1,4 +1,5 @@
import yaml from 'js-yaml'
import { OutputFormatNotSupportedError } from './errors'
export type RawOutput = {
readonly kind: 'raw'
@ -31,6 +32,14 @@ export type JsonPrintable = {
readonly json: () => unknown
}
export const OutputFormat = {
NAME: 'name',
JSON: 'json',
YAML: 'yaml',
TEXT: 'text',
WIDE: 'wide',
} as const
export type TableOutput<TRow extends TablePrintable> = {
readonly kind: 'table'
readonly format: string
@ -77,32 +86,32 @@ export function stringifyOutput(output: CommandOutput): string {
function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>): string {
switch (output.format) {
case '':
case 'text':
case OutputFormat.TEXT:
return output.data.text()
case 'json':
case OutputFormat.JSON:
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case 'yaml':
case OutputFormat.YAML:
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
case 'name':
case OutputFormat.NAME:
return `${toName(output.data)}\n`
default:
throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, text, yaml`)
throw new OutputFormatNotSupportedError(output.format)
}
}
function stringifyTableOutput(output: TableOutput<TablePrintable>): string {
switch (output.format) {
case '':
case 'wide':
case OutputFormat.WIDE:
return renderTable(output)
case 'json':
case OutputFormat.JSON:
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case 'yaml':
case OutputFormat.YAML:
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
case 'name':
case OutputFormat.NAME:
return `${toName(output.data)}\n`
default:
throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, wide, yaml`)
throw new OutputFormatNotSupportedError(output.format)
}
}
@ -186,7 +195,7 @@ function formatTable(rows: readonly (readonly string[])[]): string {
function toName(data: TablePrintable | FormattedPrintable): string {
if (!isNamePrintable(data))
throw new Error('name output requires data.name()')
throw new OutputFormatNotSupportedError('name')
return data.name()
}

View File

@ -9,7 +9,6 @@ export type FlagDefinition<T extends OptionalArgValueType = OptionalArgValueType
readonly char?: string
readonly default?: ArgValueType
readonly multiple?: boolean
readonly helpGroup?: string
readonly options?: readonly string[]
readonly _flagValue?: T
}

View File

@ -356,6 +356,9 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
subject_type: 'external_sso',
subject_email: 'sso@dify.ai',
subject_issuer: 'https://issuer.example',
account: null,
workspaces: [],
default_workspace_id: null,
token_id: 'tok-sso-1',
})
}

View File

@ -7,9 +7,13 @@ When('I open the publish panel', async function (this: DifyWorld) {
})
When('I publish the app', async function (this: DifyWorld) {
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
await this.getPage()
.getByRole('button', { name: /Publish Update/ })
.click()
})
Then('the app should be marked as published', async function (this: DifyWorld) {
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
timeout: 30_000,
})
})

View File

@ -1,13 +1,21 @@
import type { DifyWorld } from '../../support/world'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
import {
createTestApp,
enableAppSiteAndGetURL,
publishWorkflowApp,
syncRunnableWorkflowDraft,
} from '../../../support/api'
When('I enable the Web App share', async function (this: DifyWorld) {
const page = this.getPage()
const appName = this.lastCreatedAppName
if (!appName)
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
if (!appName) {
throw new Error(
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
)
}
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
@ -28,8 +36,11 @@ Given('a workflow app has been published and shared via API', async function (th
})
When('I open the shared app URL', async function (this: DifyWorld) {
if (!this.shareURL)
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
if (!this.shareURL) {
throw new Error(
'No share URL available. Run "a workflow app has been published and shared via API" first.',
)
}
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
})

View File

@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
When('I run the workflow', async function (this: DifyWorld) {
const page = this.getPage()
const testRunButton = page.getByText('Test Run')
const testRunButton = page.getByRole('button', { name: /Test Run/ })
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
await testRunButton.click()
@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
Then('the workflow run should succeed', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByText('DETAIL').click()
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
await page.getByText('DETAIL', { exact: true }).click()
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
})

View File

@ -24,6 +24,7 @@ import {
zGetAppFeedbacksResponse,
zGetAppsAnnotationReplyByActionStatusByJobIdPath,
zGetAppsAnnotationReplyByActionStatusByJobIdResponse,
zGetAppsAnnotationsQuery,
zGetAppsAnnotationsResponse,
zGetConversationsByCIdVariablesPath,
zGetConversationsByCIdVariablesQuery,
@ -379,6 +380,7 @@ export const get4 = oc
summary: 'List annotations for the application',
tags: ['service_api'],
})
.input(z.object({ query: zGetAppsAnnotationsQuery.optional() }))
.output(zGetAppsAnnotationsResponse)
/**

View File

@ -25,6 +25,12 @@ export type AnnotationList = {
total: number
}
export type AnnotationListQuery = {
keyword?: string
limit?: number
page?: number
}
export type AnnotationReplyActionPayload = {
embedding_model_name: string
embedding_provider_name: string
@ -969,7 +975,11 @@ export type GetAppsAnnotationReplyByActionStatusByJobIdResponse
export type GetAppsAnnotationsData = {
body?: never
path?: never
query?: never
query?: {
keyword?: string
limit?: number
page?: number
}
url: '/apps/annotations'
}

View File

@ -32,6 +32,15 @@ export const zAnnotationList = z.object({
total: z.int(),
})
/**
* AnnotationListQuery
*/
export const zAnnotationListQuery = z.object({
keyword: z.string().optional().default(''),
limit: z.int().gte(1).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* AnnotationReplyActionPayload
*/
@ -1216,6 +1225,12 @@ export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse = z.record(
z.unknown(),
)
export const zGetAppsAnnotationsQuery = z.object({
keyword: z.string().optional().default(''),
limit: z.int().gte(1).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Annotations retrieved successfully
*/

12
pnpm-lock.yaml generated
View File

@ -513,6 +513,9 @@ catalogs:
scheduler:
specifier: 0.27.0
version: 0.27.0
server-only:
specifier: 0.0.1
version: 0.0.1
sharp:
specifier: 0.34.5
version: 0.34.5
@ -1248,6 +1251,9 @@ importers:
scheduler:
specifier: 'catalog:'
version: 0.27.0
server-only:
specifier: 'catalog:'
version: 0.0.1
sharp:
specifier: 'catalog:'
version: 0.34.5
@ -8424,6 +8430,9 @@ packages:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@ -16656,6 +16665,8 @@ snapshots:
seroval@1.5.1: {}
server-only@0.0.1: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@ -17774,6 +17785,7 @@ time:
remark-breaks@4.0.0: '2023-09-22T16:45:41.061Z'
remark-directive@4.0.0: '2025-02-27T15:15:20.630Z'
scheduler@0.27.0: '2025-10-01T21:39:15.208Z'
server-only@0.0.1: '2022-09-03T01:07:26.139Z'
sharp@0.34.5: '2025-11-06T14:19:40.989Z'
shiki@4.1.0: '2026-05-19T07:51:34.358Z'
socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z'

View File

@ -214,6 +214,7 @@ catalog:
remark-breaks: 4.0.0
remark-directive: 4.0.0
scheduler: 0.27.0
server-only: 0.0.1
sharp: 0.34.5
shiki: 4.1.0
socket.io-client: 4.8.3

View File

@ -4,6 +4,9 @@ NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
NEXT_PUBLIC_EDITION=SELF_HOSTED
# The base path for the application
NEXT_PUBLIC_BASE_PATH=
# Server-only console API origin for server-side requests.
# Usually matches CONSOLE_API_URL from Docker deployment; local dev can rely on NEXT_PUBLIC_API_PREFIX fallback.
CONSOLE_API_URL=http://localhost:5001
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
# different from api or web app domain.
# example: https://cloud.dify.ai/console/api

View File

@ -45,6 +45,7 @@ vi.mock('@/next/navigation', () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))
@ -228,7 +229,7 @@ describe('App List Browsing Flow', () => {
mockPages = [createPage([])]
renderList()
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument()
})
it('should transition from loading to content when data loads', () => {
@ -283,7 +284,7 @@ describe('App List Browsing Flow', () => {
renderList()
expect(screen.getByText('app.createApp')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
})
it('should hide NewAppCard when user is not a workspace editor', () => {
@ -294,7 +295,7 @@ describe('App List Browsing Flow', () => {
renderList()
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.startFromBlank')).not.toBeInTheDocument()
})
})
@ -340,16 +341,18 @@ describe('App List Browsing Flow', () => {
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render all category tabs', () => {
it('should render all category options', async () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox', { name: 'app.types.all' }))
expect(await screen.findByRole('option', { name: 'app.types.all' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'app.types.workflow' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'app.types.advanced' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'app.types.chatbot' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'app.types.agent' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'app.types.completion' })).toBeInTheDocument()
})
})

View File

@ -42,6 +42,7 @@ vi.mock('@/next/navigation', () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))

View File

@ -141,7 +141,6 @@ describe('Header Account Dropdown Flow', () => {
})
it('logs out, resets cached user markers, and redirects to signin', async () => {
localStorage.setItem('setup_status', 'done')
localStorage.setItem('education-reverify-prev-expire-at', '1')
localStorage.setItem('education-reverify-has-noticed', '1')
localStorage.setItem('education-expired-has-noticed', '1')
@ -157,7 +156,6 @@ describe('Header Account Dropdown Flow', () => {
expect(mockPush).toHaveBeenCalledWith('/signin')
})
expect(localStorage.getItem('setup_status')).toBeNull()
expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull()
expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull()
expect(localStorage.getItem('education-expired-has-noticed')).toBeNull()

View File

@ -0,0 +1,124 @@
import type { ReactElement } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
queryClient: undefined as QueryClient | undefined,
profileQueryFn: vi.fn(),
systemFeaturesQueryFn: vi.fn(),
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
}),
headers: vi.fn(),
resolveServerConsoleApiUrl: vi.fn(),
}))
vi.mock('@/context/query-client-server', () => ({
getQueryClientServer: () => mocks.queryClient,
}))
vi.mock('@/next/headers', () => ({
headers: () => mocks.headers(),
}))
vi.mock('@/next/navigation', () => ({
redirect: (url: string) => mocks.redirect(url),
}))
vi.mock('@/features/account-profile/server', () => ({
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
serverUserProfileQueryOptions: () => ({
queryKey: ['common', 'user-profile'],
queryFn: mocks.profileQueryFn,
retry: false,
}),
}))
vi.mock('@/service/system-features', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['console', 'system-features'],
queryFn: mocks.systemFeaturesQueryFn,
retry: false,
}),
}))
describe('CommonLayoutHydrationBoundary', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.headers.mockResolvedValue(new Headers({
'x-dify-pathname': '/apps',
'x-dify-search': '?tag=workflow',
}))
mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile')
mocks.profileQueryFn.mockResolvedValue({
profile: {
id: 'account-id',
name: 'Dify User',
email: 'user@example.com',
avatar: '',
avatar_url: null,
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'DEVELOPMENT',
},
})
mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } })
})
it('should hydrate common layout queries and render children', async () => {
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
children: <div>Common shell</div>,
})
render(
<QueryClientProvider client={new QueryClient()}>
{element as ReactElement}
</QueryClientProvider>,
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
})
it('should redirect unauthorized users to the refresh route with the current path', async () => {
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }))
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow')
})
it('should redirect setup errors to install', async () => {
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }))
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
expect(mocks.redirect).toHaveBeenCalledWith('/install')
})
it('should render children without server prefetch when the server API URL is not resolvable', async () => {
mocks.resolveServerConsoleApiUrl.mockReturnValue(null)
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
const element = await CommonLayoutHydrationBoundary({
children: <div>Common shell</div>,
})
render(
<QueryClientProvider client={new QueryClient()}>
{element as ReactElement}
</QueryClientProvider>,
)
expect(screen.getByText('Common shell')).toBeInTheDocument()
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
})
})

View File

@ -1,8 +1,8 @@
'use client'
import { useEffect } from 'react'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import RootLoading from '@/app/loading'
import { useProviderContext } from '@/context/provider-context'
import {
useRouter,
@ -28,7 +28,7 @@ export default function EducationApply() {
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
return <RootLoading />
return <FullScreenLoading />
return <EducationApplyPage />
}

View File

@ -2,8 +2,8 @@
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import RootLoading from '@/app/loading'
import { isLegacyBase401 } from '@/service/use-common'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import { isLegacyBase401 } from '@/features/account-profile/client'
type Props = {
error: Error & { digest?: string }
@ -18,7 +18,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
// Showing the "Try again" button here would just flash for a few frames before
// the page navigates away, and clicking it would 401 again anyway.
if (isLegacyBase401(error))
return <RootLoading />
return <FullScreenLoading />
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">

View File

@ -0,0 +1,86 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClientServer } from '@/context/query-client-server'
import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
import { headers } from '@/next/headers'
import { redirect } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { basePath } from '@/utils/var'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const ACCOUNT_PROFILE_PATH = '/account/profile'
const AUTH_REFRESH_PATH = '/auth/refresh'
type ConsoleErrorPayload = {
code?: string
}
const isConsoleErrorPayload = (value: unknown): value is ConsoleErrorPayload =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPayload | null> => {
try {
const payload: unknown = await error.clone().json()
return isConsoleErrorPayload(payload) ? payload : null
}
catch {
return null
}
}
const getCurrentPath = async () => {
const requestHeaders = await headers()
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || `${basePath}/apps`
const search = requestHeaders.get(CURRENT_SEARCH_HEADER) || ''
return `${pathname}${search}`
}
const redirectToAuthRefresh = async () => {
const currentPath = await getCurrentPath()
redirect(`${basePath}${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
}
const handleProfileError = async (error: unknown) => {
if (!(error instanceof Response))
throw error
const errorData = await parseConsoleErrorPayload(error)
if (errorData?.code === 'not_setup')
redirect(`${basePath}/install`)
if (errorData?.code === 'not_init_validated')
redirect(`${basePath}/init`)
if (error.status === 401)
await redirectToAuthRefresh()
throw error
}
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (!accountProfileUrl) {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
try {
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(systemFeaturesQueryOptions()),
])
}
catch (error) {
await handleProfileError(error)
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}

View File

@ -1,26 +1,30 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import MainNavLayout from '@/app/components/main-nav/layout'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
const Layout = async ({ children }: { children: ReactNode }) => {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@ -38,8 +42,8 @@ const Layout = ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</AppInitializer>
</CommonLayoutHydrationBoundary>
<Zendesk />
</>
)
}

View File

@ -216,7 +216,6 @@ const EmailChangeModal = ({ onClose, email }: Props) => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')

View File

@ -16,10 +16,10 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { consoleQuery } from '@/service/client'
import { updateUserProfile } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
@ -49,7 +49,7 @@ export default function AccountPage() {
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const userProfile = userProfileResp.profile
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
const { isEducationAccount } = useProviderContext()
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')

View File

@ -12,8 +12,9 @@ import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter } from '@/next/navigation'
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
import { useLogout } from '@/service/use-common'
export default function AppSelector() {
const router = useRouter()
@ -31,7 +32,6 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
resetUser()
// Tokens are now stored in cookies and cleared by backend

View File

@ -1,21 +1,25 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
const Layout = ({ children }: { children: ReactNode }) => {
const Layout = async ({ children }: { children: ReactNode }) => {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@ -30,7 +34,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</AppInitializer>
</CommonLayoutHydrationBoundary>
</>
)
}

View File

@ -5,9 +5,9 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
export default function SignInLayout({ children }: any) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())

View File

@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter, useSearchParams } from '@/next/navigation'
import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common'
import { useLogout } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
function buildReturnUrl(pathname: string, search: string) {
@ -80,7 +80,6 @@ export default function OAuthAuthorize() {
const onLoginSwitchClick = async () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
setOAuthPendingRedirect(returnUrl)
if (isLoggedIn)
await logout()
router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`)

View File

@ -0,0 +1,104 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: 'http://localhost:5001/console/api',
}))
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: undefined,
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values) ? values : []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRequest = (url: string, cookie?: string) => ({
url,
headers: new Headers(cookie ? { cookie } : undefined),
}) as Request
describe('auth refresh route', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
})
it('should refresh cookies and redirect back to the requested path', async () => {
const headers = new Headers()
Object.defineProperty(headers, 'getSetCookie', {
value: () => [
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
],
})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers,
} as Response)
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow',
'refresh_token=old-refresh',
))
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:5001/console/api/refresh-token',
expect.objectContaining({
method: 'POST',
cache: 'no-store',
headers: expect.any(Headers),
}),
)
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(fetchHeaders.get('cookie')).toBe('refresh_token=old-refresh')
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/apps?category=workflow')
expect(getSetCookieHeaders(response.headers)).toEqual([
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
])
})
it('should redirect to signin when refresh token is rejected', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps',
'refresh_token=expired',
))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
})
it('should ignore cross-origin redirect targets', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }))
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'refresh_token=expired',
))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
})
})

View File

@ -0,0 +1,113 @@
import { API_PREFIX } from '@/config'
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { basePath } from '@/utils/var'
const REFRESH_TOKEN_PATH = '/refresh-token'
const AUTH_REFRESH_PATH = `${basePath}/auth/refresh`
const DEFAULT_REDIRECT_PATH = `${basePath}/apps`
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
const resolveAbsoluteUrlPrefix = (value: string) => {
try {
return new URL(value).toString()
}
catch {
return null
}
}
const resolveServerConsoleApiUrl = (pathname: string, requestUrl: URL) => {
const requestPath = withoutLeadingSlash(pathname)
const apiPrefix = SERVER_CONSOLE_API_PREFIX
|| resolveAbsoluteUrlPrefix(API_PREFIX)
|| new URL(API_PREFIX, requestUrl.origin).toString()
if (!apiPrefix)
return null
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
}
const resolveSafeRedirectPath = (request: Request) => {
const requestUrl = new URL(request.url)
const redirectUrl = requestUrl.searchParams.get('redirect_url')
if (!redirectUrl)
return DEFAULT_REDIRECT_PATH
try {
const target = new URL(redirectUrl, requestUrl.origin)
if (target.origin !== requestUrl.origin)
return DEFAULT_REDIRECT_PATH
if (target.pathname === AUTH_REFRESH_PATH)
return DEFAULT_REDIRECT_PATH
return `${target.pathname}${target.search}`
}
catch {
return DEFAULT_REDIRECT_PATH
}
}
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRedirectResponse = (request: Request, pathname: string, setCookies: string[] = []) => {
const headers = new Headers({
'Cache-Control': 'no-store',
'Location': new URL(pathname, request.url).toString(),
})
for (const cookie of setCookies)
headers.append('Set-Cookie', cookie)
return new Response(null, {
status: 303,
headers,
})
}
const createSigninRedirectResponse = (request: Request, redirectPath: string) =>
createRedirectResponse(request, `${basePath}/signin?redirect_url=${encodeURIComponent(redirectPath)}`)
export async function GET(request: Request) {
const requestUrl = new URL(request.url)
const redirectPath = resolveSafeRedirectPath(request)
const refreshUrl = resolveServerConsoleApiUrl(REFRESH_TOKEN_PATH, requestUrl)
const cookie = request.headers.get('cookie')
if (!refreshUrl || !cookie)
return createSigninRedirectResponse(request, redirectPath)
try {
const response = await fetch(refreshUrl, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
cookie,
}),
cache: 'no-store',
})
if (!response.ok)
return createSigninRedirectResponse(request, redirectPath)
return createRedirectResponse(request, redirectPath, getSetCookieHeaders(response.headers))
}
catch {
return createSigninRedirectResponse(request, redirectPath)
}
}

View File

@ -1,197 +0,0 @@
import { screen, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { AppInitializer } from '../app-initializer'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/utils/setup-status', () => ({
fetchSetupStatusWithCache: vi.fn(),
}))
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
resolvePostLoginRedirect: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = vi.mocked(useRouter)
const mockUseSearchParams = vi.mocked(useSearchParams)
const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache)
const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect)
const mockReplace = vi.fn()
describe('AppInitializer', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
window.localStorage.clear()
window.sessionStorage.clear()
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
mockUsePathname.mockReturnValue('/apps')
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
mockResolvePostLoginRedirect.mockReturnValue(null)
})
it('renders children after setup checks finish', async () => {
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
expect(mockReplace).not.toHaveBeenCalledWith('/signin')
})
it('redirects to install when setup status loading fails', async () => {
mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized'))
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
it('does not persist create app attribution from the url anymore', async () => {
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
})
it('tracks oauth registration with utm info and clears the cookie', async () => {
Cookies.set('utm_info', JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
}))
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
{ searchParams: 'oauth_new_user=true' },
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('falls back to the base registration event when the oauth utm cookie is invalid', async () => {
Cookies.set('utm_info', '{invalid-json')
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
{ searchParams: 'oauth_new_user=true' },
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('stores the education verification flag in localStorage', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
)
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
})
it('redirects to the resolved post-login url when one exists', async () => {
const mockLocationReplace = vi.fn()
vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace })
mockResolvePostLoginRedirect.mockReturnValue('/explore')
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
it('redirects to signin when redirect resolution throws', async () => {
mockResolvePostLoginRedirect.mockImplementation(() => {
throw new Error('redirect resolution failed')
})
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,40 @@
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
describe('EducationVerifyActionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
})
it('should store the education verification flag when the callback action is present', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
)
render(<EducationVerifyActionRecorder />)
await waitFor(() => {
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
})
})
it('should leave localStorage unchanged for unrelated routes', () => {
render(<EducationVerifyActionRecorder />)
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull()
})
})

View File

@ -0,0 +1,106 @@
import { render, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSearchParams } from '@/next/navigation'
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
const setSearchParams = (searchParams = '') => {
mockUseSearchParams.mockReturnValue(new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>)
window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`)
}
describe('OAuthRegistrationAnalytics', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
setSearchParams()
})
it('should track oauth registration with utm info and clear the query flag', async () => {
Cookies.set('utm_info', JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
}))
setSearchParams('oauth_new_user=true&source=signin')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(Cookies.get('utm_info')).toBeUndefined()
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
})
})
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
Cookies.set('utm_info', '{invalid-json')
setSearchParams('oauth_new_user=true')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('should do nothing without the oauth registration query flag', () => {
render(<OAuthRegistrationAnalytics />)
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
it('should clear a false oauth registration query flag without tracking', async () => {
setSearchParams('oauth_new_user=false')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
})
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
})

View File

@ -1,103 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import RootLoading from '@/app/loading'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
type AppInitializerProps = {
children: ReactNode
}
export const AppInitializer = ({
children,
}: AppInitializerProps) => {
const router = useRouter()
const searchParams = useSearchParams()
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser] = useQueryState(
'oauth_new_user',
parseAsBoolean.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)
return false
}
}, [])
useEffect(() => {
(async () => {
const action = searchParams.get('action')
if (oauthNewUser) {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
Cookies.remove('utm_info')
}
if (oauthNewUser !== null)
router.replace(pathname)
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
try {
const isFinished = await isSetupFinished()
if (!isFinished) {
router.replace('/install')
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
if (redirectUrl) {
location.replace(redirectUrl)
return
}
setInit(true)
}
catch {
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
return init ? children : <RootLoading />
}

View File

@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
@ -14,9 +14,11 @@ const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) =>
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
let mockSearchParams = new URLSearchParams('')
vi.mock('@/next/navigation', () => ({
useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
usePathname: () => '/apps',
useSearchParams: () => mockSearchParams,
}))
vi.mock('@/service/client', () => ({
@ -57,12 +59,10 @@ vi.mock('@/context/provider-context', () => ({
}))
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
emptyAppList: false,
@ -73,11 +73,20 @@ vi.mock('../hooks/use-apps-query-state', () => ({
query: mockQueryState,
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setIsCreatedByMe: mockSetIsCreatedByMe,
}),
}))
vi.mock('@/features/tag-management/components/tag-filter', () => ({
TagFilter: ({ value, onChange, onOpenTagManagement }: { value: string[], onChange: (value: string[]) => void, onOpenTagManagement: () => void }) => (
<div>
<button type="button" onClick={() => onChange(['tag-1'])}>common.tag.placeholder</button>
<span data-testid="tag-filter-value">{value.join(',')}</span>
<button type="button" onClick={onOpenTagManagement}>Manage tags</button>
</div>
),
}))
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
@ -258,6 +267,7 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
mockSearchParams = new URLSearchParams(searchParams)
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
@ -286,7 +296,6 @@ describe('List', () => {
mockServiceState.isLoading = false
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockQueryState.emptyAppList = false
@ -489,12 +498,12 @@ describe('List', () => {
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
fireEvent.click(screen.getByText('common.tag.placeholder'))
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
@ -511,6 +520,17 @@ describe('List', () => {
expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3)
expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined()
})
it('should remove legacy tagIDs from URL while preserving other filters', async () => {
renderList('?category=workflow&tagIDs=tag-1;tag-2&keywords=sales&isCreatedByMe=true')
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith(
'/apps?category=workflow&keywords=sales&isCreatedByMe=true',
{ scroll: false },
)
})
})
})
describe('Tag Filter', () => {

View File

@ -5,6 +5,7 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
// eslint-disable-next-line react/use-state -- renderHook executes a custom hook, not React.useState
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
}
@ -18,14 +19,12 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: 'all',
tagIDs: [],
keywords: '',
isCreatedByMe: false,
emptyAppList: false,
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
})
@ -36,7 +35,6 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
emptyAppList: true,
@ -119,33 +117,6 @@ describe('useAppsQueryState', () => {
}
})
it('should update tag filter URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setTagIDs(['tag1', 'tag2'])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
expect(update.options.history).toBe('push')
})
it('should remove tagIDs from URL when empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
act(() => {
result.current.setTagIDs([])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.tagIDs).toEqual([])
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should update created-by-me URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()

View File

@ -1,4 +1,4 @@
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { AppModes } from '@/types/app'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
@ -16,9 +16,6 @@ const appListQueryParsers = {
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
@ -39,10 +36,6 @@ export function useAppsQueryState() {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
@ -51,7 +44,6 @@ export function useAppsQueryState() {
query,
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
}), [query, setCategory, setKeywords, setIsCreatedByMe])
}

View File

@ -10,6 +10,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { CheckModal } from '@/hooks/use-pay'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
@ -32,15 +33,18 @@ function List({ controlRefreshList = 0 }: { controlRefreshList?: number }) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
const searchParams = useSearchParams()
const pathname = usePathname()
const { replace } = useRouter()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, tagIDs, keywords, isCreatedByMe, emptyAppList },
query: { category, keywords, isCreatedByMe, emptyAppList },
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
} = useAppsQueryState()
const [tagIDs, setTagIDs] = useState<string[]>([])
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@ -61,6 +65,16 @@ function List({ controlRefreshList = 0 }: { controlRefreshList?: number }) {
enabled: isCurrentWorkspaceEditor,
})
useEffect(() => {
if (!searchParams.has('tagIDs'))
return
const params = new URLSearchParams(searchParams.toString())
params.delete('tagIDs')
const query = params.toString()
replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
}, [pathname, replace, searchParams])
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,

View File

@ -0,0 +1,19 @@
'use client'
import { useEffect } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
export function EducationVerifyActionRecorder() {
const searchParams = useSearchParams()
useEffect(() => {
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
}, [searchParams])
return null
}

View File

@ -1,6 +1,6 @@
import Loading from '@/app/components/base/loading'
import Loading from './base/loading'
export default function CommonLayoutLoading() {
export function FullScreenLoading() {
return (
<div className="flex min-h-0 w-full flex-1 items-center justify-center bg-background-body">
<Loading />

View File

@ -277,7 +277,6 @@ describe('AccountDropdown', () => {
// Assert
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled()
expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})

View File

@ -42,7 +42,6 @@ export default function AppSelector({
const handleLogout = async () => {
await logout()
resetUser()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
// To avoid use other account's education notice info

View File

@ -1,7 +1,11 @@
'use client'
import type { EventEmitterValue } from '@/context/event-emitter'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { usePathname } from '@/next/navigation'
import { useLocalStorageBoolean } from '@/utils/local-storage'
import s from './index.module.css'
type HeaderWrapperProps = {
@ -13,9 +17,20 @@ const HeaderWrapper = ({
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
const hideHeader = eventHideHeader ?? storedHideHeader
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: EventEmitterValue) => {
if (typeof v === 'object' && v?.type === 'workflow-canvas-maximize' && typeof v.payload === 'boolean')
setEventHideHeader(v.payload)
})
return (
<div className={cn('sticky top-0 right-0 left-0 z-30 flex min-h-[56px] shrink-0 grow-0 basis-auto flex-col', s.header, isBordered ? 'border-b border-divider-regular' : '')}>
<div className={cn('sticky top-0 right-0 left-0 z-30 flex min-h-[56px] shrink-0 grow-0 basis-auto flex-col', s.header, isBordered ? 'border-b border-divider-regular' : '', hideHeader && (inWorkflowCanvas || isPipelineCanvas) && 'hidden')}>
{children}
</div>
)

View File

@ -3,19 +3,22 @@ import { useTranslation } from 'react-i18next'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { NOTICE_I18N } from '@/i18n-config/language'
import { setLocalStorageItem, useLocalStorageItem } from '@/utils/local-storage'
const MaintenanceNotice = () => {
const { t } = useTranslation()
const locale = useLanguage()
const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1')
const hiddenNotice = useLocalStorageItem('hide-maintenance-notice') === '1'
const [closedInSession, setClosedInSession] = useState(false)
const showNotice = !hiddenNotice && !closedInSession
const handleJumpNotice = () => {
window.open(NOTICE_I18N.href, '_blank')
}
const handleCloseNotice = () => {
localStorage.setItem('hide-maintenance-notice', '1')
setShowNotice(false)
setLocalStorageItem('hide-maintenance-notice', '1')
setClosedInSession(true)
}
const titleByLocale: { [key: string]: string } = NOTICE_I18N.title

View File

@ -0,0 +1,66 @@
'use client'
import Cookies from 'js-cookie'
import { useEffect, useRef } from 'react'
import { useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { trackEvent } from './base/amplitude'
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const removeOAuthNewUserParam = () => {
const url = new URL(window.location.href)
url.searchParams.delete(OAUTH_NEW_USER_PARAM)
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
}
export function OAuthRegistrationAnalytics() {
const searchParams = useSearchParams()
const oauthNewUserParam = searchParams.get(OAUTH_NEW_USER_PARAM)
const handledParamRef = useRef<string | null>(null)
useEffect(() => {
if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam)
return
handledParamRef.current = oauthNewUserParam
const oauthNewUser = oauthNewUserParam === 'true'
if (!oauthNewUser) {
removeOAuthNewUserParam()
return
}
let utmInfo: Record<string, unknown> | null = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
const parsed: unknown = JSON.parse(utmInfoStr)
if (isRecord(parsed))
utmInfo = parsed
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
trackEvent(eventName, {
method: 'oauth',
...utmInfo,
})
sendGAEvent(eventName, {
method: 'oauth',
...utmInfo,
})
Cookies.remove('utm_info')
removeOAuthNewUserParam()
}, [oauthNewUserParam])
return null
}

View File

@ -3,6 +3,12 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginItem from '../plugin-item'
vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
MagicBox: ({ className }: { className?: string }) => (
<svg data-testid="magic-box-icon" className={className} />
),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
@ -108,6 +114,23 @@ describe('PluginItem', () => {
expect(cardIcon).toHaveAttribute('data-src', 'https://example.com/icons/my-icon.svg')
expect(cardIcon).toHaveAttribute('data-size', 'small')
})
it('should show default tool icon when plugin icon is empty', () => {
const { container } = render(
<PluginItem
plugin={createPlugin({ icon: '' })}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="status"
/>,
)
expect(mockGetIconUrl).not.toHaveBeenCalled()
expect(screen.queryByTestId('card-icon')).not.toBeInTheDocument()
expect(container.querySelector('[data-testid="magic-box-icon"]')).toHaveClass('size-8', 'text-text-tertiary')
expect(screen.getByTestId('status-icon').parentElement).toHaveClass('absolute', '-bottom-0.5', '-right-0.5', 'z-10')
})
})
describe('Props', () => {

View File

@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
type PluginItemProps = {
@ -24,13 +25,20 @@ const PluginItem: FC<PluginItemProps> = ({
action,
onClear,
}) => {
const hasPluginIcon = !!plugin.icon
return (
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative shrink-0 self-start">
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
{hasPluginIcon
? (
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
)
// eslint-disable-next-line hyoban/prefer-tailwind-icons -- Reuse the same MagicBox component as the marketplace install button.
: <MagicBox className="size-8 text-text-tertiary" />}
<div className="absolute -right-0.5 -bottom-0.5 z-10">
{statusIcon}
</div>

View File

@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import { getLocalStorageBoolean, getLocalStorageNumber } from '@/utils/local-storage'
export type LayoutSliceShape = {
workflowCanvasWidth?: number
@ -20,6 +21,8 @@ export type LayoutSliceShape = {
setBottomPanelHeight: (height: number) => void
variableInspectPanelHeight: number // min-height = 120px; default-height = 320px;
setVariableInspectPanelHeight: (height: number) => void
maximizeCanvas: boolean
setMaximizeCanvas: (maximize: boolean) => void
}
export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
@ -32,10 +35,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
rightPanelWidth: undefined,
setRightPanelWidth: width => set(state =>
state.rightPanelWidth === width ? state : ({ rightPanelWidth: width })),
nodePanelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 400,
nodePanelWidth: getLocalStorageNumber('workflow-node-panel-width', 400),
setNodePanelWidth: width => set(state =>
state.nodePanelWidth === width ? state : ({ nodePanelWidth: width })),
previewPanelWidth: localStorage.getItem('debug-and-preview-panel-width') ? Number.parseFloat(localStorage.getItem('debug-and-preview-panel-width')!) : 400,
previewPanelWidth: getLocalStorageNumber('debug-and-preview-panel-width', 400),
setPreviewPanelWidth: width => set(state =>
state.previewPanelWidth === width ? state : ({ previewPanelWidth: width })),
otherPanelWidth: 400,
@ -47,7 +50,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
bottomPanelHeight: 324,
setBottomPanelHeight: height => set(state =>
state.bottomPanelHeight === height ? state : ({ bottomPanelHeight: height })),
variableInspectPanelHeight: localStorage.getItem('workflow-variable-inpsect-panel-height') ? Number.parseFloat(localStorage.getItem('workflow-variable-inpsect-panel-height')!) : 320,
variableInspectPanelHeight: getLocalStorageNumber('workflow-variable-inpsect-panel-height', 320),
setVariableInspectPanelHeight: height => set(state =>
state.variableInspectPanelHeight === height ? state : ({ variableInspectPanelHeight: height })),
maximizeCanvas: getLocalStorageBoolean('workflow-canvas-maximize'),
setMaximizeCanvas: maximize => set(state =>
state.maximizeCanvas === maximize ? state : ({ maximizeCanvas: maximize })),
})

View File

@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import { getLocalStorageNumber } from '@/utils/local-storage'
export type WorkflowContextMenuTarget
= | { type: 'panel' }
@ -33,7 +34,7 @@ export type PanelSliceShape = {
}
export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
panelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420,
panelWidth: getLocalStorageNumber('workflow-node-panel-width', 420),
showFeaturesPanel: false,
setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })),
showWorkflowVersionHistoryPanel: false,

View File

@ -6,6 +6,7 @@ import type {
WorkflowRunningData,
} from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { getLocalStorageItem, setLocalStorageItem } from '@/utils/local-storage'
type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
@ -21,6 +22,14 @@ type MousePosition = {
elementY: number
}
const getStoredControlMode = () => {
const storedControlMode = getLocalStorageItem('workflow-operation-mode')
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
return storedControlMode
return 'pointer'
}
export type WorkflowSliceShape = {
workflowRunningData?: PreviewRunningData
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
@ -92,16 +101,10 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
setSelection: selection => set(() => ({ selection })),
bundleNodeSize: null,
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
controlMode: (() => {
const storedControlMode = localStorage.getItem('workflow-operation-mode')
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
return storedControlMode
return 'pointer'
})(),
controlMode: getStoredControlMode(),
setControlMode: (controlMode) => {
set(() => ({ controlMode }))
localStorage.setItem('workflow-operation-mode', controlMode)
setLocalStorageItem('workflow-operation-mode', controlMode)
},
pendingComment: null,
setPendingComment: pendingComment => set(() => ({ pendingComment })),

View File

@ -6,9 +6,10 @@ import DevicePage from '../page'
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockDeviceLookup = vi.fn()
let mockSearchParams: Record<string, string | null> = {}
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({ get: () => null }),
useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
usePathname: () => '/device',
}))
@ -38,8 +39,11 @@ vi.mock('@/service/system-features', () => ({
systemFeaturesQueryOptions: () => ({ queryKey: ['sys'], queryFn: async () => ({}) }),
}))
vi.mock('@/service/use-common', () => ({
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({ queryKey: ['profile'], queryFn: async () => null }),
}))
vi.mock('@/service/use-common', () => ({
commonQueryKeys: { currentWorkspace: ['currentWorkspace'] },
}))
@ -53,6 +57,12 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor
beforeEach(async () => {
vi.clearAllMocks()
mockSearchParams = {}
// router.replace(pathname) in the real app drops the query string; mirror
// that so useSearchParams reflects the cleared URL on the next render.
mockReplace.mockImplementation(() => {
mockSearchParams = {}
})
mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>)
const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor }
MockDeviceFlowError = mod.DeviceFlowError
@ -110,3 +120,41 @@ describe('error_lookup_failed terminal state', () => {
expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument()
})
})
describe('sso_error inline banner on the code-entry page', () => {
const SSO_BANNER_COPY = /identity is linked to a Dify account/i
it('shows the error banner with friendly copy when sso_error is present', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument()
})
it('keeps the code-entry screen visible (error on main page, not a separate view)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Continue/i })).toBeInTheDocument()
})
it('does not surface the raw backend error code', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.queryByText('email_belongs_to_dify_account')).not.toBeInTheDocument()
})
it('does not scrub the param on mount (regression: error was wiped by router.replace)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(mockReplace).not.toHaveBeenCalled()
})
it('shows no banner when sso_error is absent', () => {
render(<DevicePage />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument()
})
})

View File

@ -5,16 +5,17 @@ import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import Divider from '@/app/components/base/divider'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { post } from '@/service/base'
import { deviceLookup } from '@/service/device-flow'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import { commonQueryKeys } from '@/service/use-common'
import AuthorizeAccount from './components/authorize-account'
import AuthorizeSSO from './components/authorize-sso'
import Chooser from './components/chooser'
import CodeInput from './components/code-input'
import { classifyLookupError } from './utils/error-copy'
import { classifyLookupError, ssoErrorCopy } from './utils/error-copy'
import { isValidUserCode } from './utils/user-code'
type View
@ -33,6 +34,7 @@ export default function DevicePage() {
const pathname = usePathname()
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
const ssoVerified = searchParams.get('sso_verified') === '1'
const ssoError = searchParams.get('sso_error') || ''
const [typed, setTyped] = useState('')
const [view, setView] = useState<View>({ kind: 'code_entry' })
@ -125,6 +127,12 @@ export default function DevicePage() {
<>
{view.kind === 'code_entry' && (
<div className="flex flex-col gap-5">
{ssoError && (
<div className="flex items-start gap-2 rounded-lg bg-state-destructive-hover p-3">
<span className="mt-0.5 i-ri-close-circle-line h-4 w-4 shrink-0 text-util-colors-red-red-600" />
<p className="text-sm text-text-destructive">{ssoErrorCopy(ssoError)}</p>
</div>
)}
<div>
<h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1>
<p className="mt-2 text-sm text-text-secondary">

View File

@ -30,6 +30,18 @@ export function approveErrorCopy(err: unknown): string {
return DEFAULT_MESSAGE
}
// SSO-branch failures arrive as a `sso_error` query param set by the backend
// (oauth_device_sso sso-complete) when it redirects back to /device.
const SSO_ERROR_COPY: Record<string, string> = {
email_belongs_to_dify_account: 'This identity is linked to a Dify account. Use “Sign in with Dify account” instead.',
}
const DEFAULT_SSO_ERROR_MESSAGE = 'Single sign-on could not be completed. Try again.'
export function ssoErrorCopy(code: string): string {
return SSO_ERROR_COPY[code] ?? DEFAULT_SSO_ERROR_MESSAGE
}
export type LookupOutcome = 'expired' | 'rate_limited' | 'failed'
export function classifyLookupError(err: unknown): LookupOutcome {

View File

@ -15,7 +15,6 @@ const UserInfo = () => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')

33
web/app/error.tsx Normal file
View File

@ -0,0 +1,33 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import { isLegacyBase401 } from '@/features/account-profile/client'
type Props = {
error: Error & { digest?: string }
reset?: () => void
unstable_retry?: () => void
}
export default function AppError({ error, reset, unstable_retry }: Props) {
const { t } = useTranslation('common')
const retry = reset ?? unstable_retry
if (isLegacyBase401(error))
return <FullScreenLoading />
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
<div className="system-sm-regular text-text-tertiary">
{t('errorBoundary.message')}
</div>
{retry && (
<Button size="small" variant="secondary" onClick={() => retry()}>
{t('errorBoundary.tryAgain')}
</Button>
)}
</div>
)
}

View File

@ -154,7 +154,6 @@ describe('InstallForm', () => {
render(<InstallForm />)
await waitFor(() => {
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})

View File

@ -87,7 +87,6 @@ const InstallForm = () => {
useEffect(() => {
fetchSetupStatus().then((res: SetupStatusResponse) => {
if (res.step === 'finished') {
localStorage.setItem('setup_status', 'finished')
router.push('/signin')
}
else {

View File

@ -1,9 +0,0 @@
import Loading from '@/app/components/base/loading'
export default function RootLoading() {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
<Loading />
</div>
)
}

View File

@ -6,11 +6,11 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CE_EDITION } from '@/config'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { invitationCheck } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
import { LicenseStatus } from '@/types/feature'
import Loading from '../components/base/loading'
import MailAndCodeAuth from './components/mail-and-code-auth'

View File

@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolvePostLoginRedirect, setPostLoginRedirect } from '../post-login-redirect'
describe('post-login redirect utilities', () => {
beforeEach(() => {
vi.useRealTimers()
window.localStorage.clear()
window.sessionStorage.clear()
})
it('should use the redirect_url query param first', () => {
const searchParams = new URLSearchParams({
redirect_url: encodeURIComponent('/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback'),
})
expect(resolvePostLoginRedirect(searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0])).toBe('/account/oauth/authorize?client_id=app&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback')
})
it('should recover a valid device redirect from sessionStorage once', () => {
setPostLoginRedirect('/device?user_code=ABCD&sso_verified=true')
expect(resolvePostLoginRedirect()).toBe('/device?user_code=ABCD&sso_verified=true')
expect(resolvePostLoginRedirect()).toBeNull()
})
it('should ignore invalid stored redirects', () => {
setPostLoginRedirect('https://example.com/device?user_code=ABCD')
expect(resolvePostLoginRedirect()).toBeNull()
})
})

View File

@ -1,6 +1,5 @@
import type { ReadonlyURLSearchParams } from '@/next/navigation'
const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending_redirect'
const REDIRECT_URL_KEY = 'redirect_url'
const DEVICE_REDIRECT_KEY = 'dify_post_login_redirect'
const DEVICE_TTL_MS = 15 * 60 * 1000
@ -10,13 +9,6 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
'/account/oauth/authorize': new Set(['client_id', 'scope', 'state', 'redirect_uri']),
}
type OAuthPendingRedirect = {
value?: string
expiry?: number
}
const getCurrentUnixTimestamp = () => Math.floor(Date.now() / 1000)
function validate(target: string): string | null {
if (typeof window === 'undefined')
return null
@ -87,51 +79,14 @@ function getDeviceRedirect(): string | null {
}
}
function removeOAuthPendingRedirect() {
try {
localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY)
}
catch {}
}
function getOAuthPendingRedirect(): string | null {
try {
const raw = localStorage.getItem(OAUTH_AUTHORIZE_PENDING_KEY)
if (!raw)
return null
removeOAuthPendingRedirect()
const item: OAuthPendingRedirect = JSON.parse(raw)
if (!item.value || typeof item.expiry !== 'number')
return null
return getCurrentUnixTimestamp() > item.expiry ? null : item.value
}
catch {
removeOAuthPendingRedirect()
return null
}
}
export function setOAuthPendingRedirect(url: string, ttlSeconds: number = 300) {
try {
const item: OAuthPendingRedirect = {
value: url,
expiry: getCurrentUnixTimestamp() + ttlSeconds,
}
localStorage.setItem(OAUTH_AUTHORIZE_PENDING_KEY, JSON.stringify(item))
}
catch {}
}
export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams) => {
if (searchParams) {
const redirectUrl = searchParams.get(REDIRECT_URL_KEY)
if (redirectUrl) {
try {
removeOAuthPendingRedirect()
return decodeURIComponent(redirectUrl)
}
catch {
removeOAuthPendingRedirect()
return redirectUrl
}
}
@ -139,5 +94,5 @@ export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams)
const device = getDeviceRedirect()
if (device)
return device
return getOAuthPendingRedirect()
return null
}

10
web/config/server.ts Normal file
View File

@ -0,0 +1,10 @@
import { env } from '@/env'
import 'server-only'
const withoutTrailingSlash = (value: string) => value.endsWith('/') ? value.slice(0, -1) : value
// Server-side requests need the origin; browser requests should keep using NEXT_PUBLIC_API_PREFIX.
export const SERVER_CONSOLE_API_PREFIX = env.CONSOLE_API_URL
? `${withoutTrailingSlash(env.CONSOLE_API_URL)}/console/api`
: undefined

View File

@ -16,11 +16,11 @@ import {
useSelector,
} from '@/context/app-context'
import { env } from '@/env'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
useCurrentWorkspace,
useLangGeniusVersion,
userProfileQueryOptions,
} from '@/service/use-common'
type AppContextProviderProps = {
@ -29,11 +29,6 @@ type AppContextProviderProps = {
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
const queryClient = useQueryClient()
// Boot point for the (commonLayout) tree:
// - useSuspenseQuery for systemFeatures triggers app/loading.tsx until cache is warm.
// - useSuspenseQuery for userProfile triggers (commonLayout)/loading.tsx until cache is warm.
// After this provider mounts, downstream components reading the same queryKeys hit cache
// and never suspend again, so their useSuspenseQuery calls return data synchronously.
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace()
@ -65,7 +60,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
const mutateUserProfile = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['common', 'user-profile'] })
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {

View File

@ -21,6 +21,10 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(() => new URLSearchParams()),
}))
vi.mock('@/app/components/billing/pricing', () => ({
default: () => <div>billing.plansCommon.mostPopular</div>,
}))
const mockUseProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),

View File

@ -1,6 +1,29 @@
import { type } from '@orpc/contract'
import { base } from '../base'
export type AccountProfileResponse = {
id: string
name: string
email: string
avatar: string
avatar_url: string | null
is_password_set: boolean
interface_language?: string
interface_theme?: string
timezone?: string
last_login_at?: string
last_active_at?: string
last_login_ip?: string
created_at?: string
}
export const accountProfileContract = base
.route({
path: '/account/profile',
method: 'GET',
})
.output(type<AccountProfileResponse>())
export const accountAvatarContract = base
.route({
path: '/account/avatar',

View File

@ -1,7 +1,7 @@
import type { InferContractRouterInputs } from '@orpc/contract'
import { contract as communityContract } from '@dify/contracts/api/console/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { accountAvatarContract } from './console/account'
import { accountAvatarContract, accountProfileContract } from './console/account'
import { appDeleteContract, appListContract, workflowOnlineUsersContract } from './console/apps'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import {
@ -77,6 +77,10 @@ export const consoleRouterContract = {
account: {
...communityContract.account,
avatar: accountAvatarContract,
profile: {
...communityContract.account.profile,
get: accountProfileContract,
},
},
systemFeatures: systemFeaturesContract,
apps: {

View File

@ -142,6 +142,7 @@ const clientSchema = {
export const env = createEnv({
server: {
CONSOLE_API_URL: z.string().optional(),
/**
* Maximum length of segmentation tokens for indexing
*/

View File

@ -0,0 +1,79 @@
import type { AccountProfileResponse } from '@/contract/console/account'
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { userProfileQueryOptions } from '../client'
import { resolveServerConsoleApiUrl } from '../server'
const headersMock = vi.fn()
const cookiesMock = vi.fn()
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: undefined,
}))
vi.mock('@/next/headers', () => ({
headers: () => headersMock(),
cookies: () => cookiesMock(),
}))
const createProfile = (overrides: Partial<AccountProfileResponse> = {}): AccountProfileResponse => ({
id: 'account-id',
name: 'Dify User',
email: 'user@example.com',
avatar: '',
avatar_url: null,
is_password_set: true,
...overrides,
})
describe('serverUserProfileQueryOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
headersMock.mockResolvedValue(new Headers({ cookie: 'session=abc' }))
cookiesMock.mockResolvedValue({
get: vi.fn(() => ({ value: 'csrf-token' })),
})
})
it('should reuse the client profile query key and return the same data shape', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(createProfile()), {
status: 200,
headers: {
'content-type': 'application/json',
'x-version': '1.2.3',
'x-env': 'DEVELOPMENT',
},
}))
vi.stubGlobal('fetch', fetchMock)
const { serverUserProfileQueryOptions } = await import('../server')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const data = await queryClient.fetchQuery(serverUserProfileQueryOptions())
expect(serverUserProfileQueryOptions().queryKey).toEqual(userProfileQueryOptions().queryKey)
expect(data).toEqual({
profile: createProfile(),
meta: {
currentVersion: '1.2.3',
currentEnv: 'DEVELOPMENT',
},
})
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:5001/console/api/account/profile',
expect.objectContaining({
method: 'GET',
cache: 'no-store',
headers: expect.any(Headers),
}),
)
})
it('should skip relative API prefixes unless a server API origin is configured', () => {
expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull()
expect(resolveServerConsoleApiUrl('/account/profile', 'https://console.example.com/console/api', '/console/api')).toBe('https://console.example.com/console/api/account/profile')
})
it('should preserve absolute API prefixes', () => {
expect(resolveServerConsoleApiUrl('/account/profile', undefined, 'https://console.example.com/console/api')).toBe('https://console.example.com/console/api/account/profile')
})
})

View File

@ -0,0 +1,41 @@
import type { AccountProfileResponse } from '@/contract/console/account'
import { queryOptions } from '@tanstack/react-query'
import { IS_DEV } from '@/config'
// eslint-disable-next-line no-restricted-imports
import { get } from '@/service/base'
import { consoleQuery } from '@/service/client'
export type UserProfileWithMeta = {
profile: AccountProfileResponse
meta: {
currentVersion: string | null
currentEnv: string | null
}
}
export const isLegacyBase401 = (err: unknown): boolean =>
err instanceof Response && err.status === 401
export const userProfileQueryOptions = () =>
queryOptions<UserProfileWithMeta>({
queryKey: consoleQuery.account.profile.get.queryKey(),
queryFn: async () => {
const response = await get<Response>('/account/profile', {}, {
needAllResponseContent: true,
silent: true,
})
const profile: AccountProfileResponse = await response.clone().json()
return {
profile,
meta: {
currentVersion: response.headers.get('x-version'),
currentEnv: IS_DEV
? 'DEVELOPMENT'
: response.headers.get('x-env'),
},
}
},
staleTime: 0,
gcTime: 0,
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
})

View File

@ -0,0 +1,81 @@
import type { UserProfileWithMeta } from './client'
import type { AccountProfileResponse } from '@/contract/console/account'
import { queryOptions } from '@tanstack/react-query'
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config'
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { cookies, headers } from '@/next/headers'
import { consoleQuery } from '@/service/client'
const ACCOUNT_PROFILE_PATH = '/account/profile'
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
const resolveAbsoluteUrlPrefix = (value: string) => {
try {
return new URL(value).toString()
}
catch {
return null
}
}
export const resolveServerConsoleApiUrl = (
pathname: string,
serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX,
publicApiPrefix = API_PREFIX,
) => {
const requestPath = withoutLeadingSlash(pathname)
const apiPrefix = serverConsoleApiPrefix || resolveAbsoluteUrlPrefix(publicApiPrefix)
if (!apiPrefix)
return null
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
}
const getServerRequestHeaders = async () => {
const requestHeaders = await headers()
const cookieStore = await cookies()
const outgoingHeaders = new Headers({
'Content-Type': 'application/json',
})
const cookie = requestHeaders.get('cookie')
if (cookie)
outgoingHeaders.set('cookie', cookie)
const csrfToken = cookieStore.get(CSRF_COOKIE_NAME())?.value
if (csrfToken)
outgoingHeaders.set(CSRF_HEADER_NAME, csrfToken)
return outgoingHeaders
}
export const serverUserProfileQueryOptions = () =>
queryOptions<UserProfileWithMeta>({
queryKey: consoleQuery.account.profile.get.queryKey(),
queryFn: async () => {
const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (!profileUrl)
throw new Error('Server account profile URL is not configured')
const response = await fetch(profileUrl, {
method: 'GET',
headers: await getServerRequestHeaders(),
cache: 'no-store',
})
if (!response.ok)
throw response
const profile: AccountProfileResponse = await response.clone().json()
return {
profile,
meta: {
currentVersion: response.headers.get('x-version'),
currentEnv: response.headers.get('x-env'),
},
}
},
staleTime: 0,
gcTime: 0,
retry: false,
})

View File

@ -136,6 +136,7 @@
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"scheduler": "catalog:",
"server-only": "catalog:",
"sharp": "catalog:",
"shiki": "catalog:",
"socket.io-client": "catalog:",

View File

@ -6,6 +6,8 @@ import { NextResponse } from 'next/server'
import { env } from '@/env'
const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://ungh.cc https://api2.amplitude.com *.amplitude.com'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => {
// prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking
@ -16,8 +18,10 @@ const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string)
return response
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const { pathname, search } = request.nextUrl
const requestHeaders = new Headers(request.headers)
requestHeaders.set(CURRENT_PATHNAME_HEADER, pathname)
requestHeaders.set(CURRENT_SEARCH_HEADER, search)
const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production'
if (!isWhiteListEnabled) {

View File

@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const createUnauthorizedResponse = () =>
new Response(JSON.stringify({
code: 'unauthorized',
message: 'Invalid Authorization token.',
status: 401,
}), {
status: 401,
headers: {
'Content-Type': 'application/json',
},
})
async function loadServerRequest() {
vi.resetModules()
const mockBaseFetch = vi.fn(async () => {
throw createUnauthorizedResponse()
})
const mockRefreshAccessTokenOrReLogin = vi.fn()
vi.doMock('@/utils/client', () => ({
isClient: false,
isServer: true,
}))
vi.doMock('../fetch', () => ({
base: mockBaseFetch,
ContentType: {
audio: 'audio/mpeg',
download: 'application/octet-stream',
downloadZip: 'application/zip',
json: 'application/json',
},
getBaseOptions: vi.fn(() => ({})),
}))
vi.doMock('../refresh-token', () => ({
refreshAccessTokenOrReLogin: mockRefreshAccessTokenOrReLogin,
}))
const { request } = await import('../base')
return {
request,
mockRefreshAccessTokenOrReLogin,
}
}
describe('request 401 handling', () => {
afterEach(() => {
vi.resetModules()
vi.restoreAllMocks()
})
it('should not run browser auth recovery when handling 401 on the server', async () => {
const { request, mockRefreshAccessTokenOrReLogin } = await loadServerRequest()
await expect(request('/account/profile')).rejects.toMatchObject({ status: 401 })
expect(mockRefreshAccessTokenOrReLogin).not.toHaveBeenCalled()
})
})

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