Merge branch 'feat/rag-pipeline' into deploy/rag-dev

This commit is contained in:
twwu 2025-06-06 10:52:53 +08:00
commit ef7bd262c5
109 changed files with 1987 additions and 485 deletions

View File

@ -8,7 +8,7 @@ inputs:
uv-version:
description: UV version to set up
required: true
default: '0.6.14'
default: '~=0.7.11'
uv-lockfile:
description: Path to the UV lockfile to restore cache from
required: true

7
.gitignore vendored
View File

@ -192,12 +192,12 @@ sdks/python-client/dist
sdks/python-client/dify_client.egg-info
.vscode/*
!.vscode/launch.json
!.vscode/launch.json.template
!.vscode/README.md
pyrightconfig.json
api/.vscode
.idea/
.vscode
# pnpm
/.pnpm-store
@ -207,3 +207,6 @@ plugins.jsonl
# mise
mise.toml
# Next.js build output
.next/

14
.vscode/README.md vendored Normal file
View File

@ -0,0 +1,14 @@
# Debugging with VS Code
This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory.
## How to Use
1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory.
2. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file.
3. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D).
4. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button.
## Tips
- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging.

68
.vscode/launch.json.template vendored Normal file
View File

@ -0,0 +1,68 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask API",
"type": "debugpy",
"request": "launch",
"module": "flask",
"env": {
"FLASK_APP": "app.py",
"FLASK_ENV": "development",
"GEVENT_SUPPORT": "True"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5001",
"--no-debugger",
"--no-reload"
],
"jinja": true,
"justMyCode": true,
"cwd": "${workspaceFolder}/api",
"python": "${workspaceFolder}/api/.venv/bin/python"
},
{
"name": "Python: Celery Worker (Solo)",
"type": "debugpy",
"request": "launch",
"module": "celery",
"env": {
"GEVENT_SUPPORT": "True"
},
"args": [
"-A",
"app.celery",
"worker",
"-P",
"solo",
"-c",
"1",
"-Q",
"dataset,generation,mail,ops_trace",
"--loglevel",
"INFO"
],
"justMyCode": false,
"cwd": "${workspaceFolder}/api",
"python": "${workspaceFolder}/api/.venv/bin/python"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/web/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}/web"
},
"cwd": "${workspaceFolder}/web"
}
]
}

View File

@ -4,7 +4,7 @@ FROM python:3.12-slim-bookworm AS base
WORKDIR /app/api
# Install uv
ENV UV_VERSION=0.6.14
ENV UV_VERSION=0.7.11
RUN pip install --no-cache-dir uv==${UV_VERSION}

View File

@ -32,6 +32,7 @@ def get_user(tenant_id: str, user_id: str | None) -> Account | EndUser:
)
session.add(user_model)
session.commit()
session.refresh(user_model)
else:
user_model = AccountService.load_user(user_id)
if not user_model:

View File

@ -209,10 +209,10 @@ class OpenSearchVector(BaseVector):
return docs
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
full_text_query = {"query": {"match": {Field.CONTENT_KEY.value: query}}}
full_text_query = {"query": {"bool": {"must": [{"match": {Field.CONTENT_KEY.value: query}}]}}}
document_ids_filter = kwargs.get("document_ids_filter")
if document_ids_filter:
full_text_query["query"]["terms"] = {"metadata.document_id": document_ids_filter}
full_text_query["query"]["bool"]["filter"] = [{"terms": {"metadata.document_id": document_ids_filter}}]
response = self._client.search(index=self._collection_name.lower(), body=full_text_query)
@ -255,7 +255,8 @@ class OpenSearchVector(BaseVector):
Field.METADATA_KEY.value: {
"type": "object",
"properties": {
"doc_id": {"type": "keyword"} # Map doc_id to keyword type
"doc_id": {"type": "keyword"}, # Map doc_id to keyword type
"document_id": {"type": "keyword"},
},
},
}

View File

@ -1,3 +1,4 @@
- audio
- code
- time
- qrcode
- webscraper

View File

@ -397,19 +397,44 @@ def _extract_text_from_csv(file_content: bytes) -> str:
if not rows:
return ""
# Create Markdown table
markdown_table = "| " + " | ".join(rows[0]) + " |\n"
markdown_table += "| " + " | ".join(["---"] * len(rows[0])) + " |\n"
for row in rows[1:]:
markdown_table += "| " + " | ".join(row) + " |\n"
# Combine multi-line text in the header row
header_row = [cell.replace("\n", " ").replace("\r", "") for cell in rows[0]]
return markdown_table.strip()
# Create Markdown table
markdown_table = "| " + " | ".join(header_row) + " |\n"
markdown_table += "| " + " | ".join(["-" * len(col) for col in rows[0]]) + " |\n"
# Process each data row and combine multi-line text in each cell
for row in rows[1:]:
processed_row = [cell.replace("\n", " ").replace("\r", "") for cell in row]
markdown_table += "| " + " | ".join(processed_row) + " |\n"
return markdown_table
except Exception as e:
raise TextExtractionError(f"Failed to extract text from CSV: {str(e)}") from e
def _extract_text_from_excel(file_content: bytes) -> str:
"""Extract text from an Excel file using pandas."""
def _construct_markdown_table(df: pd.DataFrame) -> str:
"""Manually construct a Markdown table from a DataFrame."""
# Construct the header row
header_row = "| " + " | ".join(df.columns) + " |"
# Construct the separator row
separator_row = "| " + " | ".join(["-" * len(col) for col in df.columns]) + " |"
# Construct the data rows
data_rows = []
for _, row in df.iterrows():
data_row = "| " + " | ".join(map(str, row)) + " |"
data_rows.append(data_row)
# Combine all rows into a single string
markdown_table = "\n".join([header_row, separator_row] + data_rows)
return markdown_table
try:
excel_file = pd.ExcelFile(io.BytesIO(file_content))
markdown_table = ""
@ -417,8 +442,15 @@ def _extract_text_from_excel(file_content: bytes) -> str:
try:
df = excel_file.parse(sheet_name=sheet_name)
df.dropna(how="all", inplace=True)
# Create Markdown table two times to separate tables with a newline
markdown_table += df.to_markdown(index=False, floatfmt="") + "\n\n"
# Combine multi-line text in each cell into a single line
df = df.applymap(lambda x: " ".join(str(x).splitlines()) if isinstance(x, str) else x) # type: ignore
# Combine multi-line text in column names into a single line
df.columns = pd.Index([" ".join(col.splitlines()) for col in df.columns])
# Manually construct the Markdown table
markdown_table += _construct_markdown_table(df) + "\n\n"
except Exception as e:
continue
return markdown_table

View File

@ -18,6 +18,7 @@ from flask_restful import fields
from configs import dify_config
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
from core.file import helpers as file_helpers
from core.model_runtime.utils.encoders import jsonable_encoder
from extensions.ext_redis import redis_client
if TYPE_CHECKING:
@ -196,7 +197,7 @@ def generate_text_hash(text: str) -> str:
def compact_generate_response(response: Union[Mapping, Generator, RateLimitGenerator]) -> Response:
if isinstance(response, dict):
return Response(response=json.dumps(response), status=200, mimetype="application/json")
return Response(response=json.dumps(jsonable_encoder(response)), status=200, mimetype="application/json")
else:
def generate() -> Generator:

View File

@ -1,5 +1,7 @@
import io
from unittest.mock import Mock, patch
import pandas as pd
import pytest
from docx.oxml.text.paragraph import CT_P
@ -187,145 +189,134 @@ def test_node_type(document_extractor_node):
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_single_sheet(mock_excel_file):
"""Test extracting text from Excel file with single sheet."""
# Mock DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = "| Name | Age |\n|------|-----|\n| John | 25 |"
"""Test extracting text from Excel file with single sheet and multiline content."""
# Test multi-line cell
data = {"Name\nwith\nnewline": ["John\nDoe", "Jane\nSmith"], "Age": [25, 30]}
df = pd.DataFrame(data)
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["Sheet1"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_instance.parse.return_value = df
mock_excel_file.return_value = mock_excel_instance
file_content = b"fake_excel_content"
result = _extract_text_from_excel(file_content)
expected_manual = "| Name with newline | Age |\n| ----------------- | --- |\n\
| John Doe | 25 |\n| Jane Smith | 30 |\n\n"
expected = "| Name | Age |\n|------|-----|\n| John | 25 |\n\n"
assert result == expected
mock_excel_file.assert_called_once()
mock_df.dropna.assert_called_once_with(how="all", inplace=True)
mock_df.to_markdown.assert_called_once_with(index=False, floatfmt="")
assert expected_manual == result
mock_excel_instance.parse.assert_called_once_with(sheet_name="Sheet1")
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_multiple_sheets(mock_excel_file):
"""Test extracting text from Excel file with multiple sheets."""
# Mock DataFrames for different sheets
mock_df1 = Mock()
mock_df1.dropna = Mock()
mock_df1.to_markdown.return_value = "| Product | Price |\n|---------|-------|\n| Apple | 1.50 |"
"""Test extracting text from Excel file with multiple sheets and multiline content."""
mock_df2 = Mock()
mock_df2.dropna = Mock()
mock_df2.to_markdown.return_value = "| City | Population |\n|------|------------|\n| NYC | 8000000 |"
# Test multi-line cell
data1 = {"Product\nName": ["Apple\nRed", "Banana\nYellow"], "Price": [1.50, 0.99]}
df1 = pd.DataFrame(data1)
data2 = {"City\nName": ["New\nYork", "Los\nAngeles"], "Population": [8000000, 3900000]}
df2 = pd.DataFrame(data2)
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["Products", "Cities"]
mock_excel_instance.parse.side_effect = [mock_df1, mock_df2]
mock_excel_instance.parse.side_effect = [df1, df2]
mock_excel_file.return_value = mock_excel_instance
file_content = b"fake_excel_content_multiple_sheets"
result = _extract_text_from_excel(file_content)
expected = (
"| Product | Price |\n|---------|-------|\n| Apple | 1.50 |\n\n"
"| City | Population |\n|------|------------|\n| NYC | 8000000 |\n\n"
)
assert result == expected
expected_manual1 = "| Product Name | Price |\n| ------------ | ----- |\n\
| Apple Red | 1.5 |\n| Banana Yellow | 0.99 |\n\n"
expected_manual2 = "| City Name | Population |\n| --------- | ---------- |\n\
| New York | 8000000 |\n| Los Angeles | 3900000 |\n\n"
assert expected_manual1 in result
assert expected_manual2 in result
assert mock_excel_instance.parse.call_count == 2
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_empty_sheets(mock_excel_file):
"""Test extracting text from Excel file with empty sheets."""
# Mock empty DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = ""
# Empty excel
df = pd.DataFrame()
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["EmptySheet"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_instance.parse.return_value = df
mock_excel_file.return_value = mock_excel_instance
file_content = b"fake_excel_empty_content"
result = _extract_text_from_excel(file_content)
expected = "\n\n"
expected = "| |\n| |\n\n"
assert result == expected
mock_excel_instance.parse.assert_called_once_with(sheet_name="EmptySheet")
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_sheet_parse_error(mock_excel_file):
"""Test handling of sheet parsing errors - should continue with other sheets."""
# Mock DataFrames - one successful, one that raises exception
mock_df_success = Mock()
mock_df_success.dropna = Mock()
mock_df_success.to_markdown.return_value = "| Data | Value |\n|------|-------|\n| Test | 123 |"
# Test error
data = {"Data": ["Test"], "Value": [123]}
df = pd.DataFrame(data)
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["GoodSheet", "BadSheet"]
mock_excel_instance.parse.side_effect = [mock_df_success, Exception("Parse error")]
mock_excel_instance.parse.side_effect = [df, Exception("Parse error")]
mock_excel_file.return_value = mock_excel_instance
file_content = b"fake_excel_mixed_content"
result = _extract_text_from_excel(file_content)
expected = "| Data | Value |\n|------|-------|\n| Test | 123 |\n\n"
assert result == expected
expected_manual = "| Data | Value |\n| ---- | ----- |\n| Test | 123 |\n\n"
assert expected_manual == result
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_file_error(mock_excel_file):
"""Test handling of Excel file reading errors."""
mock_excel_file.side_effect = Exception("Invalid Excel file")
file_content = b"invalid_excel_content"
with pytest.raises(Exception) as exc_info:
_extract_text_from_excel(file_content)
# Note: The function should raise TextExtractionError, but since it's not imported in the test,
# we check for the general Exception pattern
assert "Failed to extract text from Excel file" in str(exc_info.value)
assert mock_excel_instance.parse.call_count == 2
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_io_bytesio_usage(mock_excel_file):
"""Test that BytesIO is properly used with the file content."""
import io
# Mock DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = "| Test | Data |\n|------|------|\n| 1 | A |"
# Test bytesio
data = {"Test": [1], "Data": ["A"]}
df = pd.DataFrame(data)
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["TestSheet"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_instance.parse.return_value = df
mock_excel_file.return_value = mock_excel_instance
file_content = b"test_excel_bytes"
result = _extract_text_from_excel(file_content)
# Verify that ExcelFile was called with a BytesIO object
mock_excel_file.assert_called_once()
call_args = mock_excel_file.call_args[0][0]
assert isinstance(call_args, io.BytesIO)
call_arg = mock_excel_file.call_args[0][0]
assert isinstance(call_arg, io.BytesIO)
expected = "| Test | Data |\n|------|------|\n| 1 | A |\n\n"
assert result == expected
expected_manual = "| Test | Data |\n| ---- | ---- |\n| 1 | A |\n\n"
assert expected_manual == result
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_all_sheets_fail(mock_excel_file):
"""Test when all sheets fail to parse - should return empty string."""
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["BadSheet1", "BadSheet2"]
@ -335,29 +326,6 @@ def test_extract_text_from_excel_all_sheets_fail(mock_excel_file):
file_content = b"fake_excel_all_bad_sheets"
result = _extract_text_from_excel(file_content)
# Should return empty string when all sheets fail
assert result == ""
@patch("pandas.ExcelFile")
def test_extract_text_from_excel_markdown_formatting(mock_excel_file):
"""Test that markdown formatting parameters are correctly applied."""
# Mock DataFrame
mock_df = Mock()
mock_df.dropna = Mock()
mock_df.to_markdown.return_value = "| Float | Int |\n|-------|-----|\n| 123456.78 | 42 |"
# Mock ExcelFile
mock_excel_instance = Mock()
mock_excel_instance.sheet_names = ["NumberSheet"]
mock_excel_instance.parse.return_value = mock_df
mock_excel_file.return_value = mock_excel_instance
file_content = b"fake_excel_numbers"
result = _extract_text_from_excel(file_content)
# Verify to_markdown was called with correct parameters
mock_df.to_markdown.assert_called_once_with(index=False, floatfmt="")
expected = "| Float | Int |\n|-------|-----|\n| 123456.78 | 42 |\n\n"
assert result == expected
assert mock_excel_instance.parse.call_count == 2

View File

@ -47,7 +47,7 @@ class DifyClient:
def text_to_audio(self, text: str, user: str, streaming: bool = False):
data = {"text": text, "user": user, "streaming": streaming}
return self._send_request("POST", "/text-to-audio", data=data)
return self._send_request("POST", "/text-to-audio", json=data)
def get_meta(self, user):
params = {"user": user}

View File

@ -4,7 +4,7 @@ import { useContext, useContextSelector } from 'use-context-selector'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react'
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import type { App } from '@/types/app'
import Confirm from '@/app/components/base/confirm'
@ -338,7 +338,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
</div>
<div className='flex h-5 w-5 shrink-0 items-center justify-center'>
{app.access_mode === AccessMode.PUBLIC && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.anyone')}>
<RiGlobalLine className='h-4 w-4 text-text-accent' />
<RiGlobalLine className='h-4 w-4 text-text-quaternary' />
</Tooltip>}
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.specific')}>
<RiLockLine className='h-4 w-4 text-text-quaternary' />
@ -346,6 +346,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.access_mode === AccessMode.ORGANIZATION && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.organization')}>
<RiBuildingLine className='h-4 w-4 text-text-quaternary' />
</Tooltip>}
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.external')}>
<RiVerifiedBadgeLine className='h-4 w-4 text-text-quaternary' />
</Tooltip>}
</div>
</div>
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>

View File

@ -88,11 +88,11 @@ const Apps = () => {
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('app.types.all'), icon: <RiApps2Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'workflow', text: t('app.types.workflow'), icon: <RiExchange2Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'advanced-chat', text: t('app.types.advanced'), icon: <RiMessage3Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'chat', text: t('app.types.chatbot'), icon: <RiMessage3Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'agent-chat', text: t('app.types.agent'), icon: <RiRobot3Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'completion', text: t('app.types.completion'), icon: <RiFile4Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'advanced-chat', text: t('app.types.advanced'), icon: <RiMessage3Line className='mr-1 h-[14px] w-[14px]' /> },
{ value: 'workflow', text: t('app.types.workflow'), icon: <RiExchange2Line className='mr-1 h-[14px] w-[14px]' /> },
]
useEffect(() => {

View File

@ -1,14 +1,42 @@
import React from 'react'
'use client'
import React, { useEffect, useState } from 'react'
import type { FC } from 'react'
import type { Metadata } from 'next'
export const metadata: Metadata = {
icons: 'data:,', // prevent browser from using default favicon
}
import { usePathname, useSearchParams } from 'next/navigation'
import Loading from '../components/base/loading'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
import { getAppAccessModeByAppCode } from '@/service/share'
const Layout: FC<{
children: React.ReactNode
}> = ({ children }) => {
const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending)
const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode)
const pathname = usePathname()
const searchParams = useSearchParams()
const redirectUrl = searchParams.get('redirect_url')
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
(async () => {
let appCode: string | null = null
if (redirectUrl)
appCode = redirectUrl?.split('/').pop() || null
else
appCode = pathname.split('/').pop() || null
if (!appCode)
return
setIsLoading(true)
const ret = await getAppAccessModeByAppCode(appCode)
setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC)
setIsLoading(false)
})()
}, [pathname, redirectUrl, setWebAppAccessMode])
if (isLoading || isGlobalPending) {
return <div className='flex h-full w-full items-center justify-center'>
<Loading />
</div>
}
return (
<div className="h-full min-w-[300px] pb-[env(safe-area-inset-bottom)]">
{children}

View File

@ -0,0 +1,96 @@
'use client'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Countdown from '@/app/components/signin/countdown'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
import I18NContext from '@/context/i18n'
export default function CheckCode() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const email = decodeURIComponent(searchParams.get('email') as string)
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const verify = async () => {
try {
if (!code.trim()) {
Toast.notify({
type: 'error',
message: t('login.checkCode.emptyCode'),
})
return
}
if (!/\d{6}/.test(code)) {
Toast.notify({
type: 'error',
message: t('login.checkCode.invalidCode'),
})
return
}
setIsLoading(true)
const ret = await verifyWebAppResetPasswordCode({ email, code, token })
if (ret.is_valid) {
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(ret.token))
router.push(`/webapp-reset-password/set-password?${params.toString()}`)
}
}
catch (error) { console.error(error) }
finally {
setIsLoading(false)
}
}
const resendCode = async () => {
try {
const res = await sendWebAppResetPasswordCode(email, locale)
if (res.result === 'success') {
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
router.replace(`/webapp-reset-password/check-code?${params.toString()}`)
}
}
catch (error) { console.error(error) }
}
return <div className='flex flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg'>
<RiMailSendFill className='h-6 w-6 text-2xl' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
<span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span>
<br />
{t('login.checkCode.validTime')}
</p>
</div>
<form action="">
<input type='text' className='hidden' />
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
</div>
</div>
}

View File

@ -0,0 +1,30 @@
'use client'
import Header from '@/app/signin/_header'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
<div className='flex w-[400px] flex-col'>
{children}
</div>
</div>
{!systemFeatures.branding.enabled && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
}

View File

@ -0,0 +1,104 @@
'use client'
import Link from 'next/link'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendResetPasswordCode } from '@/service/common'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CheckCode() {
const { t } = useTranslation()
useDocumentTitle('')
const searchParams = useSearchParams()
const router = useRouter()
const [email, setEmail] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const handleGetEMailVerificationCode = async () => {
try {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
})
return
}
setIsLoading(true)
const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email))
router.push(`/webapp-reset-password/check-code?${params.toString()}`)
}
else if (res.code === 'account_not_found') {
Toast.notify({
type: 'error',
message: t('login.error.registrationNotAllowed'),
})
}
else {
Toast.notify({
type: 'error',
message: res.data,
})
}
}
catch (error) {
console.error(error)
}
finally {
setIsLoading(false)
}
}
return <div className='flex flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
<RiLockPasswordLine className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.resetPassword')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
{t('login.resetPasswordDesc')}
</p>
</div>
<form onSubmit={noop}>
<input type='text' className='hidden' />
<div className='mb-2'>
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
<div className='mt-1'>
<Input id='email' type="email" disabled={loading} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.sendVerificationCode')}</Button>
</div>
</div>
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<Link href={`/webapp-signin?${searchParams.toString()}`} className='flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary'>
<div className='inline-block rounded-full bg-background-default-dimmed p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.backToLogin')}</span>
</Link>
</div>
}

View File

@ -0,0 +1,188 @@
'use client'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import cn from 'classnames'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import Button from '@/app/components/base/button'
import { changeWebAppPasswordWithToken } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Input from '@/app/components/base/input'
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
const ChangePasswordForm = () => {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('token') || '')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showSuccess, setShowSuccess] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const showErrorMessage = useCallback((message: string) => {
Toast.notify({
type: 'error',
message,
})
}, [])
const getSignInUrl = () => {
return `/webapp-signin?redirect_url=${searchParams.get('redirect_url') || ''}`
}
const AUTO_REDIRECT_TIME = 5000
const [leftTime, setLeftTime] = useState<number | undefined>(undefined)
const [countdown] = useCountDown({
leftTime,
onEnd: () => {
router.replace(getSignInUrl())
},
})
const valid = useCallback(() => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
return false
}
return true
}, [password, confirmPassword, showErrorMessage, t])
const handleChangePassword = useCallback(async () => {
if (!valid())
return
try {
await changeWebAppPasswordWithToken({
url: '/forgot-password/resets',
body: {
token,
new_password: password,
password_confirm: confirmPassword,
},
})
setShowSuccess(true)
setLeftTime(AUTO_REDIRECT_TIME)
}
catch (error) {
console.error(error)
}
}, [password, token, valid, confirmPassword])
return (
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
{!showSuccess && (
<div className='flex flex-col md:w-[400px]'>
<div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">
{t('login.changePassword')}
</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
{t('login.changePasswordTip')}
</p>
</div>
<div className="mx-auto mt-6 w-full">
<div className="bg-white">
{/* Password */}
<div className='mb-5'>
<label htmlFor="password" className="system-md-semibold my-2 text-text-secondary">
{t('common.account.newPassword')}
</label>
<div className='relative mt-1'>
<Input
id="password" type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t('login.passwordPlaceholder') || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='body-xs-regular mt-1 text-text-secondary'>{t('login.error.passwordInvalid')}</div>
</div>
{/* Confirm Password */}
<div className='mb-5'>
<label htmlFor="confirmPassword" className="system-md-semibold my-2 text-text-secondary">
{t('common.account.confirmPassword')}
</label>
<div className='relative mt-1'>
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder={t('login.confirmPasswordPlaceholder') || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div>
<Button
variant='primary'
className='w-full'
onClick={handleChangePassword}
>
{t('login.changePasswordBtn')}
</Button>
</div>
</div>
</div>
</div>
)}
{showSuccess && (
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className='h-6 w-6 text-text-success' />
</div>
<h2 className="title-4xl-semi-bold text-text-primary">
{t('login.passwordChangedTip')}
</h2>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant='primary' className='w-full' onClick={() => {
setLeftTime(undefined)
router.replace(getSignInUrl())
}}>{t('login.passwordChanged')} ({Math.round(countdown / 1000)}) </Button>
</div>
</div>
)}
</div>
)
}
export default ChangePasswordForm

View File

@ -0,0 +1,115 @@
'use client'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Countdown from '@/app/components/signin/countdown'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import I18NContext from '@/context/i18n'
import { setAccessToken } from '@/app/components/share/utils'
import { fetchAccessToken } from '@/service/share'
export default function CheckCode() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const email = decodeURIComponent(searchParams.get('email') as string)
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const redirectUrl = searchParams.get('redirect_url')
const getAppCodeFromRedirectUrl = useCallback(() => {
const appCode = redirectUrl?.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const verify = async () => {
try {
const appCode = getAppCodeFromRedirectUrl()
if (!code.trim()) {
Toast.notify({
type: 'error',
message: t('login.checkCode.emptyCode'),
})
return
}
if (!/\d{6}/.test(code)) {
Toast.notify({
type: 'error',
message: t('login.checkCode.invalidCode'),
})
return
}
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: t('login.error.redirectUrlMissing'),
})
return
}
setIsLoading(true)
const ret = await webAppEmailLoginWithCode({ email, code, token })
if (ret.result === 'success') {
localStorage.setItem('webapp_access_token', ret.data.access_token)
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: ret.data.access_token })
await setAccessToken(appCode, tokenResp.access_token)
router.replace(redirectUrl)
}
}
catch (error) { console.error(error) }
finally {
setIsLoading(false)
}
}
const resendCode = async () => {
try {
const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') {
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(ret.data))
router.replace(`/webapp-signin/check-code?${params.toString()}`)
}
}
catch (error) { console.error(error) }
}
return <div className='flex w-[400px] flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
<RiMailSendFill className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
<span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span>
<br />
{t('login.checkCode.validTime')}
</p>
</div>
<form action="">
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
</div>
</div>
}

View File

@ -0,0 +1,80 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect } from 'react'
import Toast from '@/app/components/base/toast'
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { SSOProtocol } from '@/types/feature'
import Loading from '@/app/components/base/loading'
import AppUnavailable from '@/app/components/base/app-unavailable'
const ExternalMemberSSOAuth = () => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const searchParams = useSearchParams()
const router = useRouter()
const redirectUrl = searchParams.get('redirect_url')
const showErrorToast = (message: string) => {
Toast.notify({
type: 'error',
message,
})
}
const getAppCodeFromRedirectUrl = useCallback(() => {
const appCode = redirectUrl?.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const handleSSOLogin = useCallback(async () => {
const appCode = getAppCodeFromRedirectUrl()
if (!appCode || !redirectUrl) {
showErrorToast('redirect url or app code is invalid.')
return
}
switch (systemFeatures.webapp_auth.sso_config.protocol) {
case SSOProtocol.SAML: {
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
router.push(samlRes.url)
break
}
case SSOProtocol.OIDC: {
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
router.push(oidcRes.url)
break
}
case SSOProtocol.OAuth2: {
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
router.push(oauth2Res.url)
break
}
case '':
break
default:
showErrorToast('SSO protocol is not supported.')
}
}, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
useEffect(() => {
handleSSOLogin()
}, [handleSSOLogin])
if (!systemFeatures.webapp_auth.sso_config.protocol) {
return <div className="flex h-full items-center justify-center">
<AppUnavailable code={403} unknownReason='sso protocol is invalid.' />
</div>
}
return (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
export default React.memo(ExternalMemberSSOAuth)

View File

@ -0,0 +1,68 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import { emailRegex } from '@/config'
import Toast from '@/app/components/base/toast'
import { sendWebAppEMailLoginCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
export default function MailAndCodeAuth() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const handleGetEMailVerificationCode = async () => {
try {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
})
return
}
setIsLoading(true)
const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))
router.push(`/webapp-signin/check-code?${params.toString()}`)
}
}
catch (error) {
console.error(error)
}
finally {
setIsLoading(false)
}
}
return (<form onSubmit={noop}>
<input type='text' className='hidden' />
<div className='mb-2'>
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
<div className='mt-1'>
<Input id='email' type="email" value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.continueWithCode')}</Button>
</div>
</div>
</form>
)
}

View File

@ -0,0 +1,171 @@
import Link from 'next/link'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { webAppLogin } from '@/service/common'
import Input from '@/app/components/base/input'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
import { setAccessToken } from '@/app/components/share/utils'
import { fetchAccessToken } from '@/service/share'
type MailAndPasswordAuthProps = {
isEmailSetup: boolean
}
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) {
const { t } = useTranslation()
const { locale } = useContext(I18NContext)
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const redirectUrl = searchParams.get('redirect_url')
const getAppCodeFromRedirectUrl = useCallback(() => {
const appCode = redirectUrl?.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const handleEmailPasswordLogin = async () => {
const appCode = getAppCodeFromRedirectUrl()
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
})
return
}
if (!password?.trim()) {
Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') })
return
}
if (!passwordRegex.test(password)) {
Toast.notify({
type: 'error',
message: t('login.error.passwordInvalid'),
})
return
}
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: t('login.error.redirectUrlMissing'),
})
return
}
try {
setIsLoading(true)
const loginData: Record<string, any> = {
email,
password,
language: locale,
remember_me: true,
}
const res = await webAppLogin({
url: '/login',
body: loginData,
})
if (res.result === 'success') {
localStorage.setItem('webapp_access_token', res.data.access_token)
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: res.data.access_token })
await setAccessToken(appCode, tokenResp.access_token)
router.replace(redirectUrl)
}
else {
Toast.notify({
type: 'error',
message: res.data,
})
}
}
finally {
setIsLoading(false)
}
}
return <form onSubmit={noop}>
<div className='mb-3'>
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">
{t('login.email')}
</label>
<div className="mt-1">
<Input
value={email}
onChange={e => setEmail(e.target.value)}
id="email"
type="email"
autoComplete="email"
placeholder={t('login.emailPlaceholder') || ''}
tabIndex={1}
/>
</div>
</div>
<div className='mb-3'>
<label htmlFor="password" className="my-2 flex items-center justify-between">
<span className='system-md-semibold text-text-secondary'>{t('login.password')}</span>
<Link
href={`/webapp-reset-password?${searchParams.toString()}`}
className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`}
tabIndex={isEmailSetup ? 0 : -1}
aria-disabled={!isEmailSetup}
>
{t('login.forget')}
</Link>
</label>
<div className="relative mt-1">
<Input
id="password"
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={t('login.passwordPlaceholder') || ''}
tabIndex={2}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div className='mb-2'>
<Button
tabIndex={2}
variant='primary'
onClick={handleEmailPasswordLogin}
disabled={isLoading || !email || !password}
className="w-full"
>{t('login.signBtn')}</Button>
</div>
</form>
}

View File

@ -0,0 +1,88 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Toast from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
import { SSOProtocol } from '@/types/feature'
import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share'
type SSOAuthProps = {
protocol: SSOProtocol | ''
}
const SSOAuth: FC<SSOAuthProps> = ({
protocol,
}) => {
const router = useRouter()
const { t } = useTranslation()
const searchParams = useSearchParams()
const redirectUrl = searchParams.get('redirect_url')
const getAppCodeFromRedirectUrl = useCallback(() => {
const appCode = redirectUrl?.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const [isLoading, setIsLoading] = useState(false)
const handleSSOLogin = () => {
const appCode = getAppCodeFromRedirectUrl()
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: 'invalid redirect URL or app code',
})
return
}
setIsLoading(true)
if (protocol === SSOProtocol.SAML) {
fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => {
router.push(res.url)
}).finally(() => {
setIsLoading(false)
})
}
else if (protocol === SSOProtocol.OIDC) {
fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => {
router.push(res.url)
}).finally(() => {
setIsLoading(false)
})
}
else if (protocol === SSOProtocol.OAuth2) {
fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => {
router.push(res.url)
}).finally(() => {
setIsLoading(false)
})
}
else {
Toast.notify({
type: 'error',
message: 'invalid SSO protocol',
})
setIsLoading(false)
}
}
return (
<Button
tabIndex={0}
onClick={() => { handleSSOLogin() }}
disabled={isLoading}
className="w-full"
>
<Lock01 className='mr-2 h-5 w-5 text-text-accent-light-mode-only' />
<span className="truncate">{t('login.withSSO')}</span>
</Button>
)
}
export default SSOAuth

View File

@ -0,0 +1,25 @@
'use client'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
useDocumentTitle('')
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
{/* <Header /> */}
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className='flex justify-center md:w-[440px] lg:w-[600px]'>
{children}
</div>
</div>
{systemFeatures.branding.enabled === false && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
}

View File

@ -0,0 +1,176 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
import Loading from '@/app/components/base/loading'
import MailAndCodeAuth from './components/mail-and-code-auth'
import MailAndPasswordAuth from './components/mail-and-password-auth'
import SSOAuth from './components/sso-auth'
import cn from '@/utils/classnames'
import { LicenseStatus } from '@/types/feature'
import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
const NormalForm = () => {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const { systemFeatures } = useGlobalPublicStore()
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
const [showORLine, setShowORLine] = useState(false)
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
const init = useCallback(async () => {
try {
setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin)
setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login))
updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code')
}
catch (error) {
console.error(error)
setAllMethodsAreDisabled(true)
}
finally { setIsLoading(false) }
}, [systemFeatures])
useEffect(() => {
init()
}, [init])
if (isLoading) {
return <div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
<Loading type='area' />
</div>
}
if (systemFeatures.license?.status === LicenseStatus.LOST) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseLost')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseLostTip')}</p>
</div>
</div>
</div>
}
if (systemFeatures.license?.status === LicenseStatus.EXPIRED) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseExpired')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseExpiredTip')}</p>
</div>
</div>
</div>
}
if (systemFeatures.license?.status === LicenseStatus.INACTIVE) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseInactive')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseInactiveTip')}</p>
</div>
</div>
</div>
}
return (
<>
<div className="mx-auto mt-8 w-full">
<div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2>
{!systemFeatures.branding.enabled && <p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>}
</div>
<div className="relative">
<div className="mt-6 flex flex-col gap-3">
{systemFeatures.sso_enforced_for_signin && <div className='w-full'>
<SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} />
</div>}
</div>
{showORLine && <div className="relative mt-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div className="relative flex justify-center">
<span className="system-xs-medium-uppercase px-2 text-text-tertiary">{t('login.or')}</span>
</div>
</div>}
{
(systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <>
{systemFeatures.enable_email_code_login && authType === 'code' && <>
<MailAndCodeAuth />
{systemFeatures.enable_email_password_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('password') }}>
<span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.usePassword')}</span>
</div>}
</>}
{systemFeatures.enable_email_password_login && authType === 'password' && <>
<MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} />
{systemFeatures.enable_email_code_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('code') }}>
<span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.useVerificationCode')}</span>
</div>}
</>}
</>
}
{allMethodsAreDisabled && <>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiDoorLockLine className='h-5 w-5' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.noLoginMethodTip')}</p>
</div>
<div className="relative my-2 py-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
</div>
</>}
{!systemFeatures.branding.enabled && <>
<div className="system-xs-regular mt-2 block w-full text-text-tertiary">
{t('login.tosDesc')}
&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
target='_blank' rel='noopener noreferrer'
href='https://dify.ai/terms'
>{t('login.tos')}</Link>
&nbsp;&&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
target='_blank' rel='noopener noreferrer'
href='https://dify.ai/privacy'
>{t('login.pp')}</Link>
</div>
{IS_CE_EDITION && <div className="w-hull system-xs-regular mt-2 block text-text-tertiary">
{t('login.goToInit')}
&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
href='/install'
>{t('login.setAdminAccount')}</Link>
</div>}
</>}
</div>
</div>
</>
)
}
export default NormalForm

View File

@ -3,19 +3,20 @@ import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { RiDoorLockLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import Toast from '@/app/components/base/toast'
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { setAccessToken } from '@/app/components/share/utils'
import { removeAccessToken, setAccessToken } from '@/app/components/share/utils'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { SSOProtocol } from '@/types/feature'
import Loading from '@/app/components/base/loading'
import AppUnavailable from '@/app/components/base/app-unavailable'
import NormalForm from './normalForm'
import { AccessMode } from '@/models/access-control'
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
import { fetchAccessToken } from '@/service/share'
const WebSSOForm: FC = () => {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode)
const searchParams = useSearchParams()
const router = useRouter()
@ -23,10 +24,22 @@ const WebSSOForm: FC = () => {
const tokenFromUrl = searchParams.get('web_sso_token')
const message = searchParams.get('message')
const showErrorToast = (message: string) => {
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
return `/webapp-signin?${params.toString()}`
}, [searchParams])
const backToHome = useCallback(() => {
removeAccessToken()
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router])
const showErrorToast = (msg: string) => {
Toast.notify({
type: 'error',
message,
message: msg,
})
}
@ -38,102 +51,73 @@ const WebSSOForm: FC = () => {
return appCode
}, [redirectUrl])
const processTokenAndRedirect = useCallback(async () => {
const appCode = getAppCodeFromRedirectUrl()
if (!appCode || !tokenFromUrl || !redirectUrl) {
showErrorToast('redirect url or app code or token is invalid.')
return
}
useEffect(() => {
(async () => {
if (message)
return
await setAccessToken(appCode, tokenFromUrl)
router.push(redirectUrl)
}, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl])
const handleSSOLogin = useCallback(async () => {
const appCode = getAppCodeFromRedirectUrl()
if (!appCode || !redirectUrl) {
showErrorToast('redirect url or app code is invalid.')
return
}
switch (systemFeatures.webapp_auth.sso_config.protocol) {
case SSOProtocol.SAML: {
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
router.push(samlRes.url)
break
const appCode = getAppCodeFromRedirectUrl()
if (appCode && tokenFromUrl && redirectUrl) {
localStorage.setItem('webapp_access_token', tokenFromUrl)
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl })
await setAccessToken(appCode, tokenResp.access_token)
router.replace(redirectUrl)
return
}
case SSOProtocol.OIDC: {
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
router.push(oidcRes.url)
break
if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) {
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') })
await setAccessToken(appCode, tokenResp.access_token)
router.replace(redirectUrl)
}
case SSOProtocol.OAuth2: {
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
router.push(oauth2Res.url)
break
}
case '':
break
default:
showErrorToast('SSO protocol is not supported.')
}
}, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
})()
}, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message])
useEffect(() => {
const init = async () => {
if (message) {
showErrorToast(message)
return
}
if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl)
router.replace(redirectUrl)
}, [webAppAccessMode, router, redirectUrl])
if (!tokenFromUrl) {
await handleSSOLogin()
return
}
await processTokenAndRedirect()
}
init()
}, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin])
if (tokenFromUrl)
return <div className='flex h-full items-center justify-center'><Loading /></div>
if (message) {
if (tokenFromUrl) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={'App Unavailable'} unknownReason={message} />
<Loading />
</div>
}
if (systemFeatures.webapp_auth.enabled) {
if (systemFeatures.webapp_auth.allow_sso) {
return (
<div className="flex h-full items-center justify-center">
<div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}>
<Loading />
</div>
</div>
)
}
return <div className="flex h-full items-center justify-center">
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiDoorLockLine className='h-5 w-5' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.webapp.noLoginMethod')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.webapp.noLoginMethodTip')}</p>
</div>
<div className="relative my-2 py-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
</div>
if (message) {
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' code={t('share.common.appUnavailable')} unknownReason={message} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('share.login.backToHome')}</span>
</div>
}
else {
if (!redirectUrl) {
showErrorToast('redirect url is invalid.')
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={t('share.common.appUnavailable')} unknownReason='redirect url is invalid.' />
</div>
}
if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
if (!systemFeatures.webapp_auth.enabled) {
return <div className="flex h-full items-center justify-center">
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>
</div>
}
if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) {
return <div className='w-full max-w-[400px]'>
<NormalForm />
</div>
}
if (webAppAccessMode && webAppAccessMode === AccessMode.EXTERNAL_MEMBERS)
return <ExternalMemberSsoAuth />
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' isUnknownReason={true} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('share.login.backToHome')}</span>
</div>
}
export default React.memo(WebSSOForm)

View File

@ -1,6 +1,6 @@
'use client'
import { Dialog } from '@headlessui/react'
import { RiBuildingLine, RiGlobalLine } from '@remixicon/react'
import { Description as DialogDescription, DialogTitle } from '@headlessui/react'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect } from 'react'
import Button from '../../base/button'
@ -67,8 +67,8 @@ export default function AccessControl(props: AccessControlProps) {
return <AccessControlDialog show onClose={onClose}>
<div className='flex flex-col gap-y-3'>
<div className='pb-3 pl-6 pr-14 pt-6'>
<Dialog.Title className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</Dialog.Title>
<Dialog.Description className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</Dialog.Description>
<DialogTitle className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</DialogTitle>
<DialogDescription className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</DialogDescription>
</div>
<div className='flex flex-col gap-y-1 px-6 pb-3'>
<div className='leading-6'>
@ -80,12 +80,20 @@ export default function AccessControl(props: AccessControlProps) {
<RiBuildingLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers />
</AccessControlItem>
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className='flex items-center p-3'>
<div className='flex grow items-center gap-x-2'>
<RiVerifiedBadgeLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.external')}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<div className='flex items-center gap-x-2 p-3'>
<RiGlobalLine className='h-4 w-4 text-text-primary' />

View File

@ -3,12 +3,10 @@ import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect } from 'react'
import Avatar from '../../base/avatar'
import Divider from '../../base/divider'
import Tooltip from '../../base/tooltip'
import Loading from '../../base/loading'
import useAccessControlStore from '../../../../context/access-control-store'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import { useGlobalPublicStore } from '@/context/global-public-context'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
@ -19,11 +17,6 @@ export default function SpecificGroupsOrMembers() {
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const hideTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
useEffect(() => {
@ -37,7 +30,6 @@ export default function SpecificGroupsOrMembers() {
<RiLockLine className='h-4 w-4 text-text-primary' />
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
}
@ -48,10 +40,6 @@ export default function SpecificGroupsOrMembers() {
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
</div>
<div className='flex items-center gap-x-1'>
{!hideTip && <>
<WebAppSSONotEnabledTip />
<Divider className='ml-2 mr-0 h-[14px]' type="vertical" />
</>}
<AddMemberOrGroupDialog />
</div>
</div>

View File

@ -9,11 +9,14 @@ import dayjs from 'dayjs'
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBuildingLine,
RiGlobalLine,
RiLockLine,
RiPlanetLine,
RiPlayCircleLine,
RiPlayList2Line,
RiTerminalBoxLine,
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
@ -276,10 +279,30 @@ const AppPublisher = ({
setShowAppAccessControl(true)
}}>
<div className='flex grow items-center gap-x-1.5 pr-1'>
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
{appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>}
{appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>}
{appDetail?.access_mode === AccessMode.ORGANIZATION
&& <>
<RiBuildingLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>
</>
}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
&& <>
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>
</>
}
{appDetail?.access_mode === AccessMode.PUBLIC
&& <>
<RiGlobalLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
</>
}
{appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS
&& <>
<RiVerifiedBadgeLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.external')}</p>
</>
}
</div>
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>

View File

@ -1,9 +1,9 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useRouter } from 'next/navigation'
import { useContext, useContextSelector } from 'use-context-selector'
import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react'
import Link from 'next/link'
@ -19,7 +19,6 @@ import AppsContext, { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { ToastContext } from '@/app/components/base/toast'
import type { AppMode } from '@/types/app'
import { AppModes } from '@/types/app'
import { createApp } from '@/service/apps'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@ -56,14 +55,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
const isCreatingRef = useRef(false)
const searchParams = useSearchParams()
useEffect(() => {
const category = searchParams.get('category')
if (category && AppModes.includes(category as AppMode))
setAppMode(category as AppMode)
}, [searchParams])
const onCreate = useCallback(async () => {
if (!appMode) {
notify({ type: 'error', message: t('app.newApp.appTypeRequired') })
@ -128,7 +119,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
onClick={() => {
setAppMode('workflow')
}} />
<AppTypeCard
<AppTypeCard
active={appMode === 'advanced-chat'}
title={t('app.types.advanced')}
description={t('app.newApp.advancedShortDescription')}

View File

@ -5,10 +5,13 @@ import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
RiBookOpenLine,
RiBuildingLine,
RiEqualizer2Line,
RiExternalLinkLine,
RiGlobalLine,
RiLockLine,
RiPaintBrushLine,
RiVerifiedBadgeLine,
RiWindowLine,
} from '@remixicon/react'
import SettingsModal from './settings'
@ -248,11 +251,30 @@ function AppCard({
<div className='flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2'
onClick={handleClickAccessControl}>
<div className='flex grow items-center gap-x-1.5 pr-1'>
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
{appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>}
{appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>}
</div>
{appDetail?.access_mode === AccessMode.ORGANIZATION
&& <>
<RiBuildingLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>
</>
}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
&& <>
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>
</>
}
{appDetail?.access_mode === AccessMode.PUBLIC
&& <>
<RiGlobalLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
</>
}
{appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS
&& <>
<RiVerifiedBadgeLine className='h-4 w-4 shrink-0 text-text-secondary' />
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.external')}</p>
</>
}</div>
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />

View File

@ -15,7 +15,7 @@ export type AppSelectorProps = {
onChange: (value: AppSelectorProps['value']) => void
}
const allTypes: AppMode[] = ['chat', 'agent-chat', 'completion', 'advanced-chat', 'workflow']
const allTypes: AppMode[] = ['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion']
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)

View File

@ -1,4 +1,5 @@
'use client'
import classNames from '@/utils/classnames'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
@ -7,17 +8,19 @@ type IAppUnavailableProps = {
code?: number | string
isUnknownReason?: boolean
unknownReason?: string
className?: string
}
const AppUnavailable: FC<IAppUnavailableProps> = ({
code = 404,
isUnknownReason,
unknownReason,
className,
}) => {
const { t } = useTranslation()
return (
<div className='flex h-screen w-screen items-center justify-center'>
<div className={classNames('flex h-screen w-screen items-center justify-center', className)}>
<h1 className='mr-5 h-[50px] pr-5 text-[24px] font-medium leading-[50px]'
style={{
borderRight: '1px solid rgba(0,0,0,.3)',

View File

@ -16,14 +16,12 @@ import type {
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
import { AccessMode } from '@/models/access-control'
export type ChatWithHistoryContextValue = {
appInfoError?: any
appInfoLoading?: boolean
appMeta?: AppMeta
appData?: AppData
accessMode?: AccessMode
userCanAccess?: boolean
appParams?: ChatConfig
appChatListDataLoading?: boolean
@ -64,7 +62,6 @@ export type ChatWithHistoryContextValue = {
}
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
userCanAccess: false,
currentConversationId: '',
appPrevChatTree: [],

View File

@ -43,9 +43,8 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { noop } from 'lodash-es'
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
@ -77,11 +76,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
appId: installedAppInfo?.app.id || appInfo?.app_id,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
appId: installedAppInfo?.app.id || appInfo?.app_id,
isInstalledApp,
@ -492,8 +486,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
return {
appInfoError,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)),
accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
isInstalledApp,
appId,

View File

@ -124,7 +124,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
const {
appInfoError,
appInfoLoading,
accessMode,
userCanAccess,
appData,
appParams,
@ -169,7 +168,6 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
appInfoError,
appInfoLoading,
appData,
accessMode,
userCanAccess,
appParams,
appMeta,

View File

@ -19,7 +19,6 @@ import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/re
import DifyLogo from '@/app/components/base/logo/dify-logo'
import type { ConversationItem } from '@/models/share'
import cn from '@/utils/classnames'
import { AccessMode } from '@/models/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
type Props = {
@ -30,7 +29,6 @@ const Sidebar = ({ isPanel }: Props) => {
const { t } = useTranslation()
const {
isInstalledApp,
accessMode,
appData,
handleNewConversation,
pinnedConversationList,
@ -140,7 +138,7 @@ const Sidebar = ({ isPanel }: Props) => {
)}
</div>
<div className='flex shrink-0 items-center justify-between p-3'>
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} placement='top-start' data={appData?.site} />
<MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} />
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (

View File

@ -366,7 +366,7 @@ export const useChat = (
if (!newResponseItem)
return
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
updateChatTreeNode(responseItem.id, {
content: isUseAgentThought ? '' : newResponseItem.answer,
log: [

View File

@ -15,10 +15,8 @@ import type {
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
import { AccessMode } from '@/models/access-control'
export type EmbeddedChatbotContextValue = {
accessMode?: AccessMode
userCanAccess?: boolean
appInfoError?: any
appInfoLoading?: boolean
@ -58,7 +56,6 @@ export type EmbeddedChatbotContextValue = {
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
userCanAccess: false,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
currentConversationId: '',
appPrevChatList: [],
pinnedConversationList: [],

View File

@ -36,9 +36,8 @@ import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { noop } from 'lodash-es'
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
function getFormattedChatList(messages: any[]) {
const newChatList: ChatItem[] = []
@ -70,11 +69,6 @@ export const useEmbeddedChatbot = () => {
const isInstalledApp = false
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
appId: appInfo?.app_id,
isInstalledApp,
enabled: systemFeatures.webapp_auth.enabled,
})
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
appId: appInfo?.app_id,
isInstalledApp,
@ -385,8 +379,7 @@ export const useEmbeddedChatbot = () => {
return {
appInfoError,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)),
accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC,
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
isInstalledApp,
allowResetChat,

View File

@ -38,7 +38,7 @@ export default function Radio({
const divClassName = `
flex items-center py-1 relative
px-7 cursor-pointer text-text-secondary rounded
bg-components-option-card-option-bg hover:bg-components-option-card-option-bg-hover hover:shadow-xs
hover:bg-components-option-card-option-bg-hover hover:shadow-xs
`
return (

View File

@ -51,8 +51,8 @@ export const useDatasourceOptions = (pipelineNodes: Node<DataSourceNodeType>[])
return {
nodeId: node.id,
type: node.data.provider_type as DatasourceType,
description: node.data.desc || '',
docTitle: '', // todo: Add docTitle and docLink if needed, or remove these properties if not used
description: node.data.datasource_label,
docTitle: 'How to use?',
docLink: '',
fileExtensions: node.data.fileExtensions || [],
}
@ -119,31 +119,31 @@ export const useLocalFile = () => {
}
}
export const useNotionsPages = () => {
const [notionPages, setNotionPages] = useState<NotionPage[]>([])
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
export const useOnlineDocuments = () => {
const [onlineDocuments, setOnlineDocuments] = useState<NotionPage[]>([])
const [currentDocuments, setCurrentDocuments] = useState<NotionPage | undefined>()
const previewNotionPage = useRef<NotionPage>(notionPages[0])
const previewOnlineDocument = useRef<NotionPage>(onlineDocuments[0])
const updateNotionPages = (value: NotionPage[]) => {
setNotionPages(value)
const updateOnlineDocuments = (value: NotionPage[]) => {
setOnlineDocuments(value)
}
const updateCurrentPage = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
setCurrentDocuments(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
const hideOnlineDocumentPreview = useCallback(() => {
setCurrentDocuments(undefined)
}, [])
return {
notionPages,
previewNotionPage,
updateNotionPages,
currentNotionPage,
onlineDocuments,
previewOnlineDocument,
updateOnlineDocuments,
currentDocuments,
updateCurrentPage,
hideNotionPagePreview,
hideOnlineDocumentPreview,
}
}

View File

@ -5,7 +5,7 @@ import type { CrawlResultItem, DocumentItem, CustomFile as File, FileIndexingEst
import LocalFile from '@/app/components/rag-pipeline/components/panel/test-run/data-source/local-file'
import { useProviderContextSelector } from '@/context/provider-context'
import type { NotionPage } from '@/models/common'
import Notion from '@/app/components/rag-pipeline/components/panel/test-run/data-source/notion'
import OnlineDocuments from '@/app/components/rag-pipeline/components/panel/test-run/data-source/online-documents'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import WebsiteCrawl from '@/app/components/rag-pipeline/components/panel/test-run/data-source/website-crawl'
import Actions from './data-source/actions'
@ -26,7 +26,7 @@ import Processing from './processing'
import type { InitialDocumentDetail, PublishedPipelineRunPreviewResponse, PublishedPipelineRunResponse } from '@/models/pipeline'
import { DatasourceType } from '@/models/pipeline'
import { TransferMethod } from '@/types/app'
import { useAddDocumentsSteps, useLocalFile, useNotionsPages, useWebsiteCrawl } from './hooks'
import { useAddDocumentsSteps, useLocalFile, useOnlineDocuments, useWebsiteCrawl } from './hooks'
const CreateFormPipeline = () => {
const { t } = useTranslation()
@ -63,13 +63,13 @@ const CreateFormPipeline = () => {
hideFilePreview,
} = useLocalFile()
const {
notionPages,
previewNotionPage,
updateNotionPages,
currentNotionPage,
onlineDocuments,
previewOnlineDocument,
updateOnlineDocuments,
currentDocuments,
updateCurrentPage,
hideNotionPagePreview,
} = useNotionsPages()
hideOnlineDocumentPreview,
} = useOnlineDocuments()
const {
websitePages,
websiteCrawlJobId,
@ -90,11 +90,11 @@ const CreateFormPipeline = () => {
if (datasource.type === DatasourceType.localFile)
return isShowVectorSpaceFull || !fileList.length || fileList.some(file => !file.file.id)
if (datasource.type === DatasourceType.onlineDocument)
return isShowVectorSpaceFull || !notionPages.length
return isShowVectorSpaceFull || !onlineDocuments.length
if (datasource.type === DatasourceType.websiteCrawl)
return isShowVectorSpaceFull || !websitePages.length
return false
}, [datasource, isShowVectorSpaceFull, fileList, notionPages.length, websitePages.length])
}, [datasource, isShowVectorSpaceFull, fileList, onlineDocuments.length, websitePages.length])
const { mutateAsync: runPublishedPipeline, isIdle, isPending } = useRunPublishedPipeline()
@ -117,7 +117,7 @@ const CreateFormPipeline = () => {
datasourceInfoList.push(documentInfo)
}
if (datasource.type === DatasourceType.onlineDocument) {
const { workspace_id, ...rest } = previewNotionPage.current
const { workspace_id, ...rest } = previewOnlineDocument.current
const documentInfo = {
workspace_id,
page: rest,
@ -143,7 +143,7 @@ const CreateFormPipeline = () => {
setEstimateData((res as PublishedPipelineRunPreviewResponse).data.outputs)
},
})
}, [datasource, pipelineId, previewFile, previewNotionPage, previewWebsitePage, runPublishedPipeline, websiteCrawlJobId])
}, [datasource, pipelineId, previewFile, previewOnlineDocument, previewWebsitePage, runPublishedPipeline, websiteCrawlJobId])
const handleProcess = useCallback(async (data: Record<string, any>) => {
if (!datasource)
@ -166,7 +166,7 @@ const CreateFormPipeline = () => {
})
}
if (datasource.type === DatasourceType.onlineDocument) {
notionPages.forEach((page) => {
onlineDocuments.forEach((page) => {
const { workspace_id, ...rest } = page
const documentInfo = {
workspace_id,
@ -196,7 +196,7 @@ const CreateFormPipeline = () => {
handleNextStep()
},
})
}, [datasource, fileList, handleNextStep, notionPages, pipelineId, runPublishedPipeline, websiteCrawlJobId, websitePages])
}, [datasource, fileList, handleNextStep, onlineDocuments, pipelineId, runPublishedPipeline, websiteCrawlJobId, websitePages])
const onClickProcess = useCallback(() => {
isPreview.current = false
@ -217,10 +217,10 @@ const CreateFormPipeline = () => {
onClickPreview()
}, [onClickPreview, previewFile])
const handlePreviewNotionPageChange = useCallback((page: NotionPage) => {
previewNotionPage.current = page
const handlePreviewOnlineDocumentChange = useCallback((page: NotionPage) => {
previewOnlineDocument.current = page
onClickPreview()
}, [onClickPreview, previewNotionPage])
}, [onClickPreview, previewOnlineDocument])
const handlePreviewWebsiteChange = useCallback((website: CrawlResultItem) => {
previewWebsitePage.current = website
@ -263,10 +263,15 @@ const CreateFormPipeline = () => {
/>
)}
{datasource?.type === DatasourceType.onlineDocument && (
<Notion
<OnlineDocuments
nodeId={datasource?.nodeId || ''}
notionPages={notionPages}
updateNotionPages={updateNotionPages}
headerInfo={{
title: datasource.description,
docTitle: datasource.docTitle || '',
docLink: datasource.docLink || '',
}}
onlineDocuments={onlineDocuments}
updateOnlineDocuments={updateOnlineDocuments}
canPreview
onPreview={updateCurrentPage}
/>
@ -283,7 +288,6 @@ const CreateFormPipeline = () => {
onCheckedCrawlResultChange={setWebsitePages}
onJobIdChange={setWebsiteCrawlJobId}
onPreview={updateCurrentWebsite}
usingPublished
/>
)}
{isShowVectorSpaceFull && (
@ -323,7 +327,7 @@ const CreateFormPipeline = () => {
currentStep === 1 && (
<div className='flex h-full w-[752px] shrink-0 pl-2 pt-2'>
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && <NotionPagePreview currentPage={currentNotionPage} hidePreview={hideNotionPagePreview} />}
{currentDocuments && <NotionPagePreview currentPage={currentDocuments} hidePreview={hideOnlineDocumentPreview} />}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
</div>
)
@ -335,14 +339,14 @@ const CreateFormPipeline = () => {
<ChunkPreview
datasource={datasource!}
files={fileList.map(file => file.file)}
notionPages={notionPages}
onlineDocuments={onlineDocuments}
websitePages={websitePages}
isIdle={isIdle && isPreview.current}
isPending={isPending && isPreview.current}
estimateData={estimateData}
onPreview={onClickPreview}
handlePreviewFileChange={handlePreviewFileChange}
handlePreviewNotionPageChange={handlePreviewNotionPageChange}
handlePreviewOnlineDocumentChange={handlePreviewOnlineDocumentChange}
handlePreviewWebsitePageChange={handlePreviewWebsiteChange}
/>
)}

View File

@ -20,35 +20,35 @@ import { DatasourceType } from '@/models/pipeline'
type ChunkPreviewProps = {
datasource: Datasource
files: CustomFile[]
notionPages: NotionPage[]
onlineDocuments: NotionPage[]
websitePages: CrawlResultItem[]
isIdle: boolean
isPending: boolean
estimateData: FileIndexingEstimateResponse | undefined
onPreview: () => void
handlePreviewFileChange: (file: DocumentItem) => void
handlePreviewNotionPageChange: (page: NotionPage) => void
handlePreviewOnlineDocumentChange: (page: NotionPage) => void
handlePreviewWebsitePageChange: (page: CrawlResultItem) => void
}
const ChunkPreview = ({
datasource,
files,
notionPages,
onlineDocuments,
websitePages,
isIdle,
isPending,
estimateData,
onPreview,
handlePreviewFileChange,
handlePreviewNotionPageChange,
handlePreviewOnlineDocumentChange,
handlePreviewWebsitePageChange,
}: ChunkPreviewProps) => {
const { t } = useTranslation()
const currentDocForm = useDatasetDetailContextWithSelector(s => s.dataset?.doc_form)
const [previewFile, setPreviewFile] = useState<DocumentItem>(files[0] as DocumentItem)
const [previewNotionPage, setPreviewNotionPage] = useState<NotionPage>(notionPages[0])
const [previewOnlineDocument, setPreviewOnlineDocument] = useState<NotionPage>(onlineDocuments[0])
const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>(websitePages[0])
const dataSourceType = datasource?.type
@ -72,20 +72,20 @@ const ChunkPreview = ({
{dataSourceType === DatasourceType.onlineDocument
&& <PreviewDocumentPicker
files={
notionPages.map(page => ({
onlineDocuments.map(page => ({
id: page.page_id,
name: page.page_name,
extension: 'md',
}))
}
onChange={(selected) => {
const selectedPage = notionPages.find(page => page.page_id === selected.id)
setPreviewNotionPage(selectedPage!)
handlePreviewNotionPageChange(selectedPage!)
const selectedPage = onlineDocuments.find(page => page.page_id === selected.id)
setPreviewOnlineDocument(selectedPage!)
handlePreviewOnlineDocumentChange(selectedPage!)
}}
value={{
id: previewNotionPage?.page_id || '',
name: previewNotionPage?.page_name || '',
id: previewOnlineDocument?.page_id || '',
name: previewOnlineDocument?.page_name || '',
extension: 'md',
}}
/>

View File

@ -2223,7 +2223,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
- <code>document_id</code> (string) 文档 ID
- <code>metadata_list</code> (list) 元数据列表
- <code>id</code> (string) 元数据 ID
- <code>type</code> (string) 元数据类型
- <code>value</code> (string) 元数据值
- <code>name</code> (string) 元数据名称
</Property>
</Properties>

View File

@ -1,11 +1,10 @@
import { useState } from 'react'
import { useContext } from 'use-context-selector'
import I18n from '@/context/i18n'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { NOTICE_I18N } from '@/i18n/language'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
const MaintenanceNotice = () => {
const { locale } = useContext(I18n)
const locale = useLanguage()
const [showNotice, setShowNotice] = useState(localStorage.getItem('hide-maintenance-notice') !== '1')
const handleJumpNotice = () => {

View File

@ -1,33 +0,0 @@
import type { NotionPage } from '@/models/common'
import NotionPageSelector from './notion-page-selector'
type NotionProps = {
nodeId: string
notionPages: NotionPage[]
updateNotionPages: (value: NotionPage[]) => void
canPreview?: boolean
onPreview?: (selectedPage: NotionPage) => void
isInPipeline?: boolean
}
const Notion = ({
nodeId,
notionPages,
updateNotionPages,
canPreview = false,
onPreview,
isInPipeline = false,
}: NotionProps) => {
return (
<NotionPageSelector
nodeId={nodeId}
value={notionPages.map(page => page.page_id)}
onSelect={updateNotionPages}
canPreview={canPreview}
onPreview={onPreview}
isInPipeline={isInPipeline}
/>
)
}
export default Notion

View File

@ -0,0 +1,40 @@
import type { NotionPage } from '@/models/common'
import OnlineDocumentSelector from './online-document-selector'
type OnlineDocumentsProps = {
nodeId: string
headerInfo: {
title: string
docTitle: string
docLink: string
}
onlineDocuments: NotionPage[]
updateOnlineDocuments: (value: NotionPage[]) => void
canPreview?: boolean
onPreview?: (selectedPage: NotionPage) => void
isInPipeline?: boolean
}
const OnlineDocuments = ({
nodeId,
headerInfo,
onlineDocuments,
updateOnlineDocuments,
canPreview = false,
onPreview,
isInPipeline = false,
}: OnlineDocumentsProps) => {
return (
<OnlineDocumentSelector
nodeId={nodeId}
headerInfo={headerInfo}
value={onlineDocuments.map(page => page.page_id)}
onSelect={updateOnlineDocuments}
canPreview={canPreview}
onPreview={onPreview}
isInPipeline={isInPipeline}
/>
)
}
export default OnlineDocuments

View File

@ -1,14 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import WorkspaceSelector from '@/app/components/base/notion-page-selector/workspace-selector'
import SearchInput from '@/app/components/base/notion-page-selector/search-input'
import PageSelector from '@/app/components/base/notion-page-selector/page-selector'
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import Header from '@/app/components/datasets/create/website/base/header'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasourceNodeRun } from '@/service/use-pipeline'
import { useTranslation } from 'react-i18next'
import { useDraftDatasourceNodeRun, usePublishedDatasourceNodeRun } from '@/service/use-pipeline'
import { DatasourceType } from '@/models/pipeline'
type NotionPageSelectorProps = {
type OnlineDocumentSelectorProps = {
value?: string[]
onSelect: (selectedPages: NotionPage[]) => void
canPreview?: boolean
@ -16,9 +16,14 @@ type NotionPageSelectorProps = {
onPreview?: (selectedPage: NotionPage) => void
isInPipeline?: boolean
nodeId: string
headerInfo: {
title: string
docTitle: string
docLink: string
}
}
const NotionPageSelector = ({
const OnlineDocumentSelector = ({
value,
onSelect,
canPreview,
@ -26,40 +31,43 @@ const NotionPageSelector = ({
onPreview,
isInPipeline = false,
nodeId,
}: NotionPageSelectorProps) => {
const { t } = useTranslation()
headerInfo,
}: OnlineDocumentSelectorProps) => {
const pipeline_id = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
const { mutateAsync: getNotionPages } = useDatasourceNodeRun()
const [notionData, setNotionData] = useState<DataSourceNotionWorkspace[]>([])
const [documentsData, setDocumentsData] = useState<DataSourceNotionWorkspace[]>([])
const [searchValue, setSearchValue] = useState('')
const [currentWorkspaceId, setCurrentWorkspaceId] = useState('')
const getNotionData = useCallback(async () => {
const useDatasourceNodeRun = useRef(!isInPipeline ? usePublishedDatasourceNodeRun : useDraftDatasourceNodeRun)
const { mutateAsync: crawlOnlineDocuments } = useDatasourceNodeRun.current()
const getOnlineDocuments = useCallback(async () => {
if (pipeline_id) {
await getNotionPages({
await crawlOnlineDocuments({
pipeline_id,
node_id: nodeId,
inputs: {},
datasource_type: DatasourceType.onlineDocument,
}, {
onSuccess(notionData) {
setNotionData(notionData as DataSourceNotionWorkspace[])
onSuccess(documentsData) {
setDocumentsData(documentsData as DataSourceNotionWorkspace[])
},
})
}
}, [getNotionPages, nodeId, pipeline_id])
}, [crawlOnlineDocuments, nodeId, pipeline_id])
useEffect(() => {
getNotionData()
getOnlineDocuments()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const firstWorkspaceId = notionData[0]?.workspace_id
const currentWorkspace = notionData.find(workspace => workspace.workspace_id === currentWorkspaceId)
const firstWorkspaceId = documentsData[0]?.workspace_id
const currentWorkspace = documentsData.find(workspace => workspace.workspace_id === currentWorkspaceId)
const PagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set<string>, Set<string>] = useMemo(() => {
const selectedPagesId = new Set<string>()
const boundPagesId = new Set<string>()
const pagesMap = notionData.reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => {
const pagesMap = documentsData.reduce((prev: DataSourceNotionPageMap, next: DataSourceNotionWorkspace) => {
next.pages.forEach((page) => {
if (page.is_bound) {
selectedPagesId.add(page.page_id)
@ -74,7 +82,7 @@ const NotionPageSelector = ({
return prev
}, {})
return [pagesMap, selectedPagesId, boundPagesId]
}, [notionData])
}, [documentsData])
const defaultSelectedPagesId = [...Array.from(PagesMapAndSelectedPagesId[1]), ...(value || [])]
const [selectedPagesId, setSelectedPagesId] = useState<Set<string>>(new Set(defaultSelectedPagesId))
@ -99,23 +107,21 @@ const NotionPageSelector = ({
setCurrentWorkspaceId(firstWorkspaceId)
}, [firstWorkspaceId])
if (!notionData?.length)
if (!documentsData?.length)
return null
return (
<div className='flex flex-col gap-y-2'>
<Header
isInPipeline={isInPipeline}
title={t('datasetPipeline.testRun.notion.title')}
docTitle={t('datasetPipeline.testRun.notion.docTitle')}
docLink={'https://www.notion.so/docs'}
{...headerInfo}
/>
<div className='rounded-xl border border-components-panel-border bg-background-default-subtle'>
<div className='flex h-12 items-center gap-x-2 rounded-t-xl border-b border-b-divider-regular bg-components-panel-bg p-2'>
<div className='flex grow items-center gap-x-1'>
<WorkspaceSelector
value={currentWorkspaceId || firstWorkspaceId}
items={notionData}
items={documentsData}
onSelect={handleSelectWorkspace}
/>
</div>
@ -142,4 +148,4 @@ const NotionPageSelector = ({
)
}
export default NotionPageSelector
export default OnlineDocumentSelector

View File

@ -8,11 +8,13 @@ import Crawling from './crawling'
import ErrorMessage from './error-message'
import CrawledResult from './crawled-result'
import {
useDatasourceNodeRun,
useDraftDatasourceNodeRun,
useDraftPipelinePreProcessingParams,
usePublishedDatasourceNodeRun,
usePublishedPipelineProcessingParams,
} from '@/service/use-pipeline'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DatasourceType } from '@/models/pipeline'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
@ -27,7 +29,7 @@ type CrawlerProps = {
docLink: string
}
onPreview?: (payload: CrawlResultItem) => void
usingPublished?: boolean
isInPipeline?: boolean
}
enum Step {
@ -43,14 +45,14 @@ const Crawler = ({
onCheckedCrawlResultChange,
onJobIdChange,
onPreview,
usingPublished = false,
isInPipeline = false,
}: CrawlerProps) => {
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
const pipelineId = useDatasetDetailContextWithSelector(s => s.dataset?.pipeline_id)
const usePreProcessingParams = useRef(usingPublished ? usePublishedPipelineProcessingParams : useDraftPipelinePreProcessingParams)
const usePreProcessingParams = useRef(!isInPipeline ? usePublishedPipelineProcessingParams : useDraftPipelinePreProcessingParams)
const { data: paramsConfig } = usePreProcessingParams.current({
pipeline_id: pipelineId!,
node_id: nodeId,
@ -71,7 +73,8 @@ const Crawler = ({
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
const showError = isCrawlFinished && crawlErrorMessage
const { mutateAsync: runDatasourceNode } = useDatasourceNodeRun()
const useDatasourceNodeRun = useRef(!isInPipeline ? usePublishedDatasourceNodeRun : useDraftDatasourceNodeRun)
const { mutateAsync: runDatasourceNode } = useDatasourceNodeRun.current()
const handleRun = useCallback(async (value: Record<string, any>) => {
setStep(Step.running)
@ -79,6 +82,7 @@ const Crawler = ({
node_id: nodeId,
pipeline_id: pipelineId!,
inputs: value,
datasource_type: DatasourceType.websiteCrawl,
}, {
onSuccess: (res: any) => {
const jobId = res.job_id

View File

@ -14,7 +14,7 @@ type WebsiteCrawlProps = {
docLink: string
}
onPreview?: (payload: CrawlResultItem) => void
usingPublished?: boolean
isInPipeline?: boolean
}
const WebsiteCrawl = ({
@ -24,7 +24,7 @@ const WebsiteCrawl = ({
onCheckedCrawlResultChange,
onJobIdChange,
onPreview,
usingPublished,
isInPipeline,
}: WebsiteCrawlProps) => {
return (
<Crawler
@ -34,7 +34,7 @@ const WebsiteCrawl = ({
onCheckedCrawlResultChange={onCheckedCrawlResultChange}
onJobIdChange={onJobIdChange}
onPreview={onPreview}
usingPublished={usingPublished}
isInPipeline={isInPipeline}
/>
)
}

View File

@ -49,8 +49,8 @@ export const useDatasourceOptions = () => {
return {
nodeId: node.id,
type: node.data.provider_type as DatasourceType,
description: '', // todo: Add description
docTitle: '', // todo: Add docTitle and docLink
description: node.data.datasource_label,
docTitle: 'How to use?',
docLink: '',
fileExtensions: node.data.fileExtensions || [],
}
@ -101,16 +101,16 @@ export const useLocalFile = () => {
}
}
export const useNotionPages = () => {
const [notionPages, setNotionPages] = useState<NotionPage[]>([])
export const useOnlineDocuments = () => {
const [onlineDocuments, setOnlineDocuments] = useState<NotionPage[]>([])
const updateNotionPages = (value: NotionPage[]) => {
setNotionPages(value)
const updateOnlineDocuments = (value: NotionPage[]) => {
setOnlineDocuments(value)
}
return {
notionPages,
updateNotionPages,
onlineDocuments,
updateOnlineDocuments,
}
}

View File

@ -1,10 +1,10 @@
import { useStore as useWorkflowStoreWithSelector } from '@/app/components/workflow/store'
import { useCallback, useMemo, useState } from 'react'
import { useLocalFile, useNotionPages, useTestRunSteps, useWebsiteCrawl } from './hooks'
import { useLocalFile, useOnlineDocuments, useTestRunSteps, useWebsiteCrawl } from './hooks'
import DataSourceOptions from './data-source-options'
import LocalFile from './data-source/local-file'
import { useProviderContextSelector } from '@/context/provider-context'
import Notion from './data-source/notion'
import OnlineDocuments from './data-source/online-documents'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import WebsiteCrawl from './data-source/website-crawl'
import Actions from './data-source/actions'
@ -35,9 +35,9 @@ const TestRunPanel = () => {
updateFileList,
} = useLocalFile()
const {
notionPages,
updateNotionPages,
} = useNotionPages()
onlineDocuments,
updateOnlineDocuments,
} = useOnlineDocuments()
const {
websitePages,
websiteCrawlJobId,
@ -54,11 +54,11 @@ const TestRunPanel = () => {
if (datasource.type === DatasourceType.localFile)
return isShowVectorSpaceFull || !fileList.length || fileList.some(file => !file.file.id)
if (datasource.type === DatasourceType.onlineDocument)
return isShowVectorSpaceFull || !notionPages.length
return isShowVectorSpaceFull || !onlineDocuments.length
if (datasource.type === DatasourceType.websiteCrawl)
return isShowVectorSpaceFull || !websitePages.length
return false
}, [datasource, isShowVectorSpaceFull, fileList, notionPages.length, websitePages.length])
}, [datasource, isShowVectorSpaceFull, fileList, onlineDocuments.length, websitePages.length])
const handleClose = () => {
setShowDebugAndPreviewPanel(false)
@ -83,7 +83,7 @@ const TestRunPanel = () => {
datasourceInfoList.push(documentInfo)
}
if (datasource.type === DatasourceType.onlineDocument) {
const { workspace_id, ...rest } = notionPages[0]
const { workspace_id, ...rest } = onlineDocuments[0]
const documentInfo = {
workspace_id,
page: rest,
@ -103,7 +103,7 @@ const TestRunPanel = () => {
datasource_type: datasource.type,
datasource_info_list: datasourceInfoList,
})
}, [datasource, fileList, handleRun, notionPages, websiteCrawlJobId, websitePages])
}, [datasource, fileList, handleRun, onlineDocuments, websiteCrawlJobId, websitePages])
return (
<div
@ -130,10 +130,16 @@ const TestRunPanel = () => {
/>
)}
{datasource?.type === DatasourceType.onlineDocument && (
<Notion
<OnlineDocuments
nodeId={datasource?.nodeId || ''}
notionPages={notionPages}
updateNotionPages={updateNotionPages}
headerInfo={{
title: datasource.description,
docTitle: datasource.docTitle || '',
docLink: datasource.docLink || '',
}}
onlineDocuments={onlineDocuments}
updateOnlineDocuments={updateOnlineDocuments}
isInPipeline
/>
)}
{datasource?.type === DatasourceType.websiteCrawl && (
@ -147,6 +153,7 @@ const TestRunPanel = () => {
}}
onCheckedCrawlResultChange={setWebsitePages}
onJobIdChange={setWebsiteCrawlJobId}
isInPipeline
/>
)}
{isShowVectorSpaceFull && (

View File

@ -6,9 +6,8 @@ import type { Placement } from '@floating-ui/react'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import { useRouter } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import Divider from '../../base/divider'
import { removeAccessToken } from '../utils'
import InfoModal from './info-modal'
import ActionButton from '@/app/components/base/action-button'
import {
@ -19,6 +18,8 @@ import {
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import type { SiteInfo } from '@/models/share'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode } from '@/models/access-control'
type Props = {
data?: SiteInfo
@ -31,7 +32,9 @@ const MenuDropdown: FC<Props> = ({
placement,
hideLogout,
}) => {
const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode)
const router = useRouter()
const pathname = usePathname()
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
@ -45,9 +48,10 @@ const MenuDropdown: FC<Props> = ({
}, [setOpen])
const handleLogout = useCallback(() => {
removeAccessToken()
router.replace(`/webapp-signin?redirect_url=${window.location.href}`)
}, [router])
localStorage.removeItem('token')
localStorage.removeItem('webapp_access_token')
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}, [router, pathname])
const [show, setShow] = useState(false)
@ -92,6 +96,16 @@ const MenuDropdown: FC<Props> = ({
className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
>{t('common.userProfile.about')}</div>
</div>
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
<div className='p-1'>
<div
onClick={handleLogout}
className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
>
{t('common.userProfile.logout')}
</div>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>

View File

@ -10,8 +10,8 @@ export const getInitialTokenV2 = (): Record<string, any> => ({
version: 2,
})
export const checkOrSetAccessToken = async () => {
const sharedToken = globalThis.location.pathname.split('/').slice(-1)[0]
export const checkOrSetAccessToken = async (appCode?: string) => {
const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0]
const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id
const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2())
let accessTokenJson = getInitialTokenV2()
@ -23,8 +23,10 @@ export const checkOrSetAccessToken = async () => {
catch {
}
if (!accessTokenJson[sharedToken]?.[userId || 'DEFAULT']) {
const res = await fetchAccessToken(sharedToken, userId)
const webAppAccessToken = localStorage.getItem('webapp_access_token')
const res = await fetchAccessToken({ appCode: sharedToken, userId, webAppAccessToken })
accessTokenJson[sharedToken] = {
...accessTokenJson[sharedToken],
[userId || 'DEFAULT']: res.access_token,
@ -33,7 +35,7 @@ export const checkOrSetAccessToken = async () => {
}
}
export const setAccessToken = async (sharedToken: string, token: string, user_id?: string) => {
export const setAccessToken = (sharedToken: string, token: string, user_id?: string) => {
const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2())
let accessTokenJson = getInitialTokenV2()
try {
@ -69,6 +71,7 @@ export const removeAccessToken = () => {
}
localStorage.removeItem(CONVERSATION_ID_INFO)
localStorage.removeItem('webapp_access_token')
delete accessTokenJson[sharedToken]
localStorage.setItem('token', JSON.stringify(accessTokenJson))

View File

@ -53,7 +53,7 @@ export const isConversationVar = (valueSelector: ValueSelector) => {
}
export const isRagVariableVar = (valueSelector: ValueSelector) => {
if(!valueSelector)
if (!valueSelector)
return false
return valueSelector[0] === 'rag'
}
@ -152,7 +152,7 @@ const findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: Val
if (isStructuredOutput) {
childrenResult = findExceptVarInStructuredOutput(children, filterVar)
}
else if (Array.isArray(children)) {
else if (Array.isArray(children)) {
childrenResult = children.filter((item: Var) => {
const { children: itemChildren } = item
const currSelector = [...value_selector, item.variable]
@ -164,7 +164,7 @@ const findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: Val
return filteredObj.children && (filteredObj.children as Var[])?.length > 0
})
}
else {
else {
childrenResult = []
}
@ -638,7 +638,6 @@ export const toNodeOutputVars = (
let ragVariablesInDataSource: RAGPipelineVariable[] = []
if (node.data.type === BlockEnum.DataSource)
ragVariablesInDataSource = ragVariables.filter(ragVariable => ragVariable.belong_to_node_id === node.id)
console.log(ragVariables, ragVariablesInDataSource, node.id)
return {
...formatItem(node, isChatMode, filterVar, ragVariablesInDataSource.map(
(ragVariable: RAGPipelineVariable) => ({
@ -682,9 +681,9 @@ const getIterationItemType = ({
curr = Array.isArray(curr) ? curr.find(v => v.variable === key) : []
if (isLast)
arrayType = curr?.type
arrayType = curr?.type
else if (curr?.type === VarType.object || curr?.type === VarType.file)
curr = curr.children || []
curr = curr.children || []
}
}
@ -831,12 +830,20 @@ export const getVarType = ({
const isSystem = isSystemVar(valueSelector)
const isEnv = isENV(valueSelector)
const isChatVar = isConversationVar(valueSelector)
const isRagVariable = isRagVariableVar(valueSelector)
const isSharedRagVariable = isRagVariableVar(valueSelector) && valueSelector[1] === 'shared'
const isInNodeRagVariable = isRagVariableVar(valueSelector) && valueSelector[1] !== 'shared'
const startNode = availableNodes.find((node: any) => {
return node?.data.type === BlockEnum.Start
})
const targetVarNodeId = isSystem ? startNode?.id : valueSelector[0]
const targetVarNodeId = (() => {
if(isSystem)
return startNode?.id
if(isInNodeRagVariable)
return valueSelector[1]
return valueSelector[0]
})()
const targetVar = beforeNodesOutputVars.find(v => v.nodeId === targetVarNodeId)
if (!targetVar)
@ -845,14 +852,21 @@ export const getVarType = ({
let type: VarType = VarType.string
let curr: any = targetVar.vars
if (isSystem || isEnv || isChatVar || isRagVariable) {
if (isSystem || isEnv || isChatVar || isSharedRagVariable) {
return curr.find((v: any) => v.variable === (valueSelector as ValueSelector).join('.'))?.type
}
else {
const targetVar = curr.find((v: any) => v.variable === valueSelector[1])
const targetVar = curr.find((v: any) => {
if(isInNodeRagVariable)
return v.variable === valueSelector.join('.')
return v.variable === valueSelector[1]
})
if (!targetVar)
return VarType.string
if(isInNodeRagVariable)
return targetVar.type
const isStructuredOutputVar = !!targetVar.children?.schema?.properties
if (isStructuredOutputVar) {
if (valueSelector.length === 2) { // root

View File

@ -1,8 +1,8 @@
'use client'
import type { FC } from 'react'
import classNames from '@/utils/classnames'
import { useSelector } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useTheme } from 'next-themes'
type LoginLogoProps = {
className?: string
@ -12,11 +12,7 @@ const LoginLogo: FC<LoginLogoProps> = ({
className,
}) => {
const { systemFeatures } = useGlobalPublicStore()
const { theme } = useSelector((s) => {
return {
theme: s.theme,
}
})
const { theme } = useTheme()
let src = theme === 'light' ? '/logo/logo-site.png' : `/logo/logo-site-${theme}.png`
if (systemFeatures.branding.enabled)

View File

@ -7,19 +7,24 @@ import type { SystemFeatures } from '@/types/feature'
import { defaultSystemFeatures } from '@/types/feature'
import { getSystemFeatures } from '@/service/common'
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
type GlobalPublicStore = {
isPending: boolean
setIsPending: (isPending: boolean) => void
isGlobalPending: boolean
setIsGlobalPending: (isPending: boolean) => void
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
webAppAccessMode: AccessMode,
setWebAppAccessMode: (webAppAccessMode: AccessMode) => void
}
export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
isPending: true,
setIsPending: (isPending: boolean) => set(() => ({ isPending })),
isGlobalPending: true,
setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })),
systemFeatures: defaultSystemFeatures,
setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
webAppAccessMode: AccessMode.PUBLIC,
setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })),
}))
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
@ -29,7 +34,7 @@ const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
queryKey: ['systemFeatures'],
queryFn: getSystemFeatures,
})
const { setSystemFeatures, setIsPending } = useGlobalPublicStore()
const { setSystemFeatures, setIsGlobalPending: setIsPending } = useGlobalPublicStore()
useEffect(() => {
if (data)
setSystemFeatures({ ...defaultSystemFeatures, ...data })

View File

@ -11,7 +11,7 @@ describe('title should be empty if systemFeatures is pending', () => {
act(() => {
useGlobalPublicStore.setState({
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
isPending: true,
isGlobalPending: true,
})
})
it('document title should be empty if set title', () => {
@ -28,7 +28,7 @@ describe('use default branding', () => {
beforeEach(() => {
act(() => {
useGlobalPublicStore.setState({
isPending: false,
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
})
})
@ -48,7 +48,7 @@ describe('use specific branding', () => {
beforeEach(() => {
act(() => {
useGlobalPublicStore.setState({
isPending: false,
isGlobalPending: false,
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } },
})
})

View File

@ -3,7 +3,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFavicon, useTitle } from 'ahooks'
export default function useDocumentTitle(title: string) {
const isPending = useGlobalPublicStore(s => s.isPending)
const isPending = useGlobalPublicStore(s => s.isGlobalPending)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const prefix = title ? `${title} - ` : ''
let titleStr = ''

View File

@ -220,12 +220,14 @@ const translation = {
anyone: 'Jeder kann auf die Webanwendung zugreifen.',
specific: 'Nur bestimmte Gruppen oder Mitglieder können auf die Webanwendung zugreifen.',
organization: 'Jeder in der Organisation kann auf die Webanwendung zugreifen.',
external: 'Nur authentifizierte externe Benutzer können auf die Webanwendung zugreifen.',
},
accessControlDialog: {
accessItems: {
anyone: 'Jeder mit dem Link',
specific: 'Spezifische Gruppen oder Mitglieder',
organization: 'Nur Mitglieder innerhalb des Unternehmens',
external: 'Authentifizierte externe Benutzer',
},
operateGroupAndMember: {
searchPlaceholder: 'Gruppen und Mitglieder suchen',

View File

@ -77,6 +77,9 @@ const translation = {
executions: '{{num}} HINRICHTUNGEN',
execution: 'AUSFÜHRUNG',
},
login: {
backToHome: 'Zurück zur Startseite',
},
}
export default translation

View File

@ -198,9 +198,10 @@ const translation = {
},
accessControl: 'Web App Access Control',
accessItemsDescription: {
anyone: 'Anyone can access the web app',
specific: 'Only specific groups or members can access the web app',
organization: 'Anyone in the organization can access the web app',
anyone: 'Anyone can access the web app (no login required)',
specific: 'Only specific members within the platform can access the Web application',
organization: 'All members within the platform can access the Web application',
external: 'Only authenticated external users can access the Web application',
},
accessControlDialog: {
title: 'Web App Access Control',
@ -208,15 +209,16 @@ const translation = {
accessLabel: 'Who has access',
accessItems: {
anyone: 'Anyone with the link',
specific: 'Specific groups or members',
organization: 'Only members within the enterprise',
specific: 'Specific members within the platform',
organization: 'All members within the platform',
external: 'Authenticated external users',
},
groups_one: '{{count}} GROUP',
groups_other: '{{count}} GROUPS',
members_one: '{{count}} MEMBER',
members_other: '{{count}} MEMBERS',
noGroupsOrMembers: 'No groups or members selected',
webAppSSONotEnabledTip: 'Please contact enterprise administrator to configure the web app authentication method.',
webAppSSONotEnabledTip: 'Please contact your organization administrator to configure external authentication for the Web application.',
operateGroupAndMember: {
searchPlaceholder: 'Search groups and members',
allMembers: 'All members',

View File

@ -77,6 +77,9 @@ const translation = {
atLeastOne: 'Please input at least one row in the uploaded file.',
},
},
login: {
backToHome: 'Back to Home',
},
}
export default translation

View File

@ -212,12 +212,14 @@ const translation = {
anyone: 'Cualquiera puede acceder a la aplicación web.',
specific: 'Solo grupos o miembros específicos pueden acceder a la aplicación web',
organization: 'Cualquiera en la organización puede acceder a la aplicación web',
external: 'Solo los usuarios externos autenticados pueden acceder a la aplicación web.',
},
accessControlDialog: {
accessItems: {
anyone: 'Cualquiera con el enlace',
specific: 'Grupos o miembros específicos',
organization: 'Solo miembros dentro de la empresa',
external: 'Usuarios externos autenticados',
},
operateGroupAndMember: {
searchPlaceholder: 'Buscar grupos y miembros',

View File

@ -654,6 +654,7 @@ const translation = {
auto: 'sistema',
light: 'luz',
theme: 'Tema',
dark: 'noche',
},
compliance: {
iso27001: 'Certificación ISO 27001:2022',

View File

@ -77,6 +77,9 @@ const translation = {
execution: 'EJECUCIÓN',
executions: '{{num}} EJECUCIONES',
},
login: {
backToHome: 'Volver a Inicio',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
specific: 'فقط گروه‌ها یا اعضای خاصی می‌توانند به اپلیکیشن وب دسترسی پیدا کنند.',
anyone: 'هر کسی می‌تواند به وب‌اپلیکیشن دسترسی پیدا کند',
organization: 'هر کسی در سازمان می‌تواند به اپلیکیشن وب دسترسی پیدا کند.',
external: 'تنها کاربران خارجی تأیید شده می‌توانند به برنامه وب دسترسی پیدا کنند.',
},
accessControlDialog: {
accessItems: {
specific: 'گروه‌ها یا اعضای خاص',
organization: 'فقط اعضای داخل سازمان',
anyone: 'هر کسی که لینک را داشته باشد',
external: 'کاربران خارجی تأیید شده',
},
operateGroupAndMember: {
searchPlaceholder: 'گروه‌ها و اعضا را جستجو کنید',

View File

@ -73,6 +73,9 @@ const translation = {
executions: '{{num}} اعدام',
execution: 'اجرا',
},
login: {
backToHome: 'بازگشت به خانه',
},
}
export default translation

View File

@ -207,17 +207,20 @@ const translation = {
modelNotSupported: 'Modèle non pris en charge',
moreFillTip: 'Affichage d\'un maximum de 10 niveaux d\'imbrication',
configure: 'Configurer',
structured: 'systématique',
},
accessItemsDescription: {
anyone: 'Tout le monde peut accéder à l\'application web.',
specific: 'Seules des groupes ou membres spécifiques peuvent accéder à l\'application web.',
organization: 'Toute personne dans l\'organisation peut accéder à l\'application web.',
external: 'Seuls les utilisateurs externes authentifiés peuvent accéder à l\'application Web.',
},
accessControlDialog: {
accessItems: {
anyone: 'Quiconque avec le lien',
specific: 'Groupes ou membres spécifiques',
organization: 'Seuls les membres au sein de l\'entreprise',
external: 'Utilisateurs externes authentifiés',
},
operateGroupAndMember: {
searchPlaceholder: 'Rechercher des groupes et des membres',

View File

@ -77,6 +77,9 @@ const translation = {
executions: '{{num}} EXÉCUTIONS',
execution: 'EXÉCUTION',
},
login: {
backToHome: 'Retour à l\'accueil',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
anyone: 'कोई भी वेब ऐप तक पहुँच सकता है',
organization: 'संस्थान के किसी भी व्यक्ति को वेब ऐप तक पहुंच प्राप्त है',
specific: 'केवल विशेष समूह या सदस्य ही वेब ऐप तक पहुंच सकते हैं',
external: 'केवल प्रमाणित बाहरी उपयोगकर्ता वेब अनुप्रयोग तक पहुँच सकते हैं',
},
accessControlDialog: {
accessItems: {
anyone: 'लिंक के साथ कोई भी',
specific: 'विशिष्ट समूह या सदस्य',
organization: 'केवल उद्यम के भीतर के सदस्य',
external: 'प्रमाणित बाहरी उपयोगकर्ता',
},
operateGroupAndMember: {
searchPlaceholder: 'समूहों और सदस्यों की खोज करें',

View File

@ -80,6 +80,9 @@ const translation = {
execution: 'अनु执行',
executions: '{{num}} फाँसी',
},
login: {
backToHome: 'होम पर वापस',
},
}
export default translation

View File

@ -224,12 +224,14 @@ const translation = {
anyone: 'Chiunque può accedere all\'app web',
specific: 'Solo gruppi o membri specifici possono accedere all\'app web.',
organization: 'Qualsiasi persona nell\'organizzazione può accedere all\'app web',
external: 'Solo gli utenti esterni autenticati possono accedere all\'applicazione Web',
},
accessControlDialog: {
accessItems: {
anyone: 'Chiunque con il link',
specific: 'Gruppi o membri specifici',
organization: 'Solo i membri all\'interno dell\'impresa',
external: 'Utenti esterni autenticati',
},
operateGroupAndMember: {
searchPlaceholder: 'Cerca gruppi e membri',

View File

@ -79,6 +79,9 @@ const translation = {
execution: 'ESECUZIONE',
executions: '{{num}} ESECUZIONI',
},
login: {
backToHome: 'Torna alla home',
},
}
export default translation

View File

@ -210,30 +210,27 @@ const translation = {
},
accessControl: 'Web アプリアクセス制御',
accessItemsDescription: {
anyone: '誰でも Web アプリにアクセス可能',
specific: '特定のグループまたはメンバーのみが Web アプリにアクセス可能',
organization: '組織内の誰でも Web アプリにアクセス可能',
anyone: '誰でもこの web アプリにアクセスできます(ログイン不要)',
specific: '特定のプラットフォーム内メンバーのみがこの Web アプリにアクセスできます',
organization: 'プラットフォーム内の全メンバーがこの Web アプリにアクセスできます',
external: '認証済みの外部ユーザーのみがこの Web アプリにアクセスできます',
},
accessControlDialog: {
title: 'アクセス権限',
description: 'Web アプリのアクセス権限を設定します',
accessLabel: '誰がアクセスできますか',
accessItemsDescription: {
anyone: '誰でも Web アプリにアクセス可能です',
specific: '特定のグループやメンバーが Web アプリにアクセス可能です',
organization: '組織内の誰でも Web アプリにアクセス可能です',
},
accessItems: {
anyone: 'すべてのユーザー',
specific: '特定のグループメンバー',
organization: 'グループ内の全員',
anyone: 'リンクを知っているすべてのユーザー',
specific: '特定のプラットフォーム内メンバー',
organization: 'プラットフォーム内の全メンバー',
external: '認証済みの外部ユーザー',
},
groups_one: '{{count}} グループ',
groups_other: '{{count}} グループ',
members_one: '{{count}} メンバー',
members_other: '{{count}} メンバー',
noGroupsOrMembers: 'グループまたはメンバーが選択されていません',
webAppSSONotEnabledTip: 'Web アプリの認証方式設定については、企業管理者へご連絡ください。',
webAppSSONotEnabledTip: 'Web アプリの外部認証方式を設定するには、組織の管理者にお問い合わせください。',
operateGroupAndMember: {
searchPlaceholder: 'グループやメンバーを検索',
allMembers: 'すべてのメンバー',

View File

@ -73,6 +73,9 @@ const translation = {
atLeastOne: '1 行以上のデータが必要です',
},
},
login: {
backToHome: 'ホームに戻る',
},
}
export default translation

View File

@ -209,12 +209,14 @@ const translation = {
anyone: '누구나 웹 앱에 접근할 수 있습니다.',
specific: '특정 그룹이나 회원만 웹 앱에 접근할 수 있습니다.',
organization: '조직 내 모든 사람이 웹 애플리케이션에 접근할 수 있습니다.',
external: '인증된 외부 사용자만 웹 애플리케이션에 접근할 수 있습니다.',
},
accessControlDialog: {
accessItems: {
anyone: '링크가 있는 누구나',
specific: '특정 그룹 또는 구성원',
organization: '기업 내의 회원만',
external: '인증된 외부 사용자',
},
operateGroupAndMember: {
searchPlaceholder: '그룹 및 구성원 검색',

View File

@ -73,6 +73,9 @@ const translation = {
execution: '실행',
executions: '{{num}} 처형',
},
login: {
backToHome: '홈으로 돌아가기',
},
}
export default translation

View File

@ -220,12 +220,14 @@ const translation = {
anyone: 'Każdy może uzyskać dostęp do aplikacji webowej',
specific: 'Tylko określone grupy lub członkowie mogą uzyskać dostęp do aplikacji internetowej',
organization: 'Każdy w organizacji ma dostęp do aplikacji internetowej.',
external: 'Tylko uwierzytelnieni zewnętrzni użytkownicy mogą uzyskać dostęp do aplikacji internetowej.',
},
accessControlDialog: {
accessItems: {
anyone: 'Każdy z linkiem',
specific: 'Specyficzne grupy lub członkowie',
organization: 'Tylko członkowie w obrębie przedsiębiorstwa',
external: 'Uwierzytelnieni użytkownicy zewnętrzni',
},
operateGroupAndMember: {
searchPlaceholder: 'Szukaj grup i członków',

View File

@ -78,6 +78,9 @@ const translation = {
executions: '{{num}} EGZEKUCJI',
execution: 'WYKONANIE',
},
login: {
backToHome: 'Powrót do strony głównej',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
anyone: 'Qualquer pessoa pode acessar o aplicativo web',
specific: 'Apenas grupos ou membros específicos podem acessar o aplicativo web',
organization: 'Qualquer pessoa na organização pode acessar o aplicativo web',
external: 'Apenas usuários externos autenticados podem acessar o aplicativo Web.',
},
accessControlDialog: {
accessItems: {
anyone: 'Qualquer pessoa com o link',
specific: 'Grupos específicos ou membros',
organization: 'Apenas membros dentro da empresa',
external: 'Usuários externos autenticados',
},
operateGroupAndMember: {
searchPlaceholder: 'Pesquisar grupos e membros',

View File

@ -77,6 +77,9 @@ const translation = {
executions: '{{num}} EXECUÇÕES',
execution: 'EXECUÇÃO',
},
login: {
backToHome: 'Voltar para a página inicial',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
specific: 'Numai grupuri sau membri specifici pot accesa aplicația web.',
organization: 'Oricine din organizație poate accesa aplicația web',
anyone: 'Oricine poate accesa aplicația web',
external: 'Numai utilizatorii externi autentificați pot accesa aplicația web',
},
accessControlDialog: {
accessItems: {
anyone: 'Oricine are linkul',
specific: 'Grupuri sau membri specifici',
organization: 'Numai membrii din cadrul întreprinderii',
external: 'Utilizatori extern autentificați',
},
operateGroupAndMember: {
searchPlaceholder: 'Caută grupuri și membri',

View File

@ -77,6 +77,9 @@ const translation = {
execution: 'EXECUȚIE',
executions: '{{num}} EXECUȚII',
},
login: {
backToHome: 'Înapoi la Acasă',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
anyone: 'Любой может получить доступ к веб-приложению',
specific: 'Только определенные группы или участники могут получить доступ к веб-приложению.',
organization: 'Любой в организации может получить доступ к веб-приложению',
external: 'Только аутентифицированные внешние пользователи могут получить доступ к веб-приложению.',
},
accessControlDialog: {
accessItems: {
anyone: 'Кто угодно с ссылкой',
specific: 'Конкретные группы или члены',
organization: 'Только члены внутри предприятия',
external: 'Аутентифицированные внешние пользователи',
},
operateGroupAndMember: {
searchPlaceholder: 'Искать группы и участников',

View File

@ -77,6 +77,9 @@ const translation = {
execution: 'ИСПОЛНЕНИЕ',
executions: '{{num}} ВЫПОЛНЕНИЯ',
},
login: {
backToHome: 'Назад на главную',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
anyone: 'Vsakdo lahko dostopa do spletne aplikacije',
specific: 'Samo določenim skupinam ali članom je omogočen dostop do spletne aplikacije',
organization: 'Vsakdo v organizaciji lahko dostopa do spletne aplikacije',
external: 'Samo avtentificirani zunanji uporabniki lahko dostopajo do spletne aplikacije.',
},
accessControlDialog: {
accessItems: {
anyone: 'Kdorkoli s povezavo',
specific: 'Specifične skupine ali člani',
organization: 'Samo člani znotraj podjetja',
external: 'Avtorizirani zunanji uporabniki',
},
operateGroupAndMember: {
searchPlaceholder: 'Išči skupine in člane',

View File

@ -74,6 +74,9 @@ const translation = {
execution: 'IZVEDBA',
executions: '{{num}} IZVRŠITEV',
},
login: {
backToHome: 'Nazaj na začetno stran',
},
}
export default translation

View File

@ -209,12 +209,14 @@ const translation = {
anyone: 'ใครก็สามารถเข้าถึงเว็บแอปได้',
specific: 'สมาชิกหรือกลุ่มเฉพาะเท่านั้นที่สามารถเข้าถึงแอปเว็บได้',
organization: 'ใครก็ได้ในองค์กรสามารถเข้าถึงแอปเว็บได้',
external: 'ผู้ใช้งานภายนอกที่ได้รับการยืนยันตัวตนเท่านั้นที่สามารถเข้าถึงแอปพลิเคชันเว็บได้',
},
accessControlDialog: {
accessItems: {
specific: 'กลุ่มหรือสมาชิกเฉพาะ',
organization: 'เฉพาะสมาชิกภายในองค์กร',
anyone: 'ใครก็ตามที่มีลิงก์',
external: 'ผู้ใช้ภายนอกที่ได้รับการตรวจสอบแล้ว',
},
operateGroupAndMember: {
searchPlaceholder: 'ค้นหากลุ่มและสมาชิก',

View File

@ -73,6 +73,9 @@ const translation = {
execution: 'การดำเนินการ',
executions: '{{num}} การประหารชีวิต',
},
login: {
backToHome: 'กลับไปที่หน้าแรก',
},
}
export default translation

View File

@ -209,12 +209,14 @@ const translation = {
anyone: 'Herkes web uygulamasına erişebilir',
organization: 'Kuruluşta herkes web uygulamasına erişebilir.',
specific: 'Sadece belirli gruplar veya üyeler web uygulamasına erişebilir.',
external: 'Sadece kimliği doğrulanmış dış kullanıcılar Web uygulamasına erişebilir',
},
accessControlDialog: {
accessItems: {
anyone: 'Bağlantıya sahip olan herkes',
organization: 'Sadece işletme içindeki üyeler',
specific: 'Belirli gruplar veya üyeler',
external: 'Kimliği onaylanmış harici kullanıcılar',
},
operateGroupAndMember: {
searchPlaceholder: 'Grupları ve üyeleri ara',

View File

@ -73,6 +73,9 @@ const translation = {
execution: 'İFRAZAT',
executions: '{{num}} İDAM',
},
login: {
backToHome: 'Ana Sayfaya Dön',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
anyone: 'Будь-хто може отримати доступ до веб-додатку',
specific: 'Тільки окремі групи або члени можуть отримати доступ до веб-додатку.',
organization: 'Будь-хто в організації може отримати доступ до веб-додатку.',
external: 'Тільки перевірені зовнішні користувачі можуть отримати доступ до веб-застосунку.',
},
accessControlDialog: {
accessItems: {
anyone: 'Кожен, у кого є посилання',
specific: 'Конкретні групи або члени',
organization: 'Тільки члени підприємства',
external: 'Аутентифіковані зовнішні користувачі',
},
operateGroupAndMember: {
searchPlaceholder: 'Шукати групи та учасників',

View File

@ -73,6 +73,9 @@ const translation = {
execution: 'ВИКОНАННЯ',
executions: '{{num}} ВИКОНАНЬ',
},
login: {
backToHome: 'Повернутися на головну',
},
}
export default translation

View File

@ -213,12 +213,14 @@ const translation = {
anyone: 'Mọi người đều có thể truy cập ứng dụng web.',
specific: 'Chỉ những nhóm hoặc thành viên cụ thể mới có thể truy cập ứng dụng web.',
organization: 'Bất kỳ ai trong tổ chức đều có thể truy cập ứng dụng web.',
external: 'Chỉ những người dùng bên ngoài đã xác thực mới có thể truy cập vào ứng dụng Web.',
},
accessControlDialog: {
accessItems: {
anyone: 'Ai có liên kết',
specific: 'Các nhóm hoặc thành viên cụ thể',
organization: 'Chỉ các thành viên trong doanh nghiệp',
external: 'Người dùng bên ngoài được xác thực',
},
operateGroupAndMember: {
searchPlaceholder: 'Tìm kiếm nhóm và thành viên',

View File

@ -73,6 +73,9 @@ const translation = {
executions: '{{num}} ÁN TỬ HÌNH',
execution: 'THI HÀNH',
},
login: {
backToHome: 'Trở về Trang Chủ',
},
}
export default translation

View File

@ -199,30 +199,27 @@ const translation = {
},
accessControl: 'Web 应用访问控制',
accessItemsDescription: {
anyone: '任何人可以访问 web 应用',
specific: '特定组或成员可以访问 web 应用',
organization: '组织内任何人可以访问 web 应用',
anyone: '任何人都可以访问该 web 应用(无需登录)',
specific: '仅指定的平台内成员可访问该 Web 应用',
organization: '平台内所有成员均可访问该 Web 应用',
external: '仅经认证的外部用户可访问该 Web 应用',
},
accessControlDialog: {
title: 'Web 应用访问权限',
description: '设置 web 应用访问权限。',
accessLabel: '谁可以访问',
accessItemsDescription: {
anyone: '任何人可以访问 web 应用',
specific: '特定组或成员可以访问 web 应用',
organization: '组织内任何人可以访问 web 应用',
},
accessItems: {
anyone: '任何人',
specific: '特定组或成员',
organization: '组织内任何人',
specific: '平台内指定成员',
organization: '平台内所有成员',
external: '经认证的外部用户',
},
groups_one: '{{count}} 个组',
groups_other: '{{count}} 个组',
members_one: '{{count}} 个成员',
members_other: '{{count}} 个成员',
noGroupsOrMembers: '未选择分组或成员',
webAppSSONotEnabledTip: '请联系企业管理员配置 web 应用的身份认证方式。',
webAppSSONotEnabledTip: '请联系企业管理员配置 Web 应用外部认证方式。',
operateGroupAndMember: {
searchPlaceholder: '搜索组或成员',
allMembers: '所有成员',

View File

@ -73,6 +73,9 @@ const translation = {
atLeastOne: '上传文件的内容不能少于一条',
},
},
login: {
backToHome: '返回首页',
},
}
export default translation

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