Compare commits

...

15 Commits

Author SHA1 Message Date
Joel
c21f7c48fb fix: update permission in member list caused page crash (#30164)
Some checks are pending
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
2026-01-12 18:45:28 -08:00
NFish
8371a26cdf fix: reinstall packages, rebuild pnpm-lock.yaml file
Some checks failed
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
2026-01-12 15:42:39 +08:00
Stephen Zhou
a6e43f0fa0 build: limit esbuild, glob, docker base version to avoid cve (#30848) 2026-01-12 15:35:46 +08:00
NFish
30f9199fba Merge remote-tracking branch 'origin/hotfix/1.11.2-fix.3' into release/e-1.11.2 2026-01-12 13:05:04 +08:00
GareArc
b47afdd314 Revert "feat: implement workspace permission checks for member invitations and owner transfer"
This reverts commit 248871fca1.
2026-01-11 20:17:55 -08:00
GareArc
fc81605ae8 feat: add queue credential sync when tenant created
- Add queue credential sync functionality when tenant is created
- Replace FeatureService with dify_config for enterprise feature check
- Improve logging format in WorkspaceSyncService
- Update timestamp creation to use UTC
- Simplify tenant creation event emission by removing unnecessary source parameter
2026-01-11 18:43:34 -08:00
GareArc
248871fca1 feat: implement workspace permission checks for member invitations and owner transfer 2026-01-11 18:42:46 -08:00
NFish
4e0d3c224f fix: web app login code encrypt (#30705)
Some checks failed
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
2026-01-08 15:39:37 +08:00
GareArc
c9858f851f feat: add decryption decorators for password and code fields in login API (#30680) 2026-01-07 23:24:10 -08:00
Joel
c17052b8b4 fix: create from template permission set error 2026-01-07 15:57:23 +08:00
Xiyuan Chen
70571b53ad fix: use query param for delete method (#30206) 2025-12-29 21:48:54 -08:00
jyong
44ef3cc27d fix multimodal embedding retrival test
Some checks failed
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
2025-12-26 17:30:51 +08:00
jyong
676063890c fix multimodal embedding retrival test 2025-12-26 17:05:37 +08:00
jyong
901cc64ac9 fix multimodal embedding retrival test 2025-12-26 17:04:46 +08:00
Stephen Zhou
894a3c03a2 fix: load i18n on server (#30171) 2025-12-26 10:30:27 +08:00
15 changed files with 509 additions and 604 deletions

View File

@@ -10,7 +10,12 @@ from controllers.console.auth.error import (
InvalidEmailError,
)
from controllers.console.error import AccountBannedError
from controllers.console.wraps import only_edition_enterprise, setup_required
from controllers.console.wraps import (
decrypt_code_field,
decrypt_password_field,
only_edition_enterprise,
setup_required,
)
from controllers.web import web_ns
from controllers.web.wraps import decode_jwt_token
from libs.helper import email
@@ -42,6 +47,7 @@ class LoginApi(Resource):
404: "Account not found",
}
)
@decrypt_password_field
def post(self):
"""Authenticate user and login."""
parser = (
@@ -181,6 +187,7 @@ class EmailCodeLoginApi(Resource):
404: "Account not found",
}
)
@decrypt_code_field
def post(self):
parser = (
reqparse.RequestParser()

View File

@@ -381,10 +381,9 @@ class RetrievalService:
records = []
include_segment_ids = set()
segment_child_map = {}
segment_file_map = {}
valid_dataset_documents = {}
image_doc_ids = []
image_doc_ids: list[Any] = []
child_index_node_ids = []
index_node_ids = []
doc_to_document_map = {}
@@ -421,7 +420,7 @@ class RetrievalService:
index_node_segments: list[DocumentSegment] = []
segments: list[DocumentSegment] = []
attachment_map = {}
child_chunk_map = {}
child_chunk_map: dict[Any, Any] = {}
doc_segment_map = {}
with session_factory.create_session() as session:
@@ -429,16 +428,27 @@ class RetrievalService:
for attachment in attachments:
segment_ids.append(attachment["segment_id"])
attachment_map[attachment["segment_id"]] = attachment
doc_segment_map[attachment["segment_id"]] = attachment["attachment_id"]
if attachment["segment_id"] in attachment_map:
attachment_map[attachment["segment_id"]].append(attachment["attachment_info"])
else:
attachment_map[attachment["segment_id"]] = [attachment["attachment_info"]]
if attachment["attachment_id"] in doc_segment_map:
doc_segment_map[attachment["segment_id"]].append(attachment["attachment_id"])
else:
doc_segment_map[attachment["segment_id"]] = [attachment["attachment_id"]]
child_chunk_stmt = select(ChildChunk).where(ChildChunk.index_node_id.in_(child_index_node_ids))
child_index_nodes = session.execute(child_chunk_stmt).scalars().all()
for i in child_index_nodes:
segment_ids.append(i.segment_id)
child_chunk_map[i.segment_id] = i
doc_segment_map[i.segment_id] = i.index_node_id
if i.segment_id in child_chunk_map:
child_chunk_map[i.segment_id].append(i)
else:
child_chunk_map[i.segment_id] = [i]
if i.segment_id in doc_segment_map:
doc_segment_map[i.segment_id].append(i.index_node_id)
else:
doc_segment_map[i.segment_id] = [i.index_node_id]
if index_node_ids:
document_segment_stmt = select(DocumentSegment).where(
@@ -448,7 +458,7 @@ class RetrievalService:
)
index_node_segments = session.execute(document_segment_stmt).scalars().all() # type: ignore
for index_node_segment in index_node_segments:
doc_segment_map[index_node_segment.id] = index_node_segment.index_node_id
doc_segment_map[index_node_segment.id] = [index_node_segment.index_node_id]
if segment_ids:
document_segment_stmt = select(DocumentSegment).where(
DocumentSegment.enabled == True,
@@ -461,85 +471,65 @@ class RetrievalService:
segments.extend(index_node_segments)
for segment in segments:
doc_id = doc_segment_map.get(segment.id)
child_chunk = child_chunk_map.get(segment.id)
attachment_info = attachment_map.get(segment.id)
child_chunks: list[ChildChunk] = child_chunk_map.get(segment.id, [])
attachment_infos: list[dict[str, Any]] = attachment_map.get(segment.id, [])
ds_dataset_document: DatasetDocument | None = valid_dataset_documents.get(segment.document_id)
if doc_id:
document = doc_to_document_map[doc_id]
ds_dataset_document: DatasetDocument | None = valid_dataset_documents.get(
document.metadata.get("document_id")
)
if ds_dataset_document and ds_dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
if segment.id not in include_segment_ids:
include_segment_ids.add(segment.id)
if child_chunk:
if ds_dataset_document and ds_dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
if segment.id not in include_segment_ids:
include_segment_ids.add(segment.id)
if child_chunks or attachment_infos:
child_chunk_details = []
max_score = 0.0
for child_chunk in child_chunks:
document = doc_to_document_map[child_chunk.index_node_id]
child_chunk_detail = {
"id": child_chunk.id,
"content": child_chunk.content,
"position": child_chunk.position,
"score": document.metadata.get("score", 0.0) if document else 0.0,
}
map_detail = {
"max_score": document.metadata.get("score", 0.0) if document else 0.0,
"child_chunks": [child_chunk_detail],
}
segment_child_map[segment.id] = map_detail
record = {
"segment": segment,
child_chunk_details.append(child_chunk_detail)
max_score = max(max_score, document.metadata.get("score", 0.0) if document else 0.0)
for attachment_info in attachment_infos:
file_document = doc_to_document_map[attachment_info["id"]]
max_score = max(
max_score, file_document.metadata.get("score", 0.0) if file_document else 0.0
)
map_detail = {
"max_score": max_score,
"child_chunks": child_chunk_details,
}
if attachment_info:
segment_file_map[segment.id] = [attachment_info]
records.append(record)
else:
if child_chunk:
child_chunk_detail = {
"id": child_chunk.id,
"content": child_chunk.content,
"position": child_chunk.position,
"score": document.metadata.get("score", 0.0),
}
if segment.id in segment_child_map:
segment_child_map[segment.id]["child_chunks"].append(child_chunk_detail) # type: ignore
segment_child_map[segment.id]["max_score"] = max(
segment_child_map[segment.id]["max_score"],
document.metadata.get("score", 0.0) if document else 0.0,
)
else:
segment_child_map[segment.id] = {
"max_score": document.metadata.get("score", 0.0) if document else 0.0,
"child_chunks": [child_chunk_detail],
}
if attachment_info:
if segment.id in segment_file_map:
segment_file_map[segment.id].append(attachment_info)
else:
segment_file_map[segment.id] = [attachment_info]
else:
if segment.id not in include_segment_ids:
include_segment_ids.add(segment.id)
record = {
"segment": segment,
"score": document.metadata.get("score", 0.0), # type: ignore
}
if attachment_info:
segment_file_map[segment.id] = [attachment_info]
records.append(record)
else:
if attachment_info:
attachment_infos = segment_file_map.get(segment.id, [])
if attachment_info not in attachment_infos:
attachment_infos.append(attachment_info)
segment_file_map[segment.id] = attachment_infos
segment_child_map[segment.id] = map_detail
record = {
"segment": segment,
}
records.append(record)
else:
if segment.id not in include_segment_ids:
include_segment_ids.add(segment.id)
max_score = 0.0
document = doc_to_document_map.get(segment.index_node_id)
if document:
max_score = max(max_score, document.metadata.get("score", 0.0))
for attachment_info in attachment_infos:
file_document = doc_to_document_map.get(attachment_info["id"])
if file_document:
max_score = max(max_score, file_document.metadata.get("score", 0.0))
record = {
"segment": segment,
"score": max_score,
}
records.append(record)
# Add child chunks information to records
for record in records:
if record["segment"].id in segment_child_map:
record["child_chunks"] = segment_child_map[record["segment"].id].get("child_chunks") # type: ignore
record["score"] = segment_child_map[record["segment"].id]["max_score"] # type: ignore
if record["segment"].id in segment_file_map:
record["files"] = segment_file_map[record["segment"].id] # type: ignore[assignment]
if record["segment"].id in attachment_map:
record["files"] = attachment_map[record["segment"].id] # type: ignore[assignment]
result = []
for record in records:
@@ -551,6 +541,9 @@ class RetrievalService:
if not isinstance(child_chunks, list):
child_chunks = None
if child_chunks:
child_chunks = sorted(child_chunks, key=lambda x: x.get("score", 0.0), reverse=True)
# Extract files, ensuring it's a list or None
files = record.get("files")
if not isinstance(files, list):
@@ -570,7 +563,7 @@ class RetrievalService:
)
result.append(retrieval_segment)
return result
return sorted(result, key=lambda x: x.score, reverse=True)
except Exception as e:
db.session.rollback()
raise e

View File

@@ -6,6 +6,7 @@ from .create_site_record_when_app_created import handle as handle_create_site_re
from .delete_tool_parameters_cache_when_sync_draft_workflow import (
handle as handle_delete_tool_parameters_cache_when_sync_draft_workflow,
)
from .queue_credential_sync_when_tenant_created import handle as handle_queue_credential_sync_when_tenant_created
from .sync_plugin_trigger_when_app_created import handle as handle_sync_plugin_trigger_when_app_created
from .sync_webhook_when_app_created import handle as handle_sync_webhook_when_app_created
from .sync_workflow_schedule_when_app_published import handle as handle_sync_workflow_schedule_when_app_published
@@ -30,6 +31,7 @@ __all__ = [
"handle_create_installed_app_when_app_created",
"handle_create_site_record_when_app_created",
"handle_delete_tool_parameters_cache_when_sync_draft_workflow",
"handle_queue_credential_sync_when_tenant_created",
"handle_sync_plugin_trigger_when_app_created",
"handle_sync_webhook_when_app_created",
"handle_sync_workflow_schedule_when_app_published",

View File

@@ -0,0 +1,19 @@
from configs import dify_config
from events.tenant_event import tenant_was_created
from services.enterprise.workspace_sync import WorkspaceSyncService
@tenant_was_created.connect
def handle(sender, **kwargs):
"""Queue credential sync when a tenant/workspace is created."""
# Only queue sync tasks if plugin manager (enterprise feature) is enabled
if not dify_config.ENTERPRISE_ENABLED:
return
tenant = sender
# Determine source from kwargs if available, otherwise use generic
source = kwargs.get("source", "tenant_created")
# Queue credential sync task to Redis for enterprise backend to process
WorkspaceSyncService.queue_credential_sync(tenant.id, source=source)

View File

@@ -110,5 +110,5 @@ class EnterpriseService:
if not app_id:
raise ValueError("app_id must be provided.")
body = {"appId": app_id}
EnterpriseRequest.send_request("DELETE", "/webapp/clean", json=body)
params = {"appId": app_id}
EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params)

View File

@@ -0,0 +1,58 @@
import json
import logging
import uuid
from datetime import UTC, datetime
from redis import RedisError
from extensions.ext_redis import redis_client
logger = logging.getLogger(__name__)
WORKSPACE_SYNC_QUEUE = "enterprise:workspace:sync:queue"
WORKSPACE_SYNC_PROCESSING = "enterprise:workspace:sync:processing"
class WorkspaceSyncService:
"""Service to publish workspace sync tasks to Redis queue for enterprise backend consumption"""
@staticmethod
def queue_credential_sync(workspace_id: str, *, source: str) -> bool:
"""
Queue a credential sync task for a newly created workspace.
This publishes a task to Redis that will be consumed by the enterprise backend
worker to sync credentials with the plugin-manager.
Args:
workspace_id: The workspace/tenant ID to sync credentials for
source: Source of the sync request (for debugging/tracking)
Returns:
bool: True if task was queued successfully, False otherwise
"""
try:
task = {
"task_id": str(uuid.uuid4()),
"workspace_id": workspace_id,
"retry_count": 0,
"created_at": datetime.now(UTC).isoformat(),
"source": source,
}
# Push to Redis list (queue) - LPUSH adds to the head, worker consumes from tail with RPOP
redis_client.lpush(WORKSPACE_SYNC_QUEUE, json.dumps(task))
logger.info(
"Queued credential sync task for workspace %s, task_id: %s, source: %s",
workspace_id,
task["task_id"],
source,
)
return True
except (RedisError, TypeError) as e:
logger.error("Failed to queue credential sync for workspace %s: %s", workspace_id, str(e), exc_info=True)
# Don't raise - we don't want to fail workspace creation if queueing fails
# The scheduled task will catch it later
return False

View File

@@ -1,5 +1,5 @@
# base image
FROM node:22-alpine3.21 AS base
FROM node:22.21.1-alpine3.23 AS base
LABEL maintainer="takatost@gmail.com"
# if you located in China, you can use aliyun mirror to speed up

View File

@@ -14,6 +14,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
import { encryptVerificationCode } from '@/utils/encryption'
export default function CheckCode() {
const { t } = useTranslation()
@@ -64,7 +65,7 @@ export default function CheckCode() {
return
}
setIsLoading(true)
const ret = await webAppEmailLoginWithCode({ email, code, token })
const ret = await webAppEmailLoginWithCode({ email, code: encryptVerificationCode(code), token })
if (ret.result === 'success') {
setWebAppAccessToken(ret.data.access_token)
const { access_token } = await fetchAccessToken({

View File

@@ -14,6 +14,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { webAppLogin } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
import { encryptPassword } from '@/utils/encryption'
type MailAndPasswordAuthProps = {
isEmailSetup: boolean
@@ -72,7 +73,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
setIsLoading(true)
const loginData: Record<string, any> = {
email,
password,
password: encryptPassword(password),
language: locale,
remember_me: true,
}

View File

@@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppTypeSelector from '@/app/components/app/type-selector'
import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
@@ -19,7 +18,6 @@ import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
@@ -48,7 +46,6 @@ const Apps = ({
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const { push } = useRouter()
const { hasEditPermission } = useContext(ExploreContext)
const allCategoriesEn = AppCategories.RECOMMENDED
const [keywords, setKeywords] = useState('')
@@ -218,7 +215,7 @@ const Apps = ({
<AppCard
key={app.app_id}
app={app}
canCreate={hasEditPermission}
canCreate={isCurrentWorkspaceEditor}
onCreate={() => {
setCurrApp(app)
setIsShowCreateModal(true)

View File

@@ -0,0 +1,91 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import Operation from './index'
const mockUpdateMemberRole = vi.fn()
const mockDeleteMemberOrCancelInvitation = vi.fn()
vi.mock('@/service/common', () => ({
deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(),
updateMemberRole: () => mockUpdateMemberRole(),
}))
const mockUseProviderContext = vi.fn(() => ({
datasetOperatorEnabled: false,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
const defaultMember: Member = {
id: 'member-id',
name: 'Test Member',
email: 'test@example.com',
avatar: '',
avatar_url: null,
status: 'active',
role: 'editor',
last_login_at: '',
last_active_at: '',
created_at: '',
}
const renderOperation = (propsOverride: Partial<Member> = {}, operatorRole = 'owner', onOperate?: () => void) => {
const mergedMember = { ...defaultMember, ...propsOverride }
return render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<Operation member={mergedMember} operatorRole={operatorRole} onOperate={onOperate ?? vi.fn()} />
</ToastContext.Provider>,
)
}
describe('Operation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
})
it('renders the current role label', () => {
renderOperation()
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
})
it('shows dataset operator option when the feature flag is enabled', async () => {
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation()
fireEvent.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('calls updateMemberRole and onOperate when selecting another role', async () => {
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.normal'))
await waitFor(() => {
expect(mockUpdateMemberRole).toHaveBeenCalled()
expect(onOperate).toHaveBeenCalled()
})
})
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
await waitFor(() => {
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
expect(onOperate).toHaveBeenCalled()
})
})
})

View File

@@ -1,10 +1,14 @@
'use client'
import type { Member } from '@/models/common'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
import { Fragment, useMemo } from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { ToastContext } from '@/app/components/base/toast'
import { useProviderContext } from '@/context/provider-context'
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
@@ -21,6 +25,7 @@ const Operation = ({
operatorRole,
onOperate,
}: IOperationProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const { datasetOperatorEnabled } = useProviderContext()
const RoleMap = {
@@ -51,6 +56,7 @@ const Operation = ({
const { notify } = useContext(ToastContext)
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
const handleDeleteMemberOrCancelInvitation = async () => {
setOpen(false)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
onOperate()
@@ -61,6 +67,7 @@ const Operation = ({
}
}
const handleUpdateMemberRole = async (role: string) => {
setOpen(false)
try {
await updateMemberRole({
url: `/workspaces/current/members/${member.id}/update-role`,
@@ -75,63 +82,50 @@ const Operation = ({
}
return (
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
>
<div className="p-1">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
<div className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[999]">
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
<div className="p-1">
{
roleList.map(role => (
<div key={role} className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
{
roleList.map(role => (
<MenuItem key={role}>
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
{
role === member.role
? <CheckIcon className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
</div>
</div>
</MenuItem>
))
role === member.role
? <CheckIcon className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
</div>
<MenuItem>
<div className="border-t border-divider-subtle p-1">
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
</div>
</div>
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</>
)
}
</Menu>
</div>
))
}
</div>
<div className="border-t border-divider-subtle p-1">
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Operation
export default memo(Operation)

View File

@@ -1,7 +1,6 @@
import type { Locale } from '.'
import type { KeyPrefix, Namespace } from './i18next-config'
import type { Namespace } from './i18next-config'
import { match } from '@formatjs/intl-localematcher'
import { camelCase } from 'es-toolkit/compat'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import Negotiator from 'negotiator'
@@ -23,10 +22,11 @@ const initI18next = async (lng: Locale, ns: Namespace) => {
return i18nInstance
}
export async function getTranslation(lng: Locale, ns: Namespace) {
export async function getTranslation(lng: Locale, ns: Namespace, options: Record<string, any> = {}) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, 'translation', camelCase(ns) as KeyPrefix),
// @ts-expect-error types mismatch
t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
i18n: i18nextInstance,
}
}

View File

@@ -232,7 +232,8 @@
"brace-expansion@<2.0.2": "2.0.2",
"devalue@<5.3.2": "5.3.2",
"es-iterator-helpers": "npm:@nolyfill/es-iterator-helpers@^1",
"esbuild@<0.25.0": "0.25.0",
"esbuild@<0.27.2": "0.27.2",
"glob@>=10.2.0,<10.5.0": "11.1.0",
"hasown": "npm:@nolyfill/hasown@^1",
"is-arguments": "npm:@nolyfill/is-arguments@^1",
"is-core-module": "npm:@nolyfill/is-core-module@^1",
@@ -274,7 +275,6 @@
"@types/react-dom": "~19.2.3",
"brace-expansion": "~2.0",
"canvas": "^3.2.0",
"esbuild": "~0.25.0",
"pbkdf2": "~3.1.3",
"prismjs": "~1.30",
"string-width": "~4.2.3"

656
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff