Merge branch 'main' of github.com:langgenius/dify into db-session-annotation

This commit is contained in:
Byron Wang
2026-07-08 17:53:13 +08:00
95 changed files with 1017 additions and 374 deletions

View File

@ -1,30 +0,0 @@
# HITL timeout semantics implementation report
## What changed
- Updated `api/core/workflow/nodes/human_input/callback.py` so `DifyHITLCallback` now preserves Dify's timeout split at the boundary:
- `HumanInputFormStatus.TIMEOUT` returns the graphon timeout branch via `Expired(selected_handle="__timeout__", ...)`.
- `HumanInputFormStatus.EXPIRED` is treated as an invalid resume state and raises `AssertionError`.
- `HumanInputFormStatus.WAITING` with a past global deadline is treated as an invalid resume state and raises `AssertionError`.
- `HumanInputFormStatus.WAITING` with only the node-level deadline expired still returns the timeout branch.
- Added `created_at` to `HumanInputFormEntity` and `_HumanInputFormEntityImpl` so the callback can compute the global deadline using Dify's shared `HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS` invariant.
- Kept the submitted and pause flows unchanged.
- Added focused unit coverage in `api/tests/unit_tests/core/workflow/test_human_input_callback.py` for:
- node timeout branch
- global expiration rejection
- waiting-form past node deadline timeout
- waiting-form past global deadline rejection
## Verification
- `uv run --project api pytest -o addopts='' api/tests/unit_tests/core/workflow/test_human_input_callback.py api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py -q`
- `git diff --check`
## Result
- The focused test set is expected to pass with the new `created_at` boundary in place.
- No unrelated files were modified.
## Concerns
- The callback now fails fast on invalid resume states by design. That is intentional, but any caller that previously relied on `EXPIRED` being mapped to the timeout branch will now see an assertion failure instead.

View File

@ -3,8 +3,9 @@
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any
from typing import Any, cast
from unittest.mock import MagicMock, patch
import pytest
@ -475,13 +476,24 @@ class TestWorkflowTrace:
assert span_call[1]["start_time"] == _T0
assert span_call[1]["end_time"] == _T1
def test_emits_companion_log_with_event_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log:
def test_emits_companion_log_with_event_name(
self,
trace_handler: EnterpriseOtelTrace,
caplog: pytest.LogCaptureFixture,
):
with caplog.at_level(logging.INFO, logger="dify.telemetry"):
trace_handler._workflow_trace(make_workflow_info())
mock_log.assert_called_once()
assert mock_log.call_args[1]["event_name"] == EnterpriseTelemetryEvent.WORKFLOW_RUN
assert mock_log.call_args[1]["tenant_id"] == "tenant-abc"
records = [record for record in caplog.records if record.name == "dify.telemetry"]
assert len(records) == 1
record = records[0]
attrs = cast(dict[str, Any], record.__dict__["attributes"])
assert attrs["dify.event.name"] == EnterpriseTelemetryEvent.WORKFLOW_RUN
assert attrs["dify.event.signal"] == "span_detail"
assert record.__dict__["tenant_id"] == "tenant-abc"
def test_companion_log_includes_content_when_enabled(self, trace_handler: EnterpriseOtelTrace, mock_exporter):
mock_exporter.include_content = True

View File

@ -113,6 +113,21 @@ describe('Registry (pure)', () => {
expect(active?.ctx.account.email).toBe('a@x')
})
it('resolveActive returns the active context with insecureTls', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setInsecureTls('h1', true)
reg.setHost('h1')
reg.setAccount('a@x')
expect(reg.resolveActive()?.insecureTls).toBe(true)
})
it('setInsecureTls is a no-op for an unknown host', () => {
const reg = baseReg()
reg.setInsecureTls('missing', true)
expect(reg.hosts.missing).toBeUndefined()
})
it('resolveActive returns undefined for each missing pointer', () => {
const reg = baseReg()
expect(reg.resolveActive()).toBeUndefined()

View File

@ -41,6 +41,7 @@ export type AccountContext = z.infer<typeof AccountContextSchema>
export const HostEntrySchema = z.object({
scheme: z.string().optional(),
insecure_tls: z.boolean().optional(),
current_account: z.string().optional(),
accounts: z.record(z.string(), AccountContextSchema).default({}),
})
@ -58,6 +59,7 @@ export type ActiveContext = {
readonly email: string
readonly ctx: AccountContext
readonly scheme?: string
readonly insecureTls?: boolean
}
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
@ -104,7 +106,7 @@ export class Registry {
const ctx = entry.accounts[email]
if (ctx === undefined)
return undefined
return { host, email, ctx, scheme: entry.scheme }
return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls }
}
requireActive(hint?: string): ActiveContext {
@ -157,6 +159,12 @@ export class Registry {
entry.scheme = scheme
}
setInsecureTls(host: string, insecure: boolean): void {
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.insecure_tls = insecure
}
activate(host: string, email: string, ctx: AccountContext): void {
this.upsert(host, email, ctx)
this.setHost(host)

View File

@ -13,7 +13,7 @@ import { formatErrorForCli } from '@/errors/format'
import { createHttpClient } from '@/http/client'
import { getTokenStore } from '@/store/manager'
import { realStreams } from '@/sys/io/streams'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { activeHostInfo, openAPIBase } from '@/util/host'
import { enforceDifyVersion } from '@/version/enforce'
import { versionInfo } from '@/version/info'
import { maybeNudgeCompat } from '@/version/nudge'
@ -50,17 +50,17 @@ export async function buildAuthedContext(
if (bearer === '')
fail(cmd, opts, io)
const host = hostWithScheme(active.host, active.scheme)
const { host, insecure } = activeHostInfo(active)
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts })
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge
// handles the "server too new" direction.
await enforceDifyVersion(host)
await runCompatNudge({ host, io })
await enforceDifyVersion(host, { insecure })
await runCompatNudge({ host, insecure, io })
return { reg, active, store, http, host, io, cache }
}
@ -74,6 +74,7 @@ function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStr
// command flows through it without per-command wiring.
async function runCompatNudge(opts: {
readonly host: string
readonly insecure: boolean
readonly io: IOStreams
}): Promise<void> {
try {
@ -81,7 +82,7 @@ async function runCompatNudge(opts: {
await maybeNudgeCompat(opts.host, {
store,
probe: async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure: opts.insecure })
return new MetaClient(http).serverVersion()
},
emit: line => opts.io.err.write(line),

View File

@ -27,7 +27,7 @@ export default class Login extends DifyCommand {
default: false,
}),
'insecure': Flags.boolean({
description: 'allow http:// hosts (local-dev only)',
description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)',
default: false,
}),
}
@ -40,7 +40,7 @@ export default class Login extends DifyCommand {
noBrowser: flags['no-browser'],
insecure: flags.insecure,
verifyServer: async (host) => {
await enforceDifyVersion(host, { forceFresh: true })
await enforceDifyVersion(host, { forceFresh: true, insecure: flags.insecure })
},
})
}

View File

@ -44,7 +44,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
const host = await resolveLoginHost(opts, insecure)
const label = opts.deviceLabel ?? defaultDeviceLabel()
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host) }))
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure }))
const code = await api.requestCode({ device_label: label })
renderCodePrompt(opts.io.err, cs, code)
@ -88,6 +88,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
reg.token_storage = storeBundle.mode
reg.activate(display, email, ctx)
applyScheme(reg, display, host)
reg.setInsecureTls(display, insecure)
await reg.save()
renderLoggedIn(opts.io.out, cs, host, success)

View File

@ -6,7 +6,7 @@ import { createHttpClient } from '@/http/client'
import { getTokenStore } from '@/store/manager'
import { runWithSpinner } from '@/sys/io/spinner'
import { realStreams } from '@/sys/io/streams'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { activeHostInfo, openAPIBase } from '@/util/host'
import { runLogout } from './logout.js'
export default class Logout extends DifyCommand {
@ -32,7 +32,8 @@ export default class Logout extends DifyCommand {
}
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
if (bearer !== '') {
http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 })
const { host, insecure } = activeHostInfo(active)
http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure })
}
}

View File

@ -0,0 +1,66 @@
import type { Buffer } from 'node:buffer'
import type { AddressInfo } from 'node:net'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import * as https from 'node:https'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createHttpClient } from './client.js'
function generateSelfSignedCert(dir: string): { key: Buffer, cert: Buffer } {
const keyPath = join(dir, 'key.pem')
const certPath = join(dir, 'cert.pem')
execFileSync('openssl', [
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
keyPath,
'-out',
certPath,
'-days',
'1',
'-subj',
'/CN=localhost',
], { stdio: ['ignore', 'ignore', 'pipe'] })
return { key: readFileSync(keyPath), cert: readFileSync(certPath) }
}
// A real server, not a fetch mock, so this also covers Bun's native `tls`
// fetch option (ignored by Node, which only reads undici's `dispatcher`).
describe('createHttpClient against a real self-signed TLS server', () => {
let server: https.Server
let baseURL: string
let certDir: string
beforeAll(async () => {
certDir = mkdtempSync(join(tmpdir(), 'difyctl-tls-test-'))
const { key, cert } = generateSelfSignedCert(certDir)
server = https.createServer({ key, cert }, (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
await new Promise<void>(resolve => server.listen(0, resolve))
const port = (server.address() as AddressInfo).port
baseURL = `https://localhost:${port}/`
})
afterAll(async () => {
await new Promise<void>(resolve => server.close(() => resolve()))
rmSync(certDir, { recursive: true, force: true })
})
it('rejects the self-signed cert by default', async () => {
const http = createHttpClient({ baseURL, retryAttempts: 0 })
await expect(http.get('')).rejects.toBeDefined()
})
it('accepts the self-signed cert when insecure: true', async () => {
const http = createHttpClient({ baseURL, retryAttempts: 0, insecure: true })
const res = await http.get<{ ok: boolean }>('')
expect(res.ok).toBe(true)
})
})

View File

@ -38,6 +38,7 @@ type ClientState = {
readonly logger: HttpLogger | undefined
readonly originalOptions: ClientOptions
readonly dispatcher: ReturnType<typeof proxyDispatcher>
readonly insecure: boolean
}
function toArray<T>(value: T | T[] | undefined): T[] {
@ -74,7 +75,8 @@ function compileState(opts: ClientOptions): ClientState {
hooks: { onRequest, onResponse, onRequestError, onResponseError },
logger: opts.logger,
originalOptions: opts,
dispatcher: proxyDispatcher(),
dispatcher: proxyDispatcher({ insecure: opts.insecure }),
insecure: opts.insecure ?? false,
}
}
@ -171,9 +173,16 @@ async function execute(
await runHooks(state.hooks.onRequest, ctx)
const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
// Two runtimes, two options: Node's fetch reads undici's `dispatcher` (used
// below for TLS-skip + proxy routing); Bun's native fetch — what the compiled
// difyctl binary actually runs on — ignores `dispatcher` entirely and instead
// needs its own `tls` option. Set both; each runtime ignores the one it
// doesn't understand.
const init: RequestInit & { dispatcher?: unknown, tls?: { rejectUnauthorized: boolean }, verbose?: boolean } = { signal }
if (state.dispatcher !== undefined)
init.dispatcher = state.dispatcher
if (state.insecure)
init.tls = { rejectUnauthorized: false }
if (isVerbose())
init.verbose = true

View File

@ -52,4 +52,28 @@ describe('proxyDispatcher', () => {
expect(proxyDispatcher()).toBe(first)
await first?.close()
})
it('builds a plain Agent with TLS verification disabled when insecure is set and no proxy env', async () => {
const { proxyDispatcher } = await import('./proxy.js')
const d = proxyDispatcher({ insecure: true })
expect(d?.constructor.name).toBe('Agent')
await d?.close()
})
it('builds an EnvHttpProxyAgent with TLS verification disabled when insecure and a proxy are both set', async () => {
process.env.HTTP_PROXY = 'http://127.0.0.1:8888'
const { proxyDispatcher } = await import('./proxy.js')
const d = proxyDispatcher({ insecure: true })
expect(d?.constructor.name).toBe('EnvHttpProxyAgent')
await d?.close()
})
it('re-resolves when the insecure flag changes for the same call site', async () => {
const { proxyDispatcher } = await import('./proxy.js')
const secure = proxyDispatcher({ insecure: false })
expect(secure).toBeUndefined()
const insecure = proxyDispatcher({ insecure: true })
expect(insecure?.constructor.name).toBe('Agent')
await insecure?.close()
})
})

View File

@ -1,4 +1,5 @@
import { EnvHttpProxyAgent } from 'undici'
import type { Dispatcher } from 'undici'
import { Agent, EnvHttpProxyAgent } from 'undici'
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const
@ -6,18 +7,30 @@ export function hasProxyEnv(): boolean {
return PROXY_ENV_KEYS.some(k => (process.env[k] ?? '') !== '')
}
let resolved = false
let agent: EnvHttpProxyAgent | undefined
export type ProxyDispatcherOptions = {
// --insecure on a self-signed https:// host: skip certificate verification
// (local-dev only, same flag that allows plain http:// hosts).
readonly insecure?: boolean
}
let resolvedKey: string | undefined
let agent: Dispatcher | undefined
// Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY. When a proxy
// var is set, route requests through an EnvHttpProxyAgent (it also reads the
// lowercase variants and honours NO_PROXY); when none is set, return undefined so
// fetch keeps Node's default global dispatcher untouched. Resolved once per
// process — proxy env vars are fixed for a single CLI invocation.
export function proxyDispatcher(): EnvHttpProxyAgent | undefined {
if (!resolved) {
agent = hasProxyEnv() ? new EnvHttpProxyAgent() : undefined
resolved = true
// lowercase variants and honours NO_PROXY); when none is set and TLS verification
// isn't being skipped, return undefined so fetch keeps Node's default global
// dispatcher untouched. Resolved once per (proxy env, insecure) combination
// both are fixed for a single CLI invocation.
export function proxyDispatcher(opts: ProxyDispatcherOptions = {}): Dispatcher | undefined {
const insecure = opts.insecure ?? false
const key = `${hasProxyEnv()}:${insecure}`
if (resolvedKey !== key) {
const tls = insecure ? { rejectUnauthorized: false } : undefined
agent = hasProxyEnv()
? new EnvHttpProxyAgent(tls !== undefined ? { connect: tls, requestTls: tls, proxyTls: tls } : undefined)
: (tls !== undefined ? new Agent({ connect: tls }) : undefined)
resolvedKey = key
}
return agent
}

View File

@ -75,6 +75,8 @@ export type ClientOptions = {
readonly retryAttempts?: number
readonly logger?: HttpLogger
readonly hooks?: Hooks
// Skip TLS certificate verification (local-dev only, self-signed hosts).
readonly insecure?: boolean
}
export type HttpClient = {

View File

@ -1,3 +1,4 @@
import type { ActiveContext } from '@/auth/hosts'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
@ -44,6 +45,16 @@ export function hostWithScheme(host: string, scheme: string | undefined): string
return `${proto}://${host}`
}
// Every call site that builds an HTTP client for the active host needs both its
// scheme-qualified host and whether TLS verification is disabled for it — derive
// them together so neither is forgotten independently.
export function activeHostInfo(active: Pick<ActiveContext, 'host' | 'scheme' | 'insecureTls'>): { host: string, insecure: boolean } {
return {
host: hostWithScheme(active.host, active.scheme),
insecure: active.insecureTls === true,
}
}
export function bareHost(raw: string): string {
try {
const u = new URL(raw)

View File

@ -16,15 +16,18 @@ const UPGRADE_HINT
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
const defaultProbe: ServerVersionProbe = async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
return new MetaClient(http).serverVersion()
function buildDefaultProbe(insecure: boolean): ServerVersionProbe {
return async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure })
return new MetaClient(http).serverVersion()
}
}
export type EnforceOptions = {
readonly probe?: ServerVersionProbe
readonly store?: CompatStore
readonly forceFresh?: boolean
readonly insecure?: boolean
}
/**
@ -45,7 +48,7 @@ export async function enforceDifyVersion(
if (opts.forceFresh !== true && store.isFreshCompatible(host))
return undefined
const probe = opts.probe ?? defaultProbe
const probe = opts.probe ?? buildDefaultProbe(opts.insecure === true)
let server: ServerVersionResponse
try {
server = await probe(host)

View File

@ -6,7 +6,7 @@ import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
import { Registry } from '@/auth/hosts'
import { createHttpClient } from '@/http/client'
import { arch, platform } from '@/sys/index'
import { hostWithScheme, openAPIBase } from '@/util/host'
import { activeHostInfo, openAPIBase } from '@/util/host'
import { difyCompat, evaluateCompat } from './compat.js'
import { versionInfo } from './info.js'
@ -51,9 +51,11 @@ const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
return (await Registry.load()).resolveActive()
}
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
return new MetaClient(http).serverVersion()
function buildDefaultProbe(insecure: boolean): MetaProbe {
return async (endpoint) => {
const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure })
return new MetaClient(http).serverVersion()
}
}
function buildClientBlock(): ClientBlock {
@ -92,7 +94,6 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
}
const loadActive = opts.loadActive ?? defaultLoadActive
const probe = opts.probe ?? defaultProbe
let active: ActiveContext | undefined
let loadFailed = false
@ -112,7 +113,8 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
}
}
const endpoint = hostWithScheme(active.host, active.scheme)
const { host: endpoint, insecure } = activeHostInfo(active)
const probe = opts.probe ?? buildDefaultProbe(insecure)
let serverInfo: ServerVersionResponse | undefined
try {

View File

@ -279,11 +279,6 @@
"count": 1
}
},
"web/app/account/(commonLayout)/delete-account/components/check-email.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": {
"no-restricted-imports": {
"count": 1
@ -292,11 +287,6 @@
"count": 1
}
},
"web/app/account/oauth/authorize/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/account/oauth/authorize/page.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3406,30 +3396,11 @@
"count": 2
}
},
"web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx": {
"jsx-a11y/no-autofocus": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"jsx-a11y/no-autofocus": {
"count": 1
}
},
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -3571,14 +3542,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2

View File

@ -6,11 +6,32 @@ import AccountDropdown from '@/app/components/header/account-dropdown'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
const {
mockAppContextState,
mockPush,
mockLogout,
mockResetUser,
mockSetShowAccountSettingModal,
} = vi.hoisted(() => ({
mockAppContextState: {
userProfile: {
id: 'user-1',
name: 'Ada Lovelace',
email: 'ada@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
langGeniusVersionInfo: {
current_env: 'CLOUD',
current_version: '1.0.0',
latest_version: '1.1.0',
release_date: '',
release_notes: 'https://example.com/releases/1.1.0',
version: '1.0.0',
can_auto_update: false,
},
isCurrentWorkspaceOwner: false,
},
mockPush: vi.fn(),
mockLogout: vi.fn(),
mockResetUser: vi.fn(),
@ -28,21 +49,19 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
name: 'Ada Lovelace',
email: 'ada@example.com',
avatar_url: '',
},
langGeniusVersionInfo: {
current_version: '1.0.0',
latest_version: '1.1.0',
release_notes: 'https://example.com/releases/1.1.0',
},
isCurrentWorkspaceOwner: false,
}),
useAppContext: () => mockAppContextState,
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isEducationAccount: false,
@ -62,7 +81,8 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/service/use-common', () => ({
vi.mock('@/service/use-common', async importOriginal => ({
...await importOriginal<typeof import('@/service/use-common')>(),
useLogout: () => ({
mutateAsync: mockLogout,
}),

View File

@ -13,6 +13,7 @@ export type AppContextStateMockState = {
} | null
currentWorkspace?: {
id?: string
name?: string
} | null
isCurrentWorkspaceManager?: boolean
isCurrentWorkspaceOwner?: boolean
@ -22,6 +23,8 @@ export type AppContextStateMockState = {
isLoadingWorkspacePermissionKeys?: boolean
workspacePermissionKeys?: string[]
langGeniusVersionInfo?: LangGeniusVersionResponse
refreshUserProfile?: () => void
refreshCurrentWorkspace?: () => void
}
type AppContextStateAtomKind
@ -32,11 +35,14 @@ type AppContextStateAtomKind
| 'currentWorkspaceId'
| 'workspaceRoleFlags'
| 'isCurrentWorkspaceManager'
| 'isCurrentWorkspaceOwner'
| 'currentWorkspaceLoading'
| 'workspacePermissionKeys'
| 'workspacePermissionKeysLoading'
| 'langGeniusVersionInfo'
| 'langGeniusCurrentVersion'
| 'refreshUserProfile'
| 'refreshCurrentWorkspace'
type AppContextStateMockAtom = {
[APP_CONTEXT_STATE_ATOM_KIND]: AppContextStateAtomKind
@ -57,6 +63,7 @@ const defaultUserProfile = {
const defaultCurrentWorkspace = {
id: 'workspace-1',
name: 'Workspace',
}
const defaultLangGeniusVersionInfo = {
@ -109,11 +116,14 @@ export const createAppContextStateAtomMock = async (
currentWorkspaceIdAtom: createMockAtom('currentWorkspaceId'),
workspaceRoleFlagsAtom: createMockAtom('workspaceRoleFlags'),
isCurrentWorkspaceManagerAtom: createMockAtom('isCurrentWorkspaceManager'),
isCurrentWorkspaceOwnerAtom: createMockAtom('isCurrentWorkspaceOwner'),
currentWorkspaceLoadingAtom: createMockAtom('currentWorkspaceLoading'),
workspacePermissionKeysAtom: createMockAtom('workspacePermissionKeys'),
workspacePermissionKeysLoadingAtom: createMockAtom('workspacePermissionKeysLoading'),
langGeniusVersionInfoAtom: createMockAtom('langGeniusVersionInfo'),
langGeniusCurrentVersionAtom: createMockAtom('langGeniusCurrentVersion'),
refreshUserProfileAtom: createMockAtom('refreshUserProfile'),
refreshCurrentWorkspaceAtom: createMockAtom('refreshCurrentWorkspace'),
}
}
@ -162,6 +172,9 @@ export const createAppContextStateJotaiMock = async (
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceManager')
return state.isCurrentWorkspaceManager ?? false
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceOwner')
return state.isCurrentWorkspaceOwner ?? false
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceLoading')
return state.isLoadingCurrentWorkspace ?? false
@ -179,5 +192,22 @@ export const createAppContextStateJotaiMock = async (
throw new Error(`Unsupported app context state atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`)
},
useSetAtom: (atom: unknown) => {
if (!isAppContextStateMockAtom(atom))
return actual.useSetAtom(atom as Parameters<typeof actual.useSetAtom>[0])
if (!appContextStateMockRegistry)
throw new Error('App context state atom mock is not initialized')
const state = appContextStateMockRegistry.getState()
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'refreshUserProfile')
return state.refreshUserProfile ?? (() => {})
if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'refreshCurrentWorkspace')
return state.refreshCurrentWorkspace ?? (() => {})
throw new Error(`Unsupported app context state write atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`)
},
}
}

View File

@ -1,9 +1,10 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useAppContext } from '@/context/app-context'
import { userProfileEmailAtom } from '@/context/app-context-state'
import Link from '@/next/link'
import { useSendDeleteAccountEmail } from '../state'
@ -14,7 +15,7 @@ type DeleteAccountProps = {
export default function CheckEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const [userInputEmail, setUserInputEmail] = useState('')
const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail()
@ -45,7 +46,7 @@ export default function CheckEmail(props: DeleteAccountProps) {
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant="primary" onClick={handleConfirm}>{t('account.sendVerificationButton', { ns: 'common' })}</Button>
<Button className="w-full" disabled={userInputEmail !== userProfileEmail || isSendingEmail} loading={isSendingEmail} variant="primary" onClick={handleConfirm}>{t('account.sendVerificationButton', { ns: 'common' })}</Button>
<Button className="w-full" onClick={props.onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</>

View File

@ -3,9 +3,10 @@ import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { userProfileEmailAtom } from '@/context/app-context-state'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import { useDeleteAccountFeedback } from '../state'
@ -17,7 +18,7 @@ type DeleteAccountProps = {
export default function FeedBack(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const router = useRouter()
const [userFeedback, setUserFeedback] = useState('')
const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
@ -35,12 +36,12 @@ export default function FeedBack(props: DeleteAccountProps) {
const handleSubmit = useCallback(async () => {
try {
await sendFeedback({ feedback: userFeedback, email: userProfile.email })
await sendFeedback({ feedback: userFeedback, email: userProfileEmail })
props.onConfirm()
await handleSuccess()
}
catch (error) { console.error(error) }
}, [handleSuccess, userFeedback, sendFeedback, userProfile, props])
}, [handleSuccess, userFeedback, sendFeedback, userProfileEmail, props])
const handleSkip = useCallback(() => {
props.onCancel()

View File

@ -1,60 +1,40 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
export default function SignInLayout({ children }: any) {
type Props = {
children: ReactNode
}
const copyrightYear = new Date().getFullYear()
export default function OAuthAuthorizeLayout({ children }: Props) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle('')
// Probe login state. 401 stays as `error` (not thrown) so this layout can render
// the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx.
// (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.)
const { isPending, data: userResp, error } = useQuery({
...userProfileQueryOptions(),
throwOnError: err => !isLegacyBase401(err),
})
const isLoggedIn = !!userResp && !error
if (isPending) {
return (
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
<Loading />
</div>
)
}
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 items-center 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 flex-col md:w-[400px]">
{isLoggedIn
? (
<AppContextProvider>
{children}
</AppContextProvider>
)
: children}
</div>
<div className="flex min-h-screen w-full justify-center bg-background-default-burn p-6">
<div className="flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle">
<Header />
<div className="flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]">
<div className="flex flex-col md:w-[400px]">
{children}
</div>
{systemFeatures.branding.enabled === false && (
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
©
{' '}
{new Date().getFullYear()}
{' '}
LangGenius, Inc. All rights reserved.
</div>
)}
</div>
{systemFeatures.branding.enabled === false && (
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
©
{' '}
{copyrightYear}
{' '}
LangGenius, Inc. All rights reserved.
</div>
)}
</div>
</>
</div>
)
}

View File

@ -43,6 +43,9 @@ vi.mock('@/app/components/base/theme-switcher', () => ({
const { mockSetTheme } = vi.hoisted(() => ({
mockSetTheme: vi.fn(),
}))
const mockAppContextState = vi.hoisted(() => ({
current: undefined as AppContextValue | undefined,
}))
vi.mock('next-themes', () => ({
useTheme: () => ({
@ -55,6 +58,16 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {})
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
@ -63,7 +76,8 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
vi.mock('@/service/use-common', async importOriginal => ({
...await importOriginal<typeof import('@/service/use-common')>(),
useLogout: vi.fn(),
}))
@ -150,6 +164,11 @@ const baseAppContextValue: AppContextValue = {
workspacePermissionKeys: [],
}
const setAppContextValue = (value: AppContextValue) => {
mockAppContextState.current = value
vi.mocked(useAppContext).mockReturnValue(value)
}
describe('AccountDropdown', () => {
const mockPush = vi.fn()
const mockLogout = vi.fn()
@ -170,7 +189,7 @@ describe('AccountDropdown', () => {
mockConfig.IS_CLOUD_EDITION = false
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
setAppContextValue(baseAppContextValue)
vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: false,
plan: { type: Plan.sandbox },
@ -267,7 +286,7 @@ describe('AccountDropdown', () => {
it('should show Compliance in Cloud Edition for workspace owner', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
...baseAppContextValue,
userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
isCurrentWorkspaceOwner: true,
@ -286,7 +305,7 @@ describe('AccountDropdown', () => {
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
// Arrange
mockConfig.IS_CLOUD_EDITION = true
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
...baseAppContextValue,
isCurrentWorkspaceOwner: false,
})
@ -382,7 +401,7 @@ describe('AccountDropdown', () => {
describe('Version Indicators', () => {
it('should show orange indicator when version is not latest', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
...baseAppContextValue,
userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
langGeniusVersionInfo: {
@ -402,7 +421,7 @@ describe('AccountDropdown', () => {
it('should show green indicator when version is latest', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
...baseAppContextValue,
userProfile: { ...baseAppContextValue.userProfile, name: 'User' },
langGeniusVersionInfo: {

View File

@ -10,12 +10,13 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { isCurrentWorkspaceOwnerAtom, langGeniusVersionInfoAtom, userProfileAtom } from '@/context/app-context-state'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -119,7 +120,9 @@ export function DefaultMenuContent({
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { t } = useTranslation()
const docLink = useDocLink()
const { userProfile, langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()

View File

@ -8,11 +8,12 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import { useSetEducationExpiredHasNoticed, useSetEducationReverifyHasNoticed, useSetEducationReverifyPrevExpireAt } from '@/app/education-apply/storage'
import { useAppContext } from '@/context/app-context'
import { langGeniusVersionInfoAtom, userProfileAtom } from '@/context/app-context-state'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import AccountAbout from '../account-about'
@ -37,7 +38,8 @@ export default function AppSelector({
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const { t } = useTranslation()
const { userProfile, langGeniusVersionInfo } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const clearEducationReverifyPrevExpireAt = useSetEducationReverifyPrevExpireAt()
const clearEducationReverifyHasNoticed = useSetEducationReverifyHasNoticed()
const clearEducationExpiredHasNoticed = useSetEducationExpiredHasNoticed()

View File

@ -16,11 +16,12 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useAtomValue } from 'jotai'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import Link from '@/next/link'
@ -87,7 +88,7 @@ export function MainNavMenuContent({
onLogout,
}: MainNavMenuContentProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()

View File

@ -15,6 +15,18 @@ vi.mock('@/context/app-context', () => ({
})),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mocks.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
const rule: AccessPolicyWithBindings = {
policy: {
id: 'app-rule-1',

View File

@ -3,11 +3,12 @@
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue } from 'jotai'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { hasPermission } from '@/utils/permission'
import AccessRuleRow from './access-rule-row'
@ -46,7 +47,7 @@ const AccessRuleSection = ({
const [expanded, setExpanded] = useState(defaultExpanded)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManage = hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const ruleCount = totalCount ?? rules.length

View File

@ -24,6 +24,18 @@ vi.mock('@/context/app-context', () => ({
})),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.current,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/client', () => ({
consoleQuery: {
apiBasedExtension: {

View File

@ -2,11 +2,12 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
import { Empty } from './empty'
@ -51,7 +52,7 @@ export function ApiBasedExtensionPage({
layout,
}: ApiBasedExtensionPageProps = {}) {
const { t } = useTranslation()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManage = hasPermission(workspacePermissionKeys, 'api_extension.manage')
const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions())
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)

View File

@ -4,6 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BillingPage from '@/app/components/billing/billing-page'
@ -12,7 +13,7 @@ import {
ACCOUNT_SETTING_TAB,
} from '@/app/components/header/account-setting/constants'
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { useAppContext } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -54,7 +55,7 @@ export default function AccountSetting({
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { workspacePermissionKeys } = useAppContext()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)

View File

@ -14,7 +14,19 @@ import { useUpdateRolesOfMember } from '@/service/access-control/use-member-role
import { useMembers } from '@/service/use-common'
import MembersPage from '../index'
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
vi.mock('@/context/app-context')
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context')
vi.mock('@/hooks/use-format-time-from-now')
vi.mock('@/service/access-control/use-member-roles')
@ -41,6 +53,11 @@ const createRole = (overrides: Partial<Role>): Role => ({
...overrides,
})
const setAppContextValue = (value: AppContextValue) => {
mockAppContextState.current = value
vi.mocked(useAppContext).mockReturnValue(value)
}
vi.mock('../edit-workspace-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
@ -181,7 +198,7 @@ describe('MembersPage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: true,
@ -289,7 +306,7 @@ describe('MembersPage', () => {
})
it('should hide manager controls for non-owner non-manager users', () => {
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
@ -390,7 +407,7 @@ describe('MembersPage', () => {
})
it('should show invite button when user is manager but not owner', () => {
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
@ -405,7 +422,7 @@ describe('MembersPage', () => {
})
it('should allow admins to operate other non-owner members only', () => {
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
@ -483,7 +500,7 @@ describe('MembersPage', () => {
})
it('should render role badge names from account roles', () => {
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
@ -551,7 +568,7 @@ describe('MembersPage', () => {
it('should not allow assigning roles from member details when target is current user', async () => {
const user = userEvent.setup()
vi.mocked(useAppContext).mockReturnValue({
setAppContextValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,

View File

@ -8,6 +8,16 @@ import { useWorkspacePermissions } from '@/service/use-workspace'
import InviteButton from '../invite-button'
vi.mock('@/context/app-context')
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
currentWorkspace: { id: 'workspace-id' },
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-workspace')
describe('InviteButton', () => {

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import type { AppContextValue } from '@/context/app-context'
import { render } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import EditWorkspaceModal from '../index'
@ -10,6 +11,9 @@ type DialogProps = {
}
let latestOnOpenChange: DialogProps['onOpenChange']
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
@ -29,14 +33,26 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
describe('EditWorkspaceModal dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
vi.mocked(useAppContext).mockReturnValue({
const appContextValue = {
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: true,
} as never)
} as never
mockAppContextState.current = appContextValue
vi.mocked(useAppContext).mockReturnValue(appContextValue)
})
it('should only call onCancel when the dialog requests closing', () => {

View File

@ -10,10 +10,21 @@ import EditWorkspaceModal from '../index'
const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
const getSaveButton = () => screen.getByRole('button', { name: /operation\.(save|saving)/i })
vi.mock('@/context/app-context')
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/common')
vi.mock('@langgenius/dify-ui/toast', () => ({
default: {
@ -34,10 +45,12 @@ describe('EditWorkspaceModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
const appContextValue = {
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: true,
} as unknown as AppContextValue)
} as unknown as AppContextValue
mockAppContextState.current = appContextValue
vi.mocked(useAppContext).mockReturnValue(appContextValue)
})
afterEach(() => {

View File

@ -2,11 +2,12 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useAppContext } from '@/context/app-context'
import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom } from '@/context/app-context-state'
import { updateWorkspaceInfo } from '@/service/common'
type IEditWorkspaceModalProps = {
@ -14,7 +15,8 @@ type IEditWorkspaceModalProps = {
}
const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => {
const { t } = useTranslation()
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const [name, setName] = useState<string>(currentWorkspace.name)
const [isSubmitting, setIsSubmitting] = useState(false)
const inputId = useId()
@ -82,7 +84,6 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => {
</label>
<Input
id={inputId}
autoFocus
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {

View File

@ -4,12 +4,13 @@ import type { InvitationResult, Member } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useAppContext } from '@/context/app-context'
import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom, userProfileEmailAtom, workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useLocale } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -30,7 +31,10 @@ const MembersPage = () => {
const locale = useLocale()
const language = getAccessControlTemplateLanguage(locale)
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, workspacePermissionKeys } = useAppContext()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { data, refetch } = useMembers(language)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [inviteModalVisible, setInviteModalVisible] = useState(false)
@ -162,7 +166,7 @@ const MembersPage = () => {
key={account.id}
member={account}
roles={account.roles}
isCurrentUser={userProfile.email === account.email}
isCurrentUser={userProfileEmail === account.email}
canManage={canManageMembers}
canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace}
allowMultipleRoles={systemFeatures.rbac_enabled}
@ -213,7 +217,7 @@ const MembersPage = () => {
canAssignRoles={
canManageMembers
&& detailsMember.role !== 'owner'
&& userProfile.email !== detailsMember.email
&& userProfileEmail !== detailsMember.email
}
allowMultipleRoles={systemFeatures.rbac_enabled}
onClose={handleCloseDetails}

View File

@ -1,9 +1,10 @@
import { Button } from '@langgenius/dify-ui/button'
import { RiUserAddLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { currentWorkspaceIdAtom } from '@/context/app-context-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useWorkspacePermissions } from '@/service/use-workspace'
@ -14,9 +15,9 @@ type InviteButtonProps = {
const InviteButton = (props: InviteButtonProps) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled)
const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspaceId, systemFeatures.branding.enabled)
if (systemFeatures.branding.enabled) {
if (isFetchingWorkspacePermissions) {
return <Loading />

View File

@ -11,8 +11,19 @@ import TransferOwnershipModal from '../index'
const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
vi.mock('@/context/app-context')
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/common')
vi.mock('@/service/use-common')
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -40,10 +51,12 @@ describe('TransferOwnershipModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
const appContextValue = {
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
userProfile: { email: 'owner@example.com', id: 'owner-id' },
} as unknown as AppContextValue)
} as unknown as AppContextValue
mockAppContextState.current = appContextValue
vi.mocked(useAppContext).mockReturnValue(appContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [] },

View File

@ -1,11 +1,12 @@
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useAppContext } from '@/context/app-context'
import { currentWorkspaceAtom, userProfileAtom } from '@/context/app-context-state'
import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common'
import MemberSelector from './member-selector'
@ -13,15 +14,22 @@ type Props = Readonly<{
show: boolean
onClose: () => void
}>
enum STEP {
start = 'start',
verify = 'verify',
transfer = 'transfer',
const STEP = {
start: 'start',
verify: 'verify',
transfer: 'transfer',
}
type Step = typeof STEP[keyof typeof STEP]
const getErrorMessage = (error: unknown) => {
return error instanceof Error ? error.message : ''
}
const TransferOwnershipModal = ({ onClose, show }: Props) => {
const { t } = useTranslation()
const { currentWorkspace, userProfile } = useAppContext()
const [step, setStep] = useState<STEP>(STEP.start)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const userProfile = useAtomValue(userProfileAtom)
const [step, setStep] = useState<Step>(STEP.start)
const [code, setCode] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
@ -71,7 +79,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
}
}
catch (error) {
toast.error(`Error verifying email: ${error ? (error as any).message : ''}`)
toast.error(`Error verifying email: ${getErrorMessage(error)}`)
}
}
const sendCodeToOriginEmail = async () => {
@ -96,7 +104,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
globalThis.location.reload()
}
catch (error) {
toast.error(`Error ownership transfer: ${error ? (error as any).message : ''}`)
toast.error(`Error ownership transfer: ${getErrorMessage(error)}`)
}
finally {
setIsTransfer(false)

View File

@ -22,6 +22,18 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: mockPlanType },

View File

@ -11,6 +11,18 @@ vi.mock('@/context/app-context', () => ({
selector({ workspacePermissionKeys: mockWorkspacePermissionKeys }),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (selector: (state: { setShowModelLoadBalancingModal: typeof mockSetShowModelLoadBalancingModal }) => unknown) =>
selector({ setShowModelLoadBalancingModal: mockSetShowModelLoadBalancingModal }),

View File

@ -7,6 +7,7 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
@ -14,7 +15,7 @@ import {
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { IS_CE_EDITION } from '@/config'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useProviderContextSelector } from '@/context/provider-context'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import { renderI18nObject } from '@/i18n-config'
@ -68,7 +69,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
}))
const hasModelList = hasFetchedModelList && !!modelList.length
const showCollapsedSection = !expanded || !hasFetchedModelList
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()

View File

@ -4,12 +4,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
import { Switch } from '@langgenius/dify-ui/switch'
import { useQueryClient } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useAtomValue } from 'jotai'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { disableModel, enableModel } from '@/service/common'
@ -32,7 +33,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
const { t } = useTranslation()
const { plan } = useProviderContext()
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const { workspacePermissionKeys } = useAppContext()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()

View File

@ -4,13 +4,14 @@ import type {
ModelItem,
ModelProvider,
} from '../declarations'
import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useModalContextSelector } from '@/context/modal-context'
import { hasPermission } from '@/utils/permission'
import {
@ -33,7 +34,7 @@ const ModelList: FC<ModelListProps> = ({
}) => {
const { t } = useTranslation()
const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel)
const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal)
@ -58,13 +59,14 @@ const ModelList: FC<ModelListProps> = ({
{t('modelProvider.modelsNum', { ns: 'common', num: models.length })}
<span className="mr-0.5 i-ri-arrow-right-s-line size-4 rotate-90" />
</span>
<span
className="hidden h-6 cursor-pointer items-center rounded-lg bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex"
<button
type="button"
className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex"
onClick={() => onCollapse()}
>
{t('modelProvider.modelsNum', { ns: 'common', num: models.length })}
<span className="mr-0.5 i-ri-arrow-right-s-line size-4 rotate-90" />
</span>
</button>
</span>
{
isConfigurable && canConfigureModels && (

View File

@ -40,6 +40,18 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
textGenerationModelList: [],

View File

@ -12,10 +12,11 @@ import {
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { useAppContext } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useProviderContext } from '@/context/provider-context'
import { updateDefaultModel } from '@/service/common'
import { hasPermission } from '@/utils/permission'
@ -68,7 +69,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
onOpenMarketplace,
}) => {
const { t } = useTranslation()
const { workspacePermissionKeys } = useAppContext()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { textGenerationModelList } = useProviderContext()
const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const updateModelList = useUpdateModelList()

View File

@ -26,6 +26,18 @@ vi.mock('@/context/app-context', () => ({
} as AppContextValue)),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mocks.workspacePermissionKeys,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/access-control/use-workspace-roles', () => ({
useCreateWorkspaceRole: vi.fn(),
useUpdateWorkspaceRole: vi.fn(),

View File

@ -4,9 +4,10 @@ import type { RoleModalMode, submitRoleData } from './role-modal'
import type { Role } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useLocale } from '@/context/i18n'
import { getAccessControlTemplateLanguage } from '@/i18n-config/language'
import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles'
@ -32,7 +33,7 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => {
const [modalState, setModalState] = useState<ModalState>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale])

View File

@ -36,6 +36,18 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/access-control/use-workspace-roles', () => ({
useCopyWorkspaceRole: () => ({
mutate: mockCopyRole,

View File

@ -18,10 +18,11 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useCopyWorkspaceRole, useDeleteWorkspaceRole } from '@/service/access-control/use-workspace-roles'
import { hasPermission } from '@/utils/permission'
import { CopyMembersConfirmDialog } from './copy-members-confirm-dialog'
@ -44,7 +45,7 @@ const RowMenu = ({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showCopyMembersConfirm, setShowCopyMembersConfirm] = useState(false)
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManageRoles = hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const handleView = useCallback(() => onView?.(role), [onView, role])

View File

@ -75,6 +75,19 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: mockUserProfile,
refreshUserProfile: mockMutateUserProfile,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))

View File

@ -2,10 +2,11 @@
import type { Locale } from '@/i18n-config'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { refreshUserProfileAtom, userProfileAtom } from '@/context/app-context-state'
import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language'
@ -35,7 +36,8 @@ const isThemeOption = (value: string): value is ThemeOption => {
export default function PreferencePage() {
const locale = useLocale()
const { userProfile, mutateUserProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
const [editing, setEditing] = useState(false)
const { t } = useTranslation()
const router = useRouter()
@ -77,7 +79,7 @@ export default function PreferencePage() {
try {
await updateUserProfile({ url, body: { [bodyKey]: item.value } })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
mutateUserProfile()
refreshUserProfile()
}
catch (e) {
toast.error((e as Error).message)

View File

@ -7,12 +7,13 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { useMutationPluginAutoUpgradeSettings, usePluginAutoUpgradeSettings } from '@/service/use-plugins'
import UpdateSettingDialogForm from './update-setting-dialog-form'
@ -26,7 +27,7 @@ const UpdateSettingDialog = ({
disabled = false,
}: Props) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const timezone = userProfile.timezone || 'UTC'
const {
data: autoUpgradeSetting,

View File

@ -4,10 +4,24 @@ import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
import EnvNav from '../index'
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
describe('EnvNav', () => {
const mockUseAppContext = vi.mocked(useAppContext)
@ -16,33 +30,39 @@ describe('EnvNav', () => {
})
it('should render null when environment is PRODUCTION', () => {
mockUseAppContext.mockReturnValue({
const appContextValue = {
langGeniusVersionInfo: {
current_env: 'PRODUCTION',
},
} as unknown as AppContextValue)
} as unknown as AppContextValue
mockAppContextState.current = appContextValue
mockUseAppContext.mockReturnValue(appContextValue)
const { container } = render(<EnvNav />)
expect(container.firstChild).toBeNull()
})
it('should render TESTING tag and icon when environment is TESTING', () => {
mockUseAppContext.mockReturnValue({
const appContextValue = {
langGeniusVersionInfo: {
current_env: 'TESTING',
},
} as unknown as AppContextValue)
} as unknown as AppContextValue
mockAppContextState.current = appContextValue
mockUseAppContext.mockReturnValue(appContextValue)
render(<EnvNav />)
expect(screen.getByText('common.environment.testing')).toBeInTheDocument()
})
it('should render DEVELOPMENT tag and icon when environment is DEVELOPMENT', () => {
mockUseAppContext.mockReturnValue({
const appContextValue = {
langGeniusVersionInfo: {
current_env: 'DEVELOPMENT',
},
} as unknown as AppContextValue)
} as unknown as AppContextValue
mockAppContextState.current = appContextValue
mockUseAppContext.mockReturnValue(appContextValue)
render(<EnvNav />)
expect(

View File

@ -1,9 +1,10 @@
'use client'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { TerminalSquare } from '@/app/components/base/icons/src/vender/solid/development'
import { Beaker02 } from '@/app/components/base/icons/src/vender/solid/education'
import { useAppContext } from '@/context/app-context'
import { langGeniusVersionInfoAtom } from '@/context/app-context-state'
const headerEnvClassName: { [k: string]: string } = {
DEVELOPMENT: 'bg-[#FEC84B] border-[#FDB022] text-[#93370D]',
@ -12,7 +13,7 @@ const headerEnvClassName: { [k: string]: string } = {
const EnvNav = () => {
const { t } = useTranslation()
const { langGeniusVersionInfo } = useAppContext()
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
if (!showEnvTag)

View File

@ -30,6 +30,9 @@ const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoist
mockToastSuccess: vi.fn(),
mockIsAgentV2Enabled: vi.fn(() => true),
}))
const mockAppContextState = vi.hoisted(() => ({
current: undefined as AppContextValue | undefined,
}))
vi.mock('@/features/agent-v2/feature-flag', () => ({
isAgentV2Enabled: () => mockIsAgentV2Enabled(),
@ -40,6 +43,16 @@ vi.mock('@/context/app-context', () => ({
useSelector: vi.fn(),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {})
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
@ -240,6 +253,7 @@ const renderMainNav = (
const queryClient = createTestQueryClient()
const getMockAppContext = useAppContext as Mock
const currentAppContext = getMockAppContext() as AppContextValue
mockAppContextState.current = currentAppContext
queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), currentAppContext.currentWorkspace)
queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces })
const resolvedSystemFeatures = {
@ -287,6 +301,7 @@ describe('MainNav', () => {
refresh: vi.fn(),
})
;(useAppContext as Mock).mockReturnValue(appContextValue)
mockAppContextState.current = appContextValue
;(useAppContextSelector as Mock).mockImplementation((selector: (state: AppContextValue) => unknown) => selector((useAppContext as Mock)() as AppContextValue))
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,

View File

@ -29,6 +29,18 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
const React = await vi.importActual<typeof import('react')>('react')

View File

@ -6,6 +6,13 @@ import { CommentIcon } from './comment-icon'
type Position = { x: number, y: number }
let mockUserId = 'user-1'
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'User',
avatar_url: 'avatar',
},
}))
const mockFlowToScreenPosition = vi.fn((position: Position) => position)
const mockScreenToFlowPosition = vi.fn((position: Position) => position)
@ -25,13 +32,28 @@ vi.mock('reactflow', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
...mockAppContextState.userProfile,
id: mockUserId,
name: 'User',
avatar_url: 'avatar',
},
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
...mockAppContextState,
userProfile: {
...mockAppContextState.userProfile,
id: mockUserId,
},
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/base/user-avatar-list', () => ({
UserAvatarList: ({ users }: { users: Array<{ id: string }> }) => (
<div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div>

View File

@ -2,10 +2,11 @@
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import type { WorkflowCommentList } from '@/app/components/workflow/comment/types'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useReactFlow, useViewport } from 'reactflow'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { useAppContext } from '@/context/app-context'
import { userProfileIdAtom } from '@/context/app-context-state'
import CommentPreview from './comment-preview'
type CommentIconProps = {
@ -18,8 +19,8 @@ type CommentIconProps = {
export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick, isActive = false, onPositionUpdate }) => {
const { flowToScreenPosition, screenToFlowPosition } = useReactFlow()
const viewport = useViewport()
const { userProfile } = useAppContext()
const isAuthor = comment.created_by_account?.id === userProfile?.id
const currentUserId = useAtomValue(userProfileIdAtom)
const isAuthor = comment.created_by_account?.id === currentUserId
const [showPreview, setShowPreview] = useState(false)
const [dragPosition, setDragPosition] = useState<{ x: number, y: number } | null>(null)
const [isDragging, setIsDragging] = useState(false)

View File

@ -18,6 +18,13 @@ const stableT = (key: string, options?: { ns?: string }) => (
)
let mentionInputProps: MentionInputProps | null = null
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'Alice',
avatar_url: 'avatar',
},
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -27,14 +34,20 @@ vi.mock('react-i18next', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
avatar_url: 'avatar',
},
userProfile: mockAppContextState.userProfile,
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@langgenius/dify-ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,

View File

@ -1,9 +1,10 @@
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { MentionInput } from './mention-input'
type CommentInputProps = {
@ -30,7 +31,7 @@ export const CommentInput: FC<CommentInputProps> = memo(({
}) => {
const [content, setContent] = useState('')
const { t } = useTranslation()
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const dragStateRef = useRef<{
pointerId: number | null
startPointerX: number

View File

@ -4,6 +4,13 @@ import { CommentThread } from './thread'
const mockSetCommentPreviewHovering = vi.hoisted(() => vi.fn())
const mockFlowToScreenPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x, y })))
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'Alice',
avatar_url: 'alice.png',
},
}))
const storeState = vi.hoisted(() => ({
mentionableUsersCache: {
@ -33,14 +40,20 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
avatar_url: 'alice.png',
},
userProfile: mockAppContextState.userProfile,
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('reactflow', () => ({
useReactFlow: () => ({
flowToScreenPosition: mockFlowToScreenPosition,

View File

@ -11,13 +11,14 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow, useViewport } from 'reactflow'
import Divider from '@/app/components/base/divider'
import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom, userProfileIdAtom } from '@/context/app-context-state'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useParams } from '@/next/navigation'
import { useStore } from '../store'
@ -52,8 +53,7 @@ const ThreadMessage: FC<{
className?: string
}> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionableNames, className }) => {
const { formatTimeFromNow } = useFormatTimeFromNow()
const { userProfile } = useAppContext()
const currentUserId = userProfile?.id
const currentUserId = useAtomValue(userProfileIdAtom)
const isCurrentUser = authorId === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(authorId)
@ -175,7 +175,8 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
const appId = params.appId as string
const { flowToScreenPosition } = useReactFlow()
const viewport = useViewport()
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const currentUserId = userProfile.id
const { t } = useTranslation()
const [replyContent, setReplyContent] = useState('')
const [editingCommentContent, setEditingCommentContent] = useState('')
@ -369,7 +370,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
}, [editingReply, onReplyEdit])
const replies = comment.replies || []
const isOwnComment = comment.created_by_account?.id === userProfile?.id
const isOwnComment = comment.created_by_account?.id === currentUserId
const messageListRef = useRef<HTMLDivElement>(null)
const previousReplyCountRef = useRef<number | undefined>(undefined)
const previousCommentIdRef = useRef<string | undefined>(undefined)
@ -604,7 +605,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
<div className="mt-2 space-y-3 pt-3">
{replies.map((reply) => {
const isReplyEditing = editingReply?.id === reply.id
const isOwnReply = reply.created_by_account?.id === userProfile?.id
const isOwnReply = reply.created_by_account?.id === currentUserId
return (
<div
key={reply.id}

View File

@ -13,6 +13,22 @@ const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
let mockPlanType = Plan.professional
let mockEnableBilling = true
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: '',
name: '',
},
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({

View File

@ -25,6 +25,22 @@ const mockViewHistory = vi.fn()
let mockNodesReadOnly = false
let mockTheme: 'light' | 'dark' = 'light'
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: '',
name: '',
},
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('reactflow', () => ({
useNodes: () => mockUseNodes(),

View File

@ -2,6 +2,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiHistoryLine } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import {
useCallback,
useState,
@ -9,7 +10,7 @@ import {
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow'
@ -39,7 +40,7 @@ const HeaderInRestoring = ({
const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false)
const { plan, enableBilling } = useProviderContext()
const workflowStore = useWorkflowStore()
const userProfile = useAppContextSelector(s => s.userProfile)
const userProfile = useAtomValue(userProfileAtom)
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const {

View File

@ -9,10 +9,11 @@ import {
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useAtomValue } from 'jotai'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow } from 'reactflow'
import { useAppContext } from '@/context/app-context'
import { userProfileIdAtom } from '@/context/app-context-state'
import { getAvatar } from '@/service/common'
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
import { getUserColor } from '../collaboration/utils/user-color'
@ -54,12 +55,11 @@ const OnlineUsers = () => {
const { t } = useTranslation()
const appId = useStore(s => s.appId)
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string)
const { userProfile } = useAppContext()
const currentUserId = useAtomValue(userProfileIdAtom)
const reactFlow = useReactFlow()
const [dropdownOpen, setDropdownOpen] = useState(false)
const avatarUrls = useAvatarUrls(onlineUsers || [])
const currentUserId = userProfile?.id
const fallbackUsername = t('comments.fallback.user', { ns: 'workflow' })
const currentUserSuffix = t('members.you', { ns: 'common' })

View File

@ -28,6 +28,14 @@ const commentsUpdateState = vi.hoisted(() => ({
const globalFeatureState = vi.hoisted(() => ({
enableCollaboration: true,
}))
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
@ -43,15 +51,20 @@ vi.mock('@/next/navigation', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
avatar_url: 'alice.png',
},
userProfile: mockAppContextState.userProfile,
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: {

View File

@ -1,9 +1,10 @@
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/app/components/workflow/comment/types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useReactFlow } from 'reactflow'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useParams } from '@/next/navigation'
import { consoleClient } from '@/service/client'
@ -67,7 +68,7 @@ export const useWorkflowComment = () => {
() => new Map(mentionableUsers.map(user => [user.id, user])),
[mentionableUsers],
)
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const { data: isCollaborationEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_collaboration_mode,

View File

@ -11,6 +11,25 @@ const mockHandleNodeIterationChildSizeChange = vi.fn()
const mockHandleNodeLoopChildSizeChange = vi.fn()
const mockUseNodeResizeObserver = vi.fn()
const mockUseCollaboration = vi.fn()
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
},
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),

View File

@ -13,6 +13,7 @@ import {
RiPlayLargeLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import {
cloneElement,
@ -69,7 +70,7 @@ import {
hasRetryNode,
isSupportCustomRunForm,
} from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { useAllBuiltInTools } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { FlowType } from '@/types/common'
@ -114,7 +115,7 @@ const BasePanel: FC<BasePanelProps> = ({
const { t } = useTranslation()
const language = useLanguage()
const appId = useStore(s => s.appId)
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const { isConnected, nodePanelPresence } = useCollaboration(appId as string)
const { showMessageLogModal } = useAppStore(useShallow(state => ({
showMessageLogModal: state.showMessageLogModal,

View File

@ -4,6 +4,7 @@ import type {
} from 'react'
import type { NodeProps } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue } from 'jotai'
import {
cloneElement,
memo,
@ -28,7 +29,7 @@ import {
NodeRunningStatus,
} from '@/app/components/workflow/types'
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { selectWorkflowNode } from '../../utils/node-navigation'
import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container'
@ -76,7 +77,7 @@ const BaseNode: FC<BaseNodeProps> = ({
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const appId = useStore(s => s.appId)
const { nodePanelPresence } = useCollaboration(appId as string)
const controlMode = useStore(s => s.controlMode)

View File

@ -3,7 +3,11 @@ import { fireEvent, render, screen } from '@testing-library/react'
import EmailConfigureModal from '../email-configure-modal'
const mockToastError = vi.hoisted(() => vi.fn())
const mockUseAppContextSelector = vi.hoisted(() => vi.fn())
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
email: 'owner@example.com',
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
@ -13,9 +17,19 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { email: string } }) => string) =>
mockUseAppContextSelector(selector),
selector({ userProfile: mockAppContextState.userProfile }),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('../mail-body-input', () => ({
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<textarea
@ -80,11 +94,6 @@ const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig =>
describe('human-input/delivery-method/email-configure-modal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContextSelector.mockImplementation(selector => selector({
userProfile: {
email: 'owner@example.com',
},
}))
})
it('should save a valid email configuration with updated values', () => {

View File

@ -18,15 +18,29 @@ type TestEmailSenderProps = {
jumpToEmailConfigModal: () => void
}
const mockUseAppContextSelector = vi.hoisted(() => vi.fn())
const mockEmailConfigureModal = vi.hoisted(() => vi.fn())
const mockTestEmailSender = vi.hoisted(() => vi.fn())
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
email: 'owner@example.com',
},
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { email: string } }) => string) =>
mockUseAppContextSelector(selector),
selector({ userProfile: mockAppContextState.userProfile }),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('../email-configure-modal', () => ({
default: (props: EmailConfigureModalProps) => {
mockEmailConfigureModal(props)
@ -107,11 +121,6 @@ const getMethodRow = (label: string) => {
describe('human-input/delivery-method/method-item', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContextSelector.mockImplementation(selector => selector({
userProfile: {
email: 'owner@example.com',
},
}))
})
it('should toggle and delete a webapp delivery method', () => {

View File

@ -21,6 +21,28 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
email: 'owner@example.com',
name: 'Owner',
},
currentWorkspace: {
id: 'workspace-1',
name: 'Product Team',
},
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
type RecordedRequest = {
url: string
method: string
@ -48,14 +70,11 @@ const renderWithProviders = (ui: ReactNode) => {
value={{
userProfile: {
...userProfilePlaceholder,
id: 'user-1',
email: 'owner@example.com',
name: 'Owner',
...mockAppContextState.userProfile,
},
currentWorkspace: {
...initialWorkspaceInfo,
id: 'workspace-1',
name: 'Product Team',
...mockAppContextState.currentWorkspace,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
@ -67,14 +86,11 @@ const renderWithProviders = (ui: ReactNode) => {
useSelector: selector => selector({
userProfile: {
...userProfilePlaceholder,
id: 'user-1',
email: 'owner@example.com',
name: 'Owner',
...mockAppContextState.userProfile,
},
currentWorkspace: {
...initialWorkspaceInfo,
id: 'workspace-1',
name: 'Product Team',
...mockAppContextState.currentWorkspace,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,

View File

@ -8,10 +8,11 @@ import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgeni
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBugLine } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { userProfileEmailAtom } from '@/context/app-context-state'
import MailBodyInput from './mail-body-input'
import Recipient from './recipient'
@ -35,7 +36,7 @@ const EmailConfigureModal = ({
availableNodes = [],
}: EmailConfigureModalProps) => {
const { t } = useTranslation()
const email = useAppContextWithSelector(s => s.userProfile.email)
const email = useAtomValue(userProfileEmailAtom)
const [recipients, setRecipients] = useState(config?.recipients || { whole_workspace: false, items: [] })
const [subject, setSubject] = useState(config?.subject || '')
const [body, setBody] = useState(config?.body || '{{#url#}}')

View File

@ -16,11 +16,12 @@ import {
RiRobot2Fill,
RiSendPlane2Line,
} from '@remixicon/react'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge/index'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { userProfileEmailAtom } from '@/context/app-context-state'
import { DeliveryMethodType } from '../../types'
import EmailConfigureModal from './email-configure-modal'
import TestEmailSender from './test-email-sender'
@ -51,7 +52,7 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({
readonly,
}) => {
const { t } = useTranslation()
const email = useAppContextWithSelector(s => s.userProfile.email)
const email = useAtomValue(userProfileEmailAtom)
const [isHovering, setIsHovering] = useState(false)
const [showEmailModal, setShowEmailModal] = useState(false)
const [showTestEmailModal, setShowTestEmailModal] = useState(false)

View File

@ -4,6 +4,10 @@ import Recipient from '../index'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockUseAppContext = vi.hoisted(() => vi.fn())
const mockUseMembers = vi.hoisted(() => vi.fn())
const mockAppContextState = vi.hoisted(() => ({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Dify\'s Lab' },
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
@ -13,6 +17,16 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-common', () => ({
useMembers: () => mockUseMembers(),
}))
@ -69,10 +83,7 @@ describe('Recipient', () => {
mockUseTranslation.mockReturnValue({
t: (key: string, options?: { workspaceName?: string }) => options?.workspaceName ?? key,
})
mockUseAppContext.mockReturnValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Dify\'s Lab' },
})
mockUseAppContext.mockReturnValue(mockAppContextState)
mockUseMembers.mockReturnValue({
data: {
accounts: [

View File

@ -3,9 +3,10 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
import { RiGroupLine } from '@remixicon/react'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { currentWorkspaceAtom, userProfileEmailAtom } from '@/context/app-context-state'
import { useMembers } from '@/service/use-common'
import EmailInput from './email-input'
import MemberSelector from './member-selector'
@ -22,7 +23,8 @@ const Recipient = ({
onChange,
}: Props) => {
const { t } = useTranslation()
const { userProfile, currentWorkspace } = useAppContext()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const { data: members } = useMembers()
const accounts = members?.accounts || []
@ -70,14 +72,14 @@ const Recipient = ({
<div className="w-[86px]">
<MemberSelector
value={data.items}
email={userProfile.email}
email={userProfileEmail}
list={accounts}
onSelect={handleMemberSelect}
/>
</div>
</div>
<EmailInput
email={userProfile.email}
email={userProfileEmail}
value={data.items}
list={accounts}
onDelete={handleDelete}

View File

@ -10,6 +10,7 @@ import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgeni
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightSFill } from '@remixicon/react'
import { noop, unionBy } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@ -24,7 +25,7 @@ import {
isSystemVar,
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { InputVarType, VarType } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { currentWorkspaceAtom, userProfileEmailAtom } from '@/context/app-context-state'
import { useMembers } from '@/service/use-common'
import { useTestEmailSender } from '@/service/use-workflow'
import { getHumanInputFormDependencySelectors, isOutput } from '../../utils'
@ -123,7 +124,8 @@ const EmailSenderModal = ({
availableNodes = [],
}: EmailSenderModalProps) => {
const { t } = useTranslation()
const { userProfile, currentWorkspace } = useAppContext()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const appDetail = useAppStore(state => state.appDetail)
const { mutateAsync: testEmailSender } = useTestEmailSender()
@ -236,7 +238,7 @@ const EmailSenderModal = ({
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugDone`}
ns="workflow"
components={{ email: <span className="system-md-semibold text-text-secondary"></span> }}
values={{ email: userProfile.email }}
values={{ email: userProfileEmail }}
/>
</div>
)}
@ -268,7 +270,7 @@ const EmailSenderModal = ({
<div className="mt-4">
<EmailInput
disabled
email={userProfile.email}
email={userProfileEmail}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
@ -308,7 +310,7 @@ const EmailSenderModal = ({
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`}
ns="workflow"
components={{ email: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ email: userProfile.email }}
values={{ email: userProfileEmail }}
/>
</div>
</>
@ -342,7 +344,7 @@ const EmailSenderModal = ({
<div className="mt-4">
<EmailInput
disabled
email={userProfile.email}
email={userProfileEmail}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}

View File

@ -141,6 +141,21 @@ vi.mock('@/context/app-context', () => ({
}),
}))
const mockAppContextState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/utils/permission', () => ({
DatasetACLPermission: {
Readonly: 'dataset.acl.readonly',

View File

@ -2,10 +2,11 @@
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state'
import { getDatasetACLCapabilities } from '@/utils/permission'
import Item from './dataset-item'
@ -29,8 +30,8 @@ const DatasetList: FC<Props> = ({
settingsModalHeight,
}) => {
const { t } = useTranslation()
const currentUserId = useAppContextSelector(s => s.userProfile?.id)
const workspacePermissionKeys = useAppContextSelector(s => s.workspacePermissionKeys)
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const handleRemove = useCallback((index: number) => {
return () => {

View File

@ -1,6 +1,7 @@
import type { NoteNodeType } from '../note-node/types'
import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import {
CUSTOM_NOTE_NODE,
} from '../note-node/constants'
@ -11,7 +12,7 @@ import { generateNewNode } from '../utils'
export const useOperator = () => {
const workflowStore = useWorkflowStore()
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const showAuthorStorage = useWorkflowNoteShowAuthorValue()
const handleAddNote = useCallback(() => {

View File

@ -7,6 +7,9 @@ const mockHandleCommentResolve = vi.hoisted(() => vi.fn())
const mockSetActiveCommentId = vi.hoisted(() => vi.fn())
const mockSetControlMode = vi.hoisted(() => vi.fn())
const mockSetShowResolvedComments = vi.hoisted(() => vi.fn())
const mockAppContextState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
}))
const commentFixtures: WorkflowCommentList[] = [
{
@ -70,10 +73,20 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { id: 'user-1' },
userProfile: mockAppContextState.userProfile,
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: WorkflowStoreSelectionState) => unknown) => selector({
activeCommentId: storeState.activeCommentId,

View File

@ -2,6 +2,7 @@ import type { WorkflowCommentList } from '@/app/components/workflow/comment/type
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
import { RiCheckboxCircleFill, RiCheckboxCircleLine, RiCheckLine, RiCloseLine, RiFilter3Line } from '@remixicon/react'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@ -9,7 +10,7 @@ import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment'
import { useStore } from '@/app/components/workflow/store'
import { ControlMode } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { userProfileIdAtom } from '@/context/app-context-state'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
const CommentsPanel = () => {
@ -29,16 +30,16 @@ const CommentsPanel = () => {
handleCommentIconClick(comment)
}, [handleCommentIconClick])
const { userProfile } = useAppContext()
const currentUserId = useAtomValue(userProfileIdAtom)
const filteredSorted = useMemo(() => {
let data = comments
if (!showResolvedComments)
data = data.filter(c => !c.resolved)
if (showOnlyMine)
data = data.filter(c => c.created_by === userProfile?.id)
data = data.filter(c => c.created_by === currentUserId)
return data
}, [comments, showOnlyMine, showResolvedComments, userProfile?.id])
}, [comments, currentUserId, showOnlyMine, showResolvedComments])
const handleResolve = useCallback(async (comment: WorkflowCommentList) => {
if (comment.resolved)

View File

@ -18,6 +18,12 @@ const mockEmitRestoreComplete = vi.fn()
const mockEmitWorkflowUpdate = vi.fn()
let mockPlanType = Plan.professional
let mockEnableBilling = true
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'test-user-id',
name: 'Test User',
},
}))
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id',
@ -60,9 +66,19 @@ type MockVersionHistoryItemProps = {
}
vi.mock('@/context/app-context', () => ({
useSelector: () => ({ id: 'test-user-id' }),
useSelector: () => mockAppContextState.userProfile,
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: { type: mockPlanType },

View File

@ -3,6 +3,7 @@ import type { VersionHistory } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,7 +11,7 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo
import Divider from '@/app/components/base/divider'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { useProviderContext } from '@/context/provider-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
@ -58,7 +59,7 @@ export const VersionHistoryPanel = ({
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const currentVersion = useStore(s => s.currentVersion)
const setCurrentVersion = useStore(s => s.setCurrentVersion)
const userProfile = useAppContextSelector(s => s.userProfile)
const userProfile = useAtomValue(userProfileAtom)
const configsMap = useHooksStore(s => s.configsMap)
const canImportExportDSL = useHooksStore(s => s.accessControl.canImportExportDSL)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)

View File

@ -7,6 +7,7 @@ import {
ContextMenuSeparator,
} from '@langgenius/dify-ui/context-menu'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import {
useCallback,
} from 'react'
@ -15,7 +16,7 @@ import { useStore as useReactFlowStore } from 'reactflow'
import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection'
import { canCreateAndModifySnippets } from '@/app/components/snippets/utils/permission'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { workspacePermissionKeysAtom } from '@/context/app-context-state'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
@ -235,7 +236,7 @@ export function SelectionContextmenu({
}) {
const { t } = useTranslation()
const { getNodesReadOnly } = useNodesReadOnly()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection')

View File

@ -142,11 +142,19 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => 'https://docs.example.com',
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (value: { currentWorkspace: { id: string } }) => string) => selector({
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
currentWorkspace: { id: 'workspace-123' },
}),
}))
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),

View File

@ -2,9 +2,10 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AgentTool } from '@/features/agent-v2/agent-composer/form-state'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import { API_PREFIX } from '@/config'
import { useSelector } from '@/context/app-context'
import { currentWorkspaceIdAtom } from '@/context/app-context-state'
import useTheme from '@/hooks/use-theme'
import {
useAllBuiltInTools,
@ -67,7 +68,7 @@ function createProviderMap(providers: ToolWithProvider[]) {
export function useAgentPromptToolIconResolver() {
const { theme } = useTheme()
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const { data: builtInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()

View File

@ -96,14 +96,22 @@ vi.mock('@/app/components/base/chat/chat/hooks', () => ({
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: {
avatar_url: '',
name: 'User',
},
}),
}))
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({

View File

@ -58,16 +58,23 @@ vi.mock('@/service/client', () => ({
},
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (state: { userProfile: { id: string, name: string, email: string } }) => T) =>
selector({
userProfile: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
}),
}))
vi.mock('@/context/app-context-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => ({
userProfile: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
}))
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
describe('AgentPreviewVersionsPanel', () => {
beforeEach(() => {

View File

@ -35,7 +35,7 @@ import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/comp
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useAppContext } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
@ -589,7 +589,7 @@ function AgentPreviewChatSession({
}) {
const { t } = useTranslation('agentV2')
const queryClient = useQueryClient()
const { userProfile } = useAppContext()
const userProfile = useAtomValue(userProfileAtom)
const prompt = useAtomValue(agentComposerPromptAtom)
const currentModel = useAtomValue(agentComposerModelAtom)
const config = useMemo(() => buildChatConfig({

View File

@ -2,9 +2,10 @@
import type { AgentVersionFilter } from './filter'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { userProfileAtom } from '@/context/app-context-state'
import { consoleQuery } from '@/service/client'
import { CurrentDraftItem } from './current-draft-item'
import { VersionFilter } from './filter'
@ -27,7 +28,7 @@ export function AgentPreviewVersionsPanel({
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tWorkflow } = useTranslation('workflow')
const userProfile = useAppContextSelector(state => state.userProfile)
const userProfile = useAtomValue(userProfileAtom)
const [filterValue, setFilterValue] = useState<AgentVersionFilter>('all')
const versionsQuery = useQuery(consoleQuery.agent.byAgentId.versions.get.queryOptions({
input: {