chore: improve current workspace summary (#40121)

Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai>
This commit is contained in:
非法操作andJingyi-Dify authored and GitHub committed 2026-08-07 03:08:55 +00:00
1 parent 4b2c5ac4ae
commit baba36d47d
48 files changed
+978 -776

No files matched your search

@@ -1,6 +1,12 @@
from libs.exception import BaseHTTPException
class CurrentWorkspaceArchivedError(BaseHTTPException):
error_code = "current_workspace_archived"
description = "The current workspace has been archived."
code = 409
class RepeatPasswordNotMatchError(BaseHTTPException):
error_code = "repeat_password_not_match"
description = "New password and repeat password does not match."
+53 -21
View File
@@ -7,9 +7,10 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound, Unauthorized
from werkzeug.exceptions import NotFound
import services
from configs import dify_config
from controllers.common.errors import (
FilenameNotExistsError,
FileTooLargeError,
@@ -28,6 +29,7 @@ from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import CurrentWorkspaceArchivedError
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
@@ -43,7 +45,7 @@ from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from machinery.context import RequestContext
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from models.account import Account, Tenant, TenantAccountRole, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.enterprise.enterprise_service import EnterpriseService
from services.file_service import FileService
@@ -105,6 +107,14 @@ class TenantInfoResponse(ResponseModel):
return to_timestamp(value)
class CurrentWorkspaceSummaryResponse(ResponseModel):
id: str
name: str
role: TenantAccountRole
plan: str | None
credits: int | None = Field(description="Remaining credits in the effective pool; -1 means unlimited.")
class TenantListItemResponse(ResponseModel):
id: str
name: str | None = None
@@ -201,6 +211,7 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
CurrentWorkspaceSummaryResponse,
TenantInfoResponse,
TenantListItemResponse,
TenantListResponse,
@@ -245,35 +256,31 @@ class WorkspaceListApi(Resource):
).model_dump(mode="json"), HTTPStatus.OK
@console_ns.route("/workspaces/current", endpoint="workspaces_current")
@console_ns.route("/info", endpoint="info") # Deprecated
class TenantApi(Resource):
@console_ns.route("/workspaces/current/summary")
class CurrentWorkspaceSummaryApi(Resource):
@console_ns.response(
HTTPStatus.OK,
"Success",
console_ns.models[CurrentWorkspaceSummaryResponse.__name__],
)
@console_ns.response(HTTPStatus.CONFLICT, "Current workspace is archived")
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantInfoResponse.__name__])
@with_current_user
@with_session
def post(self, session: Session, current_user: Account):
if request.path == "/info":
logger.warning("Deprecated URL /info was used.")
@with_session(write=False)
def get(self, session: Session, current_user: Account):
tenant = current_user.current_tenant
if not tenant:
raise ValueError("No current tenant")
if tenant.status == TenantStatus.ARCHIVE:
tenants = TenantService.get_join_tenants(current_user, session=session)
# if there is any tenant, switch to the first one
if len(tenants) > 0:
TenantService.switch_tenant(current_user, tenants[0].id, session=session)
tenant = tenants[0]
# else, raise Unauthorized
else:
raise Unauthorized("workspace is archived")
raise CurrentWorkspaceArchivedError()
return (
dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant, session=session)),
dump_response(
CurrentWorkspaceSummaryResponse,
WorkspaceService.get_current_workspace_summary(tenant, current_user.id, session=session),
),
HTTPStatus.OK,
)
@@ -308,6 +315,31 @@ class SwitchWorkspaceApi(Resource):
@console_ns.route("/workspaces/custom-config")
class CustomConfigWorkspaceApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceCustomConfigResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str):
tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session)
if tenant is None:
raise NotFound()
custom_config = tenant.custom_config_dict
replace_webapp_logo = (
f"{dify_config.FILES_URL}/files/workspaces/{tenant.id}/webapp-logo"
if custom_config.get("replace_webapp_logo")
else None
)
return dump_response(
WorkspaceCustomConfigResponse,
{
"remove_webapp_brand": custom_config.get("remove_webapp_brand", False),
"replace_webapp_logo": replace_webapp_logo,
},
)
@console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
+25 -14
View File
@@ -7037,13 +7037,6 @@ Request body:
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [ConsoleHumanInputFormSubmitResponse](#consolehumaninputformsubmitresponse)<br> |
### [POST] /info
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TenantInfoResponse](#tenantinforesponse)<br> |
### [GET] /installed-apps
#### Parameters
@@ -10069,13 +10062,6 @@ Returns information about why and where the workflow is paused.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TenantListResponse](#tenantlistresponse)<br> |
### [POST] /workspaces/current
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TenantInfoResponse](#tenantinforesponse)<br> |
### [GET] /workspaces/current/agent-provider/{provider_name}
Get specific agent provider details
@@ -12031,6 +12017,14 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
### [GET] /workspaces/current/summary
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [CurrentWorkspaceSummaryResponse](#currentworkspacesummaryresponse)<br> |
| 409 | Current workspace is archived | |
### [GET] /workspaces/current/tool-labels
#### Responses
@@ -12838,6 +12832,13 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Trigger providers retrieved successfully | **application/json**: [TriggerProviderListResponse](#triggerproviderlistresponse)<br> |
### [GET] /workspaces/custom-config
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceCustomConfigResponse](#workspacecustomconfigresponse)<br> |
### [POST] /workspaces/custom-config
#### Request Body
@@ -16619,6 +16620,16 @@ Model class for credential form schema.
| ---- | ---- | ----------- | -------- |
| CredentialType | string | | |
#### CurrentWorkspaceSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credits | integer | Remaining credits in the effective pool; -1 means unlimited. | Yes |
| id | string | | Yes |
| name | string | | Yes |
| plan | string | | Yes |
| role | [TenantAccountRole](#tenantaccountrole) | | Yes |
#### CustomConfigurationResponse
Model class for provider custom configuration response.
+25 -10
View File
@@ -325,26 +325,41 @@ class AccountService:
if account.status == AccountStatus.BANNED:
raise Unauthorized("Account is banned.")
current_tenant = session.scalar(
current_tenant_join = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account.id, TenantAccountJoin.current == True)
.limit(1)
)
if current_tenant:
account.set_tenant_id_with_session(current_tenant.tenant_id, session=session)
else:
available_ta = session.scalar(
if current_tenant_join is not None:
account.set_tenant_id_with_session(current_tenant_join.tenant_id, session=session)
has_valid_current_tenant = (
current_tenant_join is not None
and account.current_tenant is not None
and account.current_tenant.status == TenantStatus.NORMAL
)
if not has_valid_current_tenant:
if current_tenant_join is not None:
current_tenant_join.current = False
available_tenant_join = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account.id)
.join(Tenant, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account.id,
Tenant.status == TenantStatus.NORMAL,
)
.order_by(TenantAccountJoin.id.asc())
.limit(1)
)
if not available_ta:
if available_tenant_join is None:
if current_tenant_join is not None:
session.commit()
return None
account.set_tenant_id_with_session(available_ta.tenant_id, session=session)
available_ta.current = True
available_ta.last_opened_at = naive_utc_now()
account.set_tenant_id_with_session(available_tenant_join.tenant_id, session=session)
available_tenant_join.current = True
available_tenant_join.last_opened_at = naive_utc_now()
session.commit()
AccountService._refresh_account_last_active(account, session)
+19
View File
@@ -96,6 +96,25 @@ class WorkspaceService:
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
)
@classmethod
def get_current_workspace_summary(cls, tenant: Tenant, account_id: str, *, session: Session) -> dict[str, object]:
tenant_account_join = session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account_id)
.limit(1)
)
assert tenant_account_join is not None, "TenantAccountJoin not found"
effective_pool = cls.get_effective_credit_pool(tenant.id, session=session)
return {
"id": tenant.id,
"name": tenant.name,
"role": tenant_account_join.role,
"plan": effective_pool.plan,
"credits": effective_pool.remaining_credits,
}
@classmethod
def get_tenant_info(cls, tenant: Tenant, session: Session):
if not tenant:
@@ -5,14 +5,14 @@ from http import HTTPStatus
from inspect import unwrap
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Unauthorized
from werkzeug.exceptions import NotFound
import services
from controllers.common.errors import (
@@ -22,11 +22,13 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.console import console_ns
from controllers.console.error import AccountNotLinkTenantError
from controllers.console.workspace.error import CurrentWorkspaceArchivedError
from controllers.console.workspace.workspace import (
CurrentWorkspaceSummaryApi,
CustomConfigWorkspaceApi,
SwitchWorkspaceApi,
TenantApi,
TenantInfoResponse,
TenantListApi,
WebappLogoWorkspaceApi,
@@ -334,66 +336,59 @@ class TestWorkspaceListApi:
assert result["has_more"] is True
class TestTenantApi:
def test_post_active_tenant(self, app: Flask):
api = TenantApi()
method = unwrap(api.post)
def test_legacy_current_workspace_routes_are_not_registered():
urls = {url for _resource, resource_urls, _route_doc, _kwargs in console_ns.resources for url in resource_urls}
assert "/workspaces/current" not in urls
assert "/info" not in urls
class TestCurrentWorkspaceSummaryApi:
def test_get_summary(self, app: Flask):
api = CurrentWorkspaceSummaryApi()
method = unwrap(api.get)
tenant = make_tenant()
user = make_account_with_tenant(tenant)
session = MagicMock()
summary = {
"id": tenant.id,
"name": tenant.name,
"role": "owner",
"plan": CloudPlan.SANDBOX,
"credits": 180,
}
with (
app.test_request_context("/workspaces/current"),
app.test_request_context("/workspaces/current/summary"),
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t1"}
),
"controllers.console.workspace.workspace.WorkspaceService.get_current_workspace_summary",
return_value=summary,
) as get_summary,
):
result, status = method(api, MagicMock(), user)
result, status = method(api, session, user)
assert status == HTTPStatus.OK
assert result["id"] == "t1"
assert result == {
"id": tenant.id,
"name": tenant.name,
"role": "owner",
"plan": "sandbox",
"credits": 180,
}
get_summary.assert_called_once_with(tenant, user.id, session=session)
def test_post_archived_with_switch(self, app: Flask):
api = TenantApi()
method = unwrap(api.post)
archived = make_tenant(status=TenantStatus.ARCHIVE)
new_tenant = make_tenant("new")
user = make_account_with_tenant(archived)
with (
app.test_request_context("/workspaces/current"),
patch("controllers.console.workspace.workspace.TenantService.get_join_tenants", return_value=[new_tenant]),
patch("controllers.console.workspace.workspace.TenantService.switch_tenant") as switch_tenant,
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "new"}
),
):
result, status = method(api, MagicMock(), user)
assert result["id"] == "new"
switch_tenant.assert_called_once_with(user, new_tenant.id, session=ANY)
def test_get_archived_tenant_returns_conflict(self, app: Flask):
api = CurrentWorkspaceSummaryApi()
method = unwrap(api.get)
tenant = make_tenant(status=TenantStatus.ARCHIVE)
user = make_account_with_tenant(tenant)
def test_post_archived_no_tenant(self, app: Flask):
api = TenantApi()
method = unwrap(api.post)
user = make_account_with_tenant(make_tenant(status=TenantStatus.ARCHIVE))
with (
app.test_request_context("/workspaces/current"),
patch("controllers.console.workspace.workspace.TenantService.get_join_tenants", return_value=[]),
):
with pytest.raises(Unauthorized):
with app.test_request_context("/workspaces/current/summary"):
with pytest.raises(CurrentWorkspaceArchivedError) as exc_info:
method(api, MagicMock(), user)
def test_post_info_path(self, app: Flask, caplog: pytest.LogCaptureFixture):
api = TenantApi()
method = unwrap(api.post)
tenant = make_tenant()
user = make_account_with_tenant(tenant)
with (
app.test_request_context("/info"),
caplog.at_level(logging.WARNING, logger="controllers.console.workspace.workspace"),
patch(
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t1"}
),
):
result, status = method(api, MagicMock(), user)
assert "Deprecated URL /info was used." in caplog.messages
assert status == HTTPStatus.OK
assert exc_info.value.code == HTTPStatus.CONFLICT
assert exc_info.value.error_code == "current_workspace_archived"
class TestTenantInfoResponse:
@@ -467,6 +462,43 @@ class TestSwitchWorkspaceApi:
class TestCustomConfigWorkspaceApi:
def test_get_workspace_not_found(self, app: Flask, workspace_session: scoped_session[Session]):
api = CustomConfigWorkspaceApi()
method = unwrap(api.get)
with app.test_request_context("/workspaces/custom-config"), pytest.raises(NotFound):
method(api, workspace_session, "missing")
def test_get_defaults(self, app: Flask, workspace_session: scoped_session[Session]):
api = CustomConfigWorkspaceApi()
method = unwrap(api.get)
tenant = make_tenant(custom_config={})
workspace_session.add(tenant)
workspace_session.commit()
with app.test_request_context("/workspaces/custom-config"):
result = method(api, workspace_session, tenant.id)
assert result == {"remove_webapp_brand": False, "replace_webapp_logo": None}
def test_get_configured_brand(self, app: Flask, workspace_session: scoped_session[Session]):
api = CustomConfigWorkspaceApi()
method = unwrap(api.get)
tenant = make_tenant(custom_config={"remove_webapp_brand": True, "replace_webapp_logo": "logo-file-id"})
workspace_session.add(tenant)
workspace_session.commit()
with (
app.test_request_context("/workspaces/custom-config"),
patch("controllers.console.workspace.workspace.dify_config.FILES_URL", "https://files.example.com"),
):
result = method(api, workspace_session, tenant.id)
assert result == {
"remove_webapp_brand": True,
"replace_webapp_logo": f"https://files.example.com/files/workspaces/{tenant.id}/webapp-logo",
}
def test_post_success(self, app: Flask, workspace_session: scoped_session[Session]):
api = CustomConfigWorkspaceApi()
method = unwrap(api.post)
@@ -456,14 +456,11 @@ class TestAccountService:
sqlite_session.add(tenant_join)
sqlite_session.commit()
with (
patch.object(Account, "set_tenant_id_with_session") as mock_set_tenant_id,
patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active,
):
with patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active:
result = AccountService.load_user(account.id, sqlite_session)
assert result is account
mock_set_tenant_id.assert_called_once_with(tenant.id, session=sqlite_session)
assert result.current_tenant_id == tenant.id
mock_refresh_last_active.assert_called_once_with(account, sqlite_session)
def test_load_user_not_found(self, sqlite_session: Session) -> None:
@@ -519,6 +516,54 @@ class TestAccountService:
assert persisted_tenant_join.current is True
assert persisted_tenant_join.last_opened_at == mock_now
def test_load_user_switches_from_archived_current_tenant(self, sqlite_session: Session) -> None:
account = Account(name="Test User", email="test@example.com")
archived_tenant = Tenant(name="Archived Workspace", status=TenantStatus.ARCHIVE)
available_tenant = Tenant(name="Available Workspace")
sqlite_session.add_all([account, archived_tenant, available_tenant])
sqlite_session.flush()
archived_join = TenantAccountJoin(
tenant_id=archived_tenant.id,
account_id=account.id,
role=TenantAccountRole.NORMAL,
current=True,
)
available_join = TenantAccountJoin(
tenant_id=available_tenant.id,
account_id=account.id,
role=TenantAccountRole.NORMAL,
current=False,
)
sqlite_session.add_all([archived_join, available_join])
sqlite_session.commit()
with patch.object(AccountService, "_refresh_account_last_active"):
result = AccountService.load_user(account.id, sqlite_session)
assert result is account
assert result.current_tenant_id == available_tenant.id
assert archived_join.current is False
assert available_join.current is True
def test_load_user_returns_none_without_normal_tenant(self, sqlite_session: Session) -> None:
account = Account(name="Test User", email="test@example.com")
archived_tenant = Tenant(name="Archived Workspace", status=TenantStatus.ARCHIVE)
sqlite_session.add_all([account, archived_tenant])
sqlite_session.flush()
archived_join = TenantAccountJoin(
tenant_id=archived_tenant.id,
account_id=account.id,
role=TenantAccountRole.NORMAL,
current=True,
)
sqlite_session.add(archived_join)
sqlite_session.commit()
result = AccountService.load_user(account.id, sqlite_session)
assert result is None
assert archived_join.current is False
def test_load_user_keeps_tenant_accessible_with_expiring_session(self, sqlite_session: Session) -> None:
account = Account(name="Test User", email="test@example.com")
tenant = Tenant(name="Test Workspace")
@@ -0,0 +1,108 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
from enums.cloud_plan import CloudPlan
from models.account import Tenant
from services.credit_pool_service import CreditPoolBalance
from services.workspace_service import WorkspaceService
def test_get_current_workspace_summary_sandbox_uses_trial_only() -> None:
tenant = Tenant(name="Workspace")
membership = SimpleNamespace(role="owner")
session = MagicMock()
session.scalar.return_value = membership
trial_pool = CreditPoolBalance(
tenant_id=tenant.id,
pool_type="trial",
quota_limit=200,
quota_used=20,
)
billing_info = {
"enabled": True,
"subscription": {"plan": CloudPlan.SANDBOX},
}
config = SimpleNamespace(BILLING_ENABLED=True)
with (
patch("services.workspace_service.dify_config", config),
patch("services.workspace_service.BillingService.get_info", return_value=billing_info) as get_info,
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=trial_pool) as get_pool,
patch("services.workspace_service.FeatureService.get_features") as get_features,
):
result = WorkspaceService.get_current_workspace_summary(tenant, "account-1", session=session)
assert result == {
"id": tenant.id,
"name": tenant.name,
"role": "owner",
"plan": CloudPlan.SANDBOX,
"credits": 180,
}
get_info.assert_called_once_with(tenant.id, exclude_vector_space=True)
get_pool.assert_called_once_with(tenant_id=tenant.id, pool_type="trial", session=session)
get_features.assert_not_called()
def test_get_current_workspace_summary_falls_back_from_exhausted_paid_pool() -> None:
tenant = Tenant(name="Workspace")
session = MagicMock()
session.scalar.return_value = SimpleNamespace(role="admin")
paid_pool = CreditPoolBalance(
tenant_id=tenant.id,
pool_type="paid",
quota_limit=500,
quota_used=500,
)
trial_pool = CreditPoolBalance(
tenant_id=tenant.id,
pool_type="trial",
quota_limit=100,
quota_used=40,
)
billing_info = {
"enabled": True,
"subscription": {"plan": CloudPlan.TEAM},
}
config = SimpleNamespace(BILLING_ENABLED=True)
with (
patch("services.workspace_service.dify_config", config),
patch("services.workspace_service.BillingService.get_info", return_value=billing_info),
patch(
"services.credit_pool_service.CreditPoolService.get_pool",
side_effect=[paid_pool, trial_pool],
) as get_pool,
):
result = WorkspaceService.get_current_workspace_summary(tenant, "account-1", session=session)
assert result["plan"] == CloudPlan.TEAM
assert result["credits"] == 60
assert get_pool.call_args_list == [
call(tenant_id=tenant.id, pool_type="paid", session=session),
call(tenant_id=tenant.id, pool_type="trial", session=session),
]
def test_get_current_workspace_summary_billing_disabled_skips_billing_and_credits() -> None:
tenant = Tenant(name="Workspace")
session = MagicMock()
session.scalar.return_value = SimpleNamespace(role="editor")
config = SimpleNamespace(BILLING_ENABLED=False)
with (
patch("services.workspace_service.dify_config", config),
patch("services.workspace_service.BillingService.get_info") as get_info,
patch("services.credit_pool_service.CreditPoolService.get_pool") as get_pool,
):
result = WorkspaceService.get_current_workspace_summary(tenant, "account-1", session=session)
assert result == {
"id": tenant.id,
"name": tenant.name,
"role": "editor",
"plan": None,
"credits": None,
}
get_info.assert_not_called()
get_pool.assert_not_called()
+1 -1
View File
@@ -5919,7 +5919,7 @@
"count": 1
},
"typescript/no-explicit-any": {
"count": 8
"count": 6
}
},
"web/service/datasets.ts": {
@@ -1,22 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import { zPostInfoResponse } from './zod.gen'
export const post = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postInfo',
path: '/info',
tags: ['console'],
})
.output(zPostInfoResponse)
export const info = {
post,
}
export const contract = {
info,
}
@@ -1,39 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type TenantInfoResponse = {
created_at?: number | null
custom_config?: WorkspaceCustomConfigResponse | null
id: string
in_trial?: boolean | null
name?: string | null
next_credit_reset_date?: number | null
plan?: string | null
role?: string | null
status?: string | null
trial_credits?: number | null
trial_credits_exhausted_at?: number | null
trial_credits_used?: number | null
trial_end_reason?: string | null
}
export type WorkspaceCustomConfigResponse = {
remove_webapp_brand?: boolean | null
replace_webapp_logo?: string | null
}
export type PostInfoData = {
body?: never
path?: never
query?: never
url: '/info'
}
export type PostInfoResponses = {
200: TenantInfoResponse
}
export type PostInfoResponse = PostInfoResponses[keyof PostInfoResponses]
@@ -1,35 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import * as z from 'zod'
/**
* WorkspaceCustomConfigResponse
*/
export const zWorkspaceCustomConfigResponse = z.object({
remove_webapp_brand: z.boolean().nullish(),
replace_webapp_logo: z.string().nullish(),
})
/**
* TenantInfoResponse
*/
export const zTenantInfoResponse = z.object({
created_at: z.int().nullish(),
custom_config: zWorkspaceCustomConfigResponse.nullish(),
id: z.string(),
in_trial: z.boolean().nullish(),
name: z.string().nullish(),
next_credit_reset_date: z.int().nullish(),
plan: z.string().nullish(),
role: z.string().nullish(),
status: z.string().nullish(),
trial_credits: z.int().nullish(),
trial_credits_exhausted_at: z.int().nullish(),
trial_credits_used: z.int().nullish(),
trial_end_reason: z.string().nullish(),
})
/**
* Success
*/
export const zPostInfoResponse = zTenantInfoResponse
@@ -34,7 +34,6 @@ export const contractLoaders = {
forgotPassword: () =>
import('./forgot-password/orpc.gen').then(({ forgotPassword }) => ({ forgotPassword })),
form: () => import('./form/orpc.gen').then(({ form }) => ({ form })),
info: () => import('./info/orpc.gen').then(({ info }) => ({ info })),
init: () => import('./init/orpc.gen').then(({ init }) => ({ init })),
installedApps: () =>
import('./installed-apps/orpc.gen').then(({ installedApps }) => ({ installedApps })),
@@ -23,7 +23,6 @@ import { features } from './features/orpc.gen'
import { files } from './files/orpc.gen'
import { forgotPassword } from './forgot-password/orpc.gen'
import { form } from './form/orpc.gen'
import { info } from './info/orpc.gen'
import { init } from './init/orpc.gen'
import { installedApps } from './installed-apps/orpc.gen'
import { instructionGenerate } from './instruction-generate/orpc.gen'
@@ -80,7 +79,6 @@ const communityContract = {
files,
forgotPassword,
form,
info,
init,
installedApps,
instructionGenerate,
@@ -151,6 +151,7 @@ import {
zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath,
zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse,
zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse,
zGetWorkspacesCurrentSummaryResponse,
zGetWorkspacesCurrentToolLabelsResponse,
zGetWorkspacesCurrentToolProviderApiGetQuery,
zGetWorkspacesCurrentToolProviderApiGetResponse,
@@ -205,6 +206,7 @@ import {
zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizePath,
zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse,
zGetWorkspacesCurrentTriggersResponse,
zGetWorkspacesCustomConfigResponse,
zGetWorkspacesResponse,
zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody,
zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath,
@@ -319,7 +321,6 @@ import {
zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath,
zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse,
zPostWorkspacesCurrentRbacRolesResponse,
zPostWorkspacesCurrentResponse,
zPostWorkspacesCurrentToolProviderApiAddBody,
zPostWorkspacesCurrentToolProviderApiAddResponse,
zPostWorkspacesCurrentToolProviderApiDeleteBody,
@@ -3037,6 +3038,20 @@ export const rbac = {
}
export const get62 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesCurrentSummary',
path: '/workspaces/current/summary',
tags: ['console'],
})
.output(zGetWorkspacesCurrentSummaryResponse)
export const summary2 = {
get: get62,
}
export const get63 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3047,7 +3062,7 @@ export const get62 = oc
.output(zGetWorkspacesCurrentToolLabelsResponse)
export const toolLabels = {
get: get62,
get: get63,
}
export const post48 = oc
@@ -3080,7 +3095,7 @@ export const delete13 = {
post: post49,
}
export const get63 = oc
export const get64 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3091,11 +3106,11 @@ export const get63 = oc
.input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery }))
.output(zGetWorkspacesCurrentToolProviderApiGetResponse)
export const get64 = {
get: get63,
export const get65 = {
get: get64,
}
export const get65 = oc
export const get66 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3107,7 +3122,7 @@ export const get65 = oc
.output(zGetWorkspacesCurrentToolProviderApiRemoteResponse)
export const remote = {
get: get65,
get: get66,
}
export const post50 = oc
@@ -3144,7 +3159,7 @@ export const test = {
pre,
}
export const get66 = oc
export const get67 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3156,7 +3171,7 @@ export const get66 = oc
.output(zGetWorkspacesCurrentToolProviderApiToolsResponse)
export const tools = {
get: get66,
get: get67,
}
export const post52 = oc
@@ -3177,7 +3192,7 @@ export const update2 = {
export const api = {
add,
delete: delete13,
get: get64,
get: get65,
remote,
schema,
test,
@@ -3205,7 +3220,7 @@ export const add2 = {
post: post53,
}
export const get67 = oc
export const get68 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3222,10 +3237,10 @@ export const get67 = oc
.output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse)
export const info = {
get: get67,
get: get68,
}
export const get68 = oc
export const get69 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3245,7 +3260,7 @@ export const get68 = oc
)
export const byCredentialType = {
get: get68,
get: get69,
}
export const schema2 = {
@@ -3257,7 +3272,7 @@ export const credential = {
schema: schema2,
}
export const get69 = oc
export const get70 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3274,7 +3289,7 @@ export const get69 = oc
.output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse)
export const credentials3 = {
get: get69,
get: get70,
}
export const post54 = oc
@@ -3317,7 +3332,7 @@ export const delete14 = {
post: post55,
}
export const get70 = oc
export const get71 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3329,10 +3344,10 @@ export const get70 = oc
.output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse)
export const icon2 = {
get: get70,
get: get71,
}
export const get71 = oc
export const get72 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3344,10 +3359,10 @@ export const get71 = oc
.output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse)
export const info2 = {
get: get71,
get: get72,
}
export const get72 = oc
export const get73 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3361,7 +3376,7 @@ export const get72 = oc
.output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse)
export const clientSchema = {
get: get72,
get: get73,
}
export const delete15 = oc
@@ -3379,7 +3394,7 @@ export const delete15 = oc
)
.output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse)
export const get73 = oc
export const get74 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3410,7 +3425,7 @@ export const post56 = oc
export const customClient = {
delete: delete15,
get: get73,
get: get74,
post: post56,
}
@@ -3419,7 +3434,7 @@ export const oauth = {
customClient,
}
export const get74 = oc
export const get75 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3431,7 +3446,7 @@ export const get74 = oc
.output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse)
export const tools2 = {
get: get74,
get: get75,
}
export const post57 = oc
@@ -3486,7 +3501,7 @@ export const auth = {
post: post58,
}
export const get75 = oc
export const get76 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3498,14 +3513,14 @@ export const get75 = oc
.output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse)
export const byProviderId = {
get: get75,
get: get76,
}
export const tools3 = {
byProviderId,
}
export const get76 = oc
export const get77 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3517,7 +3532,7 @@ export const get76 = oc
.output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse)
export const byProviderId2 = {
get: get76,
get: get77,
}
export const update4 = {
@@ -3596,7 +3611,7 @@ export const delete17 = {
post: post61,
}
export const get77 = oc
export const get78 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3607,11 +3622,11 @@ export const get77 = oc
.input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() }))
.output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse)
export const get78 = {
get: get77,
export const get79 = {
get: get78,
}
export const get79 = oc
export const get80 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3623,7 +3638,7 @@ export const get79 = oc
.output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse)
export const tools4 = {
get: get79,
get: get80,
}
export const post62 = oc
@@ -3644,7 +3659,7 @@ export const update5 = {
export const workflow = {
create: create2,
delete: delete17,
get: get78,
get: get79,
tools: tools4,
update: update5,
}
@@ -3656,7 +3671,7 @@ export const toolProvider = {
workflow,
}
export const get80 = oc
export const get81 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3668,10 +3683,10 @@ export const get80 = oc
.output(zGetWorkspacesCurrentToolProvidersResponse)
export const toolProviders = {
get: get80,
get: get81,
}
export const get81 = oc
export const get82 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3682,10 +3697,10 @@ export const get81 = oc
.output(zGetWorkspacesCurrentToolsApiResponse)
export const api2 = {
get: get81,
get: get82,
}
export const get82 = oc
export const get83 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3696,10 +3711,10 @@ export const get82 = oc
.output(zGetWorkspacesCurrentToolsBuiltinResponse)
export const builtin2 = {
get: get82,
get: get83,
}
export const get83 = oc
export const get84 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3710,10 +3725,10 @@ export const get83 = oc
.output(zGetWorkspacesCurrentToolsMcpResponse)
export const mcp2 = {
get: get83,
get: get84,
}
export const get84 = oc
export const get85 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3724,7 +3739,7 @@ export const get84 = oc
.output(zGetWorkspacesCurrentToolsWorkflowResponse)
export const workflow2 = {
get: get84,
get: get85,
}
export const tools5 = {
@@ -3734,7 +3749,7 @@ export const tools5 = {
workflow: workflow2,
}
export const get85 = oc
export const get86 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3746,13 +3761,13 @@ export const get85 = oc
.output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse)
export const icon3 = {
get: get85,
get: get86,
}
/**
* Get info for a trigger provider
*/
export const get86 = oc
export const get87 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3765,7 +3780,7 @@ export const get86 = oc
.output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse)
export const info3 = {
get: get86,
get: get87,
}
/**
@@ -3786,7 +3801,7 @@ export const delete18 = oc
/**
* Get OAuth client configuration for a provider
*/
export const get87 = oc
export const get88 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3820,7 +3835,7 @@ export const post63 = oc
export const client = {
delete: delete18,
get: get87,
get: get88,
post: post63,
}
@@ -3887,7 +3902,7 @@ export const create3 = {
/**
* Get the request logs for a subscription instance for a trigger provider
*/
export const get88 = oc
export const get89 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -3908,7 +3923,7 @@ export const get88 = oc
)
export const bySubscriptionBuilderId2 = {
get: get88,
get: get89,
}
export const logs = {
@@ -3982,7 +3997,7 @@ export const verifyAndUpdate = {
/**
* Get a subscription instance for a trigger provider
*/
export const get89 = oc
export const get90 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4003,7 +4018,7 @@ export const get89 = oc
)
export const bySubscriptionBuilderId5 = {
get: get89,
get: get90,
}
export const builder = {
@@ -4018,7 +4033,7 @@ export const builder = {
/**
* List all trigger subscriptions for the current tenant's provider
*/
export const get90 = oc
export const get91 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4031,13 +4046,13 @@ export const get90 = oc
.output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse)
export const list4 = {
get: get90,
get: get91,
}
/**
* Initiate OAuth authorization flow for a trigger provider
*/
export const get91 = oc
export const get92 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4054,7 +4069,7 @@ export const get91 = oc
.output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse)
export const authorize = {
get: get91,
get: get92,
}
export const oauth3 = {
@@ -4171,7 +4186,7 @@ export const triggerProvider = {
/**
* List all trigger providers for the current tenant
*/
export const get92 = oc
export const get93 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4183,21 +4198,10 @@ export const get92 = oc
.output(zGetWorkspacesCurrentTriggersResponse)
export const triggers = {
get: get92,
get: get93,
}
export const post71 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesCurrent',
path: '/workspaces/current',
tags: ['console'],
})
.output(zPostWorkspacesCurrentResponse)
export const current = {
post: post71,
agentProvider,
agentProviders,
customizedSnippets,
@@ -4210,6 +4214,7 @@ export const current = {
permission,
plugin: plugin2,
rbac,
summary: summary2,
toolLabels,
toolProvider,
toolProviders,
@@ -4218,7 +4223,7 @@ export const current = {
triggers,
}
export const post72 = oc
export const post71 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -4231,14 +4236,24 @@ export const post72 = oc
.output(zPostWorkspacesCustomConfigWebappLogoUploadResponse)
export const upload2 = {
post: post72,
post: post71,
}
export const webappLogo = {
upload: upload2,
}
export const post73 = oc
export const get94 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesCustomConfig',
path: '/workspaces/custom-config',
tags: ['console'],
})
.output(zGetWorkspacesCustomConfigResponse)
export const post72 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -4250,11 +4265,12 @@ export const post73 = oc
.output(zPostWorkspacesCustomConfigResponse)
export const customConfig = {
post: post73,
get: get94,
post: post72,
webappLogo,
}
export const post74 = oc
export const post73 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -4266,10 +4282,10 @@ export const post74 = oc
.output(zPostWorkspacesInfoResponse)
export const info4 = {
post: post74,
post: post73,
}
export const post75 = oc
export const post74 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -4281,10 +4297,10 @@ export const post75 = oc
.output(zPostWorkspacesSwitchResponse)
export const switch3 = {
post: post75,
post: post74,
}
export const get93 = oc
export const get95 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4296,7 +4312,7 @@ export const get93 = oc
.output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse)
export const byLang = {
get: get93,
get: get95,
}
export const byIconType = {
@@ -4315,7 +4331,7 @@ export const byTenantId = {
modelProviders: modelProviders2,
}
export const get94 = oc
export const get96 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -4326,7 +4342,7 @@ export const get94 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get94,
get: get96,
current,
customConfig,
info: info4,
@@ -8,22 +8,6 @@ export type TenantListResponse = {
workspaces: Array<TenantListItemResponse>
}
export type TenantInfoResponse = {
created_at?: number | null
custom_config?: WorkspaceCustomConfigResponse | null
id: string
in_trial?: boolean | null
name?: string | null
next_credit_reset_date?: number | null
plan?: string | null
role?: string | null
status?: string | null
trial_credits?: number | null
trial_credits_exhausted_at?: number | null
trial_credits_used?: number | null
trial_end_reason?: string | null
}
export type AgentProviderResponse = {
[key: string]: unknown
}
@@ -658,6 +642,14 @@ export type WorkspaceAccessMatrix = {
pagination?: Pagination | null
}
export type CurrentWorkspaceSummaryResponse = {
credits: number | null
id: string
name: string
plan: string | null
role: TenantAccountRole
}
export type ToolLabelListResponse = Array<ToolLabel>
export type ApiToolProviderAddPayload = {
@@ -1023,6 +1015,11 @@ export type TriggerOAuthAuthorizeResponse = {
export type TriggerProviderListResponse = Array<TriggerProviderApiEntity>
export type WorkspaceCustomConfigResponse = {
remove_webapp_brand?: boolean | null
replace_webapp_logo?: string | null
}
export type WorkspaceCustomConfigPayload = {
remove_webapp_brand?: boolean | null
replace_webapp_logo?: string | null
@@ -1060,11 +1057,6 @@ export type TenantListItemResponse = {
status?: string | null
}
export type WorkspaceCustomConfigResponse = {
remove_webapp_brand?: boolean | null
replace_webapp_logo?: string | null
}
export type SnippetListItemResponse = {
author_name: string | null
created_at: number
@@ -1549,6 +1541,8 @@ export type AccessPolicyRole = {
role_tag?: string
}
export type TenantAccountRole = 'admin' | 'dataset_operator' | 'editor' | 'normal' | 'owner'
export type ToolLabel = {
icon: string
label: I18nObject
@@ -1708,6 +1702,22 @@ export type TriggerProviderSubscriptionApiEntity = {
workflows_in_use: number
}
export type TenantInfoResponse = {
created_at?: number | null
custom_config?: WorkspaceCustomConfigResponse | null
id: string
in_trial?: boolean | null
name?: string | null
next_credit_reset_date?: number | null
plan?: string | null
role?: string | null
status?: string | null
trial_credits?: number | null
trial_credits_exhausted_at?: number | null
trial_credits_used?: number | null
trial_end_reason?: string | null
}
export type PluginDependencyType = 'github' | 'marketplace' | 'package'
export type Github = {
@@ -2489,20 +2499,6 @@ export type GetWorkspacesResponses = {
export type GetWorkspacesResponse = GetWorkspacesResponses[keyof GetWorkspacesResponses]
export type PostWorkspacesCurrentData = {
body?: never
path?: never
query?: never
url: '/workspaces/current'
}
export type PostWorkspacesCurrentResponses = {
200: TenantInfoResponse
}
export type PostWorkspacesCurrentResponse =
PostWorkspacesCurrentResponses[keyof PostWorkspacesCurrentResponses]
export type GetWorkspacesCurrentAgentProviderByProviderNameData = {
body?: never
path: {
@@ -4732,6 +4728,24 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = {
export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse =
GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses]
export type GetWorkspacesCurrentSummaryData = {
body?: never
path?: never
query?: never
url: '/workspaces/current/summary'
}
export type GetWorkspacesCurrentSummaryErrors = {
409: unknown
}
export type GetWorkspacesCurrentSummaryResponses = {
200: CurrentWorkspaceSummaryResponse
}
export type GetWorkspacesCurrentSummaryResponse =
GetWorkspacesCurrentSummaryResponses[keyof GetWorkspacesCurrentSummaryResponses]
export type GetWorkspacesCurrentToolLabelsData = {
body?: never
path?: never
@@ -5631,6 +5645,20 @@ export type GetWorkspacesCurrentTriggersResponses = {
export type GetWorkspacesCurrentTriggersResponse =
GetWorkspacesCurrentTriggersResponses[keyof GetWorkspacesCurrentTriggersResponses]
export type GetWorkspacesCustomConfigData = {
body?: never
path?: never
query?: never
url: '/workspaces/custom-config'
}
export type GetWorkspacesCustomConfigResponses = {
200: WorkspaceCustomConfigResponse
}
export type GetWorkspacesCustomConfigResponse =
GetWorkspacesCustomConfigResponses[keyof GetWorkspacesCustomConfigResponses]
export type PostWorkspacesCustomConfigData = {
body: WorkspaceCustomConfigPayload
path?: never
@@ -586,6 +586,14 @@ export const zTriggerProviderErrorResponse = z.object({
error: z.string(),
})
/**
* WorkspaceCustomConfigResponse
*/
export const zWorkspaceCustomConfigResponse = z.object({
remove_webapp_brand: z.boolean().nullish(),
replace_webapp_logo: z.string().nullish(),
})
/**
* WorkspaceCustomConfigPayload
*/
@@ -635,49 +643,6 @@ export const zTenantListResponse = z.object({
workspaces: z.array(zTenantListItemResponse),
})
/**
* WorkspaceCustomConfigResponse
*/
export const zWorkspaceCustomConfigResponse = z.object({
remove_webapp_brand: z.boolean().nullish(),
replace_webapp_logo: z.string().nullish(),
})
/**
* TenantInfoResponse
*/
export const zTenantInfoResponse = z.object({
created_at: z.int().nullish(),
custom_config: zWorkspaceCustomConfigResponse.nullish(),
id: z.string(),
in_trial: z.boolean().nullish(),
name: z.string().nullish(),
next_credit_reset_date: z.int().nullish(),
plan: z.string().nullish(),
role: z.string().nullish(),
status: z.string().nullish(),
trial_credits: z.int().nullish(),
trial_credits_exhausted_at: z.int().nullish(),
trial_credits_used: z.int().nullish(),
trial_end_reason: z.string().nullish(),
})
/**
* WorkspaceTenantResultResponse
*/
export const zWorkspaceTenantResultResponse = z.object({
result: z.string(),
tenant: zTenantInfoResponse,
})
/**
* SwitchWorkspaceResponse
*/
export const zSwitchWorkspaceResponse = z.object({
new_tenant: zTenantInfoResponse,
result: z.string(),
})
/**
* IconInfo
*
@@ -1266,6 +1231,22 @@ export const zWorkspaceAccessMatrix = z.object({
pagination: zPagination.nullish(),
})
/**
* TenantAccountRole
*/
export const zTenantAccountRole = z.enum(['admin', 'dataset_operator', 'editor', 'normal', 'owner'])
/**
* CurrentWorkspaceSummaryResponse
*/
export const zCurrentWorkspaceSummaryResponse = z.object({
credits: z.int().nullable(),
id: z.string(),
name: z.string(),
plan: z.string().nullable(),
role: zTenantAccountRole,
})
/**
* ToolEmojiIcon
*/
@@ -1575,6 +1556,41 @@ export const zTriggerProviderSubscriptionListResponse = z.array(
zTriggerProviderSubscriptionApiEntity,
)
/**
* TenantInfoResponse
*/
export const zTenantInfoResponse = z.object({
created_at: z.int().nullish(),
custom_config: zWorkspaceCustomConfigResponse.nullish(),
id: z.string(),
in_trial: z.boolean().nullish(),
name: z.string().nullish(),
next_credit_reset_date: z.int().nullish(),
plan: z.string().nullish(),
role: z.string().nullish(),
status: z.string().nullish(),
trial_credits: z.int().nullish(),
trial_credits_exhausted_at: z.int().nullish(),
trial_credits_used: z.int().nullish(),
trial_end_reason: z.string().nullish(),
})
/**
* WorkspaceTenantResultResponse
*/
export const zWorkspaceTenantResultResponse = z.object({
result: z.string(),
tenant: zTenantInfoResponse,
})
/**
* SwitchWorkspaceResponse
*/
export const zSwitchWorkspaceResponse = z.object({
new_tenant: zTenantInfoResponse,
result: z.string(),
})
/**
* PluginDependencyType
*/
@@ -3483,11 +3499,6 @@ export const zAccountWithRoleListResponseWritable = z.object({
*/
export const zGetWorkspacesResponse = zTenantListResponse
/**
* Success
*/
export const zPostWorkspacesCurrentResponse = zTenantInfoResponse
export const zGetWorkspacesCurrentAgentProviderByProviderNamePath = z.object({
provider_name: z.string(),
})
@@ -4797,6 +4808,11 @@ export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdR
*/
export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix
/**
* Success
*/
export const zGetWorkspacesCurrentSummaryResponse = zCurrentWorkspaceSummaryResponse
/**
* Tool labels retrieved successfully
*/
@@ -5350,6 +5366,11 @@ export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsU
*/
export const zGetWorkspacesCurrentTriggersResponse = zTriggerProviderListResponse
/**
* Success
*/
export const zGetWorkspacesCustomConfigResponse = zWorkspaceCustomConfigResponse
export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload
/**
@@ -54,8 +54,10 @@ vi.mock('@/service/server', () => ({
serverConsoleQuery: {
workspaces: {
current: {
post: {
queryOptions: (...args: unknown[]) => mocks.workspaceQueryOptions(...args),
summary: {
get: {
queryOptions: (...args: unknown[]) => mocks.workspaceQueryOptions(...args),
},
},
rbac: {
myPermissions: {
@@ -97,7 +99,13 @@ describe('CommonLayoutHydrationBoundary', () => {
currentEnv: 'DEVELOPMENT',
},
})
mocks.workspaceQueryFn.mockResolvedValue({ id: 'workspace-id', name: 'Workspace' })
mocks.workspaceQueryFn.mockResolvedValue({
id: 'workspace-id',
name: 'Workspace',
role: 'owner',
plan: 'sandbox',
credits: 200,
})
mocks.permissionQueryFn.mockResolvedValue({
workspace: { permission_keys: ['agent.manage'] },
app: { default_permission_keys: [], overrides: [] },
@@ -108,7 +116,7 @@ describe('CommonLayoutHydrationBoundary', () => {
csrfToken: 'csrf-token',
})
mocks.workspaceQueryOptions.mockReturnValue({
queryKey: ['console', 'workspaces', 'current', 'post'],
queryKey: ['console', 'workspaces', 'current', 'summary', 'get'],
queryFn: mocks.workspaceQueryFn,
retry: false,
})
@@ -166,7 +174,7 @@ describe('CommonLayoutHydrationBoundary', () => {
expect(queryKeys).toEqual(
expect.arrayContaining([
['common', 'user-profile'],
['console', 'workspaces', 'current', 'post'],
['console', 'workspaces', 'current', 'summary', 'get'],
[['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'], { type: 'query' }],
]),
)
@@ -65,7 +65,7 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(
serverConsoleQuery.workspaces.current.post.queryOptions({
serverConsoleQuery.workspaces.current.summary.get.queryOptions({
context,
retry: false,
}),
@@ -7,7 +7,8 @@ import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { consoleQuery } from '@/service/client'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import CustomPage from '../index'
vi.mock('@/config', async (importOriginal) => {
@@ -17,8 +18,15 @@ vi.mock('@/config', async (importOriginal) => {
}
})
const render = (ui: ReactElement) =>
renderWithConsoleQuery(ui, {
const render = (ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.customConfig.get.queryKey(), {
remove_webapp_brand: false,
replace_webapp_logo: null,
})
return renderWithConsoleQuery(ui, {
queryClient,
systemFeatures: {
deployment_edition: 'CLOUD',
branding: {
@@ -27,6 +35,7 @@ const render = (ui: ReactElement) =>
},
},
})
}
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
@@ -18,6 +18,7 @@ const createHookState = (
uploading: false,
webappLogo: 'https://example.com/replace.png',
webappBrandRemoved: false,
isCustomConfigUnavailable: false,
uploadDisabled: false,
workspaceLogo: 'https://example.com/workspace-logo.png',
isSandbox: false,
@@ -106,6 +107,18 @@ describe('CustomWebAppBrand', () => {
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled()
})
it('should disable brand edits while the custom config is unavailable', () => {
renderComponent({
fileId: 'new-logo',
isCustomConfigUnavailable: true,
uploadDisabled: true,
})
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('button', { name: 'custom.change' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled()
})
it('should default the switch to unchecked when brand removal state is missing', () => {
const { container } = renderComponent({
webappBrandRemoved: undefined,
@@ -1,22 +1,30 @@
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { ChangeEvent } from 'react'
import type { ICurrentWorkspace } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { act } from '@testing-library/react'
import { act, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from '@/context/app-context-defaults'
import { initialLangGeniusVersionInfo } from '@/context/app-context-defaults'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data'
import useWebAppBrand from '../use-web-app-brand'
let currentBrandingOverrides: Partial<GetSystemFeaturesResponse['branding']> = {}
const renderHook = <Result, Props = void>(callback: (props: Props) => Result) =>
renderHookWithConsoleQuery(callback, {
let customConfig = {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: false,
}
let seedCustomConfig = true
let customConfigQueryPending = false
let customConfigQueryError: Error | undefined
const renderHook = <Result, Props = void>(callback: (props: Props) => Result) => {
const queryClient = createConsoleQueryClient()
if (seedCustomConfig) queryClient.setQueryData(['custom-config'], customConfig)
return renderHookWithConsoleQuery(callback, {
systemFeatures: {
branding: {
enabled: true,
@@ -24,7 +32,9 @@ const renderHook = <Result, Props = void>(callback: (props: Props) => Result) =>
...currentBrandingOverrides,
},
},
queryClient,
})
}
const { mockNotify, mockToast } = vi.hoisted(() => {
const mockNotify = vi.fn()
@@ -42,14 +52,54 @@ const { mockNotify, mockToast } = vi.hoisted(() => {
const consoleStateRef = vi.hoisted(() => ({
value: undefined as ConsoleStateFixture | undefined,
}))
const mockConsoleStateReader = vi.hoisted(() => vi.fn())
const mockUpdateCustomConfig = vi.hoisted(() => vi.fn())
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
vi.mock('@/service/common', () => ({
updateCurrentWorkspace: vi.fn(),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const consoleQuery = new Proxy(actual.consoleQuery, {
get(target, prop, receiver) {
if (prop === 'workspaces') {
return new Proxy(target.workspaces, {
get(workspacesTarget, workspaceProp, workspaceReceiver) {
if (workspaceProp === 'customConfig') {
return {
get: {
key: () => ['custom-config'],
queryOptions: () => ({
queryKey: ['custom-config'],
queryFn: async () => {
if (customConfigQueryPending) return new Promise<never>(() => {})
if (customConfigQueryError)
return Promise.reject(new Error(customConfigQueryError.message))
return customConfig
},
}),
},
post: {
mutationOptions: () => ({
mutationFn: (variables: unknown) => mockUpdateCustomConfig(variables),
}),
},
}
}
return Reflect.get(workspacesTarget, workspaceProp, workspaceReceiver)
},
})
}
return Reflect.get(target, prop, receiver)
},
})
return {
...actual,
consoleQuery,
}
})
vi.mock('@/context/account-state', async () => {
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
return createAccountStateModuleMock(() => ({
@@ -87,7 +137,6 @@ vi.mock('@/app/components/base/image-uploader/utils', () => ({
getImageUploadErrorMessage: vi.fn(),
}))
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockImageUpload = vi.mocked(imageUpload)
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
@@ -118,19 +167,6 @@ const createProviderContext = ({
}
const createConsoleState = (overrides: Partial<ConsoleStateFixture> = {}): ConsoleStateFixture => {
const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides
const workspaceOverrides: Partial<ConsoleStateFixture['currentWorkspace']> =
currentWorkspaceOverride ?? {}
const currentWorkspace = {
...initialWorkspaceInfo,
...workspaceOverrides,
custom_config: {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: false,
...workspaceOverrides.custom_config,
},
}
return {
userProfile: testUserProfile,
refreshUserProfile: vi.fn(),
@@ -142,15 +178,12 @@ const createConsoleState = (overrides: Partial<ConsoleStateFixture> = {}): Conso
refreshCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
isLoadingCurrentWorkspace: false,
...restOverrides,
currentWorkspace,
...overrides,
}
}
describe('useWebAppBrand', () => {
let consoleState: ConsoleStateFixture
const setConsoleState = (nextValue: ConsoleStateFixture) => {
consoleState = nextValue
consoleStateRef.value = nextValue
}
@@ -160,8 +193,14 @@ describe('useWebAppBrand', () => {
setConsoleState(createConsoleState())
currentBrandingOverrides = {}
mockUpdateCurrentWorkspace.mockResolvedValue(consoleState.currentWorkspace as ICurrentWorkspace)
mockConsoleStateReader.mockImplementation(() => consoleState)
customConfig = {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: false,
}
seedCustomConfig = true
customConfigQueryPending = false
customConfigQueryError = undefined
mockUpdateCustomConfig.mockResolvedValue(customConfig)
mockUseProviderContext.mockReturnValue(createProviderContext())
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
})
@@ -213,17 +252,7 @@ describe('useWebAppBrand', () => {
planType: Plan.sandbox,
}),
)
setConsoleState(
createConsoleState({
currentWorkspace: {
...initialWorkspaceInfo,
custom_config: {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: true,
},
},
}),
)
customConfig = { ...customConfig, remove_webapp_brand: true }
const { result } = renderHook(() => useWebAppBrand())
@@ -240,18 +269,35 @@ describe('useWebAppBrand', () => {
expect(result.current.workspaceLogo).toBe('')
})
it('should fall back to an empty custom logo when custom config is missing', () => {
setConsoleState({
...createConsoleState(),
currentWorkspace: {
...initialWorkspaceInfo,
},
})
it('should fall back to an empty custom logo when the custom config has no logo', () => {
customConfig = { replace_webapp_logo: '', remove_webapp_brand: false }
const { result } = renderHook(() => useWebAppBrand())
expect(result.current.webappLogo).toBe('')
})
it('should disable brand edits while the custom config is loading', () => {
seedCustomConfig = false
customConfigQueryPending = true
const { result } = renderHook(() => useWebAppBrand())
expect(result.current.isCustomConfigUnavailable).toBe(true)
expect(result.current.uploadDisabled).toBe(true)
})
it('should disable brand edits when the custom config request fails', async () => {
seedCustomConfig = false
customConfigQueryError = new Error('custom config unavailable')
const { result } = renderHook(() => useWebAppBrand())
await waitFor(() => {
expect(result.current.isCustomConfigUnavailable).toBe(true)
})
expect(result.current.uploadDisabled).toBe(true)
})
})
// State transitions driven by user actions.
@@ -348,12 +394,6 @@ describe('useWebAppBrand', () => {
})
it('should persist the selected logo and reset transient state on apply', async () => {
const mutateCurrentWorkspace = vi.fn()
setConsoleState(
createConsoleState({
refreshCurrentWorkspace: mutateCurrentWorkspace,
}),
)
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
onSuccessCallback({ id: 'new-logo' })
})
@@ -373,64 +413,44 @@ describe('useWebAppBrand', () => {
await result.current.handleApply()
})
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
expect(mockUpdateCustomConfig).toHaveBeenCalledWith({
body: {
remove_webapp_brand: false,
replace_webapp_logo: 'new-logo',
},
})
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
expect(result.current.fileId).toBe('')
expect(result.current.imgKey).toBe(previousImgKey + 1)
dateNowSpy.mockRestore()
})
it('should restore the default branding configuration', async () => {
const mutateCurrentWorkspace = vi.fn()
setConsoleState(
createConsoleState({
refreshCurrentWorkspace: mutateCurrentWorkspace,
}),
)
const { result } = renderHook(() => useWebAppBrand())
await act(async () => {
await result.current.handleRestore()
})
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
expect(mockUpdateCustomConfig).toHaveBeenCalledWith({
body: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
})
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
})
it('should persist brand removal changes', async () => {
const mutateCurrentWorkspace = vi.fn()
setConsoleState(
createConsoleState({
refreshCurrentWorkspace: mutateCurrentWorkspace,
}),
)
const { result } = renderHook(() => useWebAppBrand())
await act(async () => {
await result.current.handleSwitch(true)
})
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
expect(mockUpdateCustomConfig).toHaveBeenCalledWith({
body: {
remove_webapp_brand: true,
},
})
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
})
it('should clear temporary upload state on cancel', () => {
@@ -1,46 +1,50 @@
import type { WorkspaceCustomConfigPayload } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ChangeEvent } from 'react'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { Plan } from '@/app/components/billing/type'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { currentWorkspaceAtom, refreshCurrentWorkspaceAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { updateCurrentWorkspace } from '@/service/common'
import { consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
const CUSTOM_CONFIG_URL = '/workspaces/custom-config'
const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload'
const useWebAppBrand = () => {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const mutateCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
const queryClient = useQueryClient()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(() => Date.now())
const [uploadProgress, setUploadProgress] = useState(0)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const customConfigQuery = useQuery(consoleQuery.workspaces.customConfig.get.queryOptions())
const { data: customConfig } = customConfigQuery
const updateCustomConfigMutation = useMutation(
consoleQuery.workspaces.customConfig.post.mutationOptions(),
)
const isSandbox = enableBilling && plan.type === Plan.sandbox
const uploading = uploadProgress > 0 && uploadProgress < 100
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
const webappLogo = customConfig?.replace_webapp_logo || ''
const webappBrandRemoved = customConfig?.remove_webapp_brand ?? undefined
const canManageCustomBrand = hasPermission(workspacePermissionKeys, 'customization.manage')
const uploadDisabled = isSandbox || webappBrandRemoved || !canManageCustomBrand
const isCustomConfigUnavailable = customConfigQuery.isPending || customConfigQuery.isError
const uploadDisabled =
isCustomConfigUnavailable || isSandbox || webappBrandRemoved || !canManageCustomBrand
const workspaceLogo = systemFeatures.branding.enabled
? systemFeatures.branding.workspace_logo
: ''
const persistWorkspaceBrand = async (body: Record<string, unknown>) => {
await updateCurrentWorkspace({
url: CUSTOM_CONFIG_URL,
body,
const persistWorkspaceBrand = async (body: WorkspaceCustomConfigPayload) => {
await updateCustomConfigMutation.mutateAsync({ body })
await queryClient.invalidateQueries({
queryKey: consoleQuery.workspaces.customConfig.get.key(),
})
mutateCurrentWorkspace()
}
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -101,6 +105,7 @@ const useWebAppBrand = () => {
uploading,
webappLogo,
webappBrandRemoved,
isCustomConfigUnavailable,
uploadDisabled,
workspaceLogo,
isSandbox,
@@ -18,6 +18,7 @@ const CustomWebAppBrand = () => {
uploading,
webappLogo,
webappBrandRemoved,
isCustomConfigUnavailable,
uploadDisabled,
workspaceLogo,
canManageCustomBrand,
@@ -36,7 +37,7 @@ const CustomWebAppBrand = () => {
<Switch
size="lg"
checked={webappBrandRemoved ?? false}
disabled={isSandbox || !canManageCustomBrand}
disabled={isCustomConfigUnavailable || isSandbox || !canManageCustomBrand}
onCheckedChange={handleSwitch}
/>
</div>
@@ -105,7 +106,7 @@ const CustomWebAppBrand = () => {
variant="primary"
className="mr-2"
onClick={handleApply}
disabled={webappBrandRemoved || !canManageCustomBrand}
disabled={isCustomConfigUnavailable || webappBrandRemoved || !canManageCustomBrand}
>
{t(($) => $.apply, { ns: 'custom' })}
</Button>
@@ -122,6 +122,11 @@ vi.mock('@/app/components/billing/billing-page', () => ({
default: () => <div data-testid="billing-page" />,
}))
vi.mock('@/app/components/custom/custom-page', () => ({
__esModule: true,
default: () => <div>custom.custom</div>,
}))
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
__esModule: true,
default: () => <div data-testid="data-source-page" />,
@@ -151,13 +156,7 @@ const baseConsoleState: ConsoleStateFixture = {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
@@ -1,6 +1,6 @@
import type { ReactElement } from 'react'
import type { Role } from '@/models/access-control'
import type { ICurrentWorkspace, Member } from '@/models/common'
import type { Member } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -227,7 +227,7 @@ describe('MembersPage', () => {
setConsoleState({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace', role: 'owner' },
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['workspace.member.manage'],
@@ -346,7 +346,7 @@ describe('MembersPage', () => {
it('should hide manager controls for non-owner non-manager users', () => {
setConsoleState({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace', role: 'admin' },
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as ConsoleStateFixture)
@@ -459,7 +459,7 @@ describe('MembersPage', () => {
it('should show invite button when user is manager but not owner', () => {
setConsoleState({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace', role: 'admin' },
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['workspace.member.manage'],
@@ -474,7 +474,7 @@ describe('MembersPage', () => {
it('should allow admins to operate other non-owner members only', () => {
setConsoleState({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace', role: 'admin' },
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['workspace.member.manage'],
@@ -580,7 +580,7 @@ describe('MembersPage', () => {
it('should render role badge names from account roles', () => {
setConsoleState({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace', role: 'admin' },
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as ConsoleStateFixture)
@@ -651,7 +651,7 @@ describe('MembersPage', () => {
const user = userEvent.setup()
setConsoleState({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace', role: 'admin' },
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['workspace.member.manage'],
@@ -1,4 +1,3 @@
import type { ICurrentWorkspace } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { screen } from '@testing-library/react'
import { vi } from 'vitest'
@@ -39,7 +38,7 @@ describe('InviteButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConsoleStateReader.mockReturnValue({
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
currentWorkspace: { id: 'workspace-id' },
} as unknown as ConsoleStateFixture)
})
@@ -1,4 +1,3 @@
import type { ICurrentWorkspace } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -43,7 +42,7 @@ describe('EditWorkspaceModal', () => {
vi.clearAllMocks()
const consoleState = {
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: true,
} as unknown as ConsoleStateFixture
mockConsoleState.current = consoleState
@@ -93,7 +92,10 @@ describe('EditWorkspaceModal', () => {
assign: mockAssign,
origin: 'http://localhost',
})
vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace)
vi.mocked(updateWorkspaceInfo).mockResolvedValue({
result: 'success',
tenant: { id: 'workspace-id' },
})
renderModal()
@@ -170,7 +172,7 @@ describe('EditWorkspaceModal', () => {
it('should disable confirm button for non-owners', async () => {
mockConsoleStateReader.mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: false,
} as unknown as ConsoleStateFixture)
@@ -1,4 +1,3 @@
import type { ICurrentWorkspace } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -53,7 +52,7 @@ describe('TransferOwnershipModal', () => {
vi.clearAllMocks()
const consoleState = {
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
currentWorkspace: { name: 'Test Workspace' },
userProfile: { email: 'owner@example.com', id: 'owner-id' },
} as unknown as ConsoleStateFixture
mockConsoleState.current = consoleState
@@ -3,12 +3,15 @@ import type {
StepByStepTourStatePatchPayload,
StepByStepTourStateResponse,
} from '@dify/contracts/api/console/onboarding/types.gen'
import type {
GetWorkspacesCurrentSummaryResponse,
TenantListItemResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { ICurrentWorkspace, IWorkspace } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
@@ -238,7 +241,7 @@ vi.mock('react-i18next', async () => {
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'post'] as const
const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'summary', 'get'] as const
const currentPermissionsQueryKey = [
['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'],
{ type: 'query' },
@@ -249,14 +252,16 @@ vi.mock('@/service/client', async (importOriginal) => {
if (prop === 'workspaces') {
return {
current: {
post: {
key: () => currentWorkspaceQueryKey,
queryKey: () => currentWorkspaceQueryKey,
queryOptions: (options?: object) => ({
queryKey: currentWorkspaceQueryKey,
queryFn: () => new Promise(() => {}),
...options,
}),
summary: {
get: {
key: () => currentWorkspaceQueryKey,
queryKey: () => currentWorkspaceQueryKey,
queryOptions: (options?: object) => ({
queryKey: currentWorkspaceQueryKey,
queryFn: () => new Promise(() => {}),
...options,
}),
},
},
rbac: {
myPermissions: {
@@ -392,7 +397,7 @@ let mockPathname = '/apps'
let mockInstalledApps: InstalledAppResponse[] = []
let mockInstalledAppsPending = false
let mockInstalledAppsHasNextPage = false
let mockWorkspaces: IWorkspace[] = []
let mockWorkspaces: TenantListItemResponse[] = []
const ownerWorkspacePermissionKeys = [
'workspace.member.manage',
@@ -450,13 +455,8 @@ const consoleState: ConsoleStateFixture = {
id: 'workspace-1',
name: 'Solar Studio',
plan: Plan.team,
status: 'normal',
created_at: 0,
credits: 7500,
role: 'owner',
providers: [],
trial_credits: 10000,
trial_credits_used: 2500,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
@@ -497,8 +497,8 @@ const renderMainNav = (
const currentConsoleState = mockConsoleState.current ?? consoleState
mockConsoleState.current = currentConsoleState
queryClient.setQueryData(
consoleQuery.workspaces.current.post.queryKey(),
currentConsoleState.currentWorkspace as ICurrentWorkspace,
consoleQuery.workspaces.current.summary.get.queryKey(),
currentConsoleState.currentWorkspace as GetWorkspacesCurrentSummaryResponse,
)
queryClient.setQueryData(userProfileQueryOptions().queryKey, {
profile: {
@@ -1,7 +1,9 @@
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type {
GetWorkspacesCurrentSummaryResponse,
TenantListItemResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { IWorkspace } from '@/models/common'
import { zLicenseStatus } from '@dify/contracts/api/console/system-features/zod.gen'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -25,7 +27,7 @@ const {
} = vi.hoisted(() => ({
mockFetchWorkspaces: vi.fn(),
mockSwitchWorkspace: vi.fn(),
mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const,
mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'summary', 'get'] as const,
mockWorkspacesQueryKey: ['console', 'workspaces', 'get'] as const,
}))
const mockConsoleState = vi.hoisted(() => ({
@@ -58,14 +60,16 @@ vi.mock('@/service/client', async (importOriginal) => {
if (prop === 'workspaces') {
return {
current: {
post: {
key: () => mockCurrentWorkspaceQueryKey,
queryKey: () => mockCurrentWorkspaceQueryKey,
queryOptions: (options?: object) => ({
queryKey: mockCurrentWorkspaceQueryKey,
queryFn: () => new Promise(() => {}),
...options,
}),
summary: {
get: {
key: () => mockCurrentWorkspaceQueryKey,
queryKey: () => mockCurrentWorkspaceQueryKey,
queryOptions: (options?: object) => ({
queryKey: mockCurrentWorkspaceQueryKey,
queryFn: () => new Promise(() => {}),
...options,
}),
},
},
},
get: {
@@ -96,17 +100,12 @@ vi.mock('@/service/client', async (importOriginal) => {
}
})
const currentWorkspaceValue: PostWorkspacesCurrentResponse = {
const currentWorkspaceValue: GetWorkspacesCurrentSummaryResponse = {
id: 'workspace-1',
name: 'Solar Studio',
plan: Plan.sandbox,
status: 'normal',
created_at: 0,
role: 'owner',
trial_credits: 10000,
trial_credits_used: 2500,
trial_credits_exhausted_at: 0,
next_credit_reset_date: 0,
credits: 7500,
}
const mockSetShowPricingModal = vi.fn()
@@ -115,11 +114,11 @@ vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
let mockCurrentWorkspace: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue
let mockWorkspaces: IWorkspace[] = []
let mockCurrentWorkspace: GetWorkspacesCurrentSummaryResponse | undefined = currentWorkspaceValue
let mockWorkspaces: TenantListItemResponse[] = []
const mockCurrentWorkspaceQuery = (
data: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue,
data: GetWorkspacesCurrentSummaryResponse | undefined = currentWorkspaceValue,
isPending = false,
) => {
mockCurrentWorkspace = isPending ? undefined : data
@@ -134,7 +133,10 @@ const renderWorkspaceCard = (options?: RenderWorkspaceCardOptions) => {
const { seedWorkspaces = true, systemFeaturesLicense, ...renderOptions } = options ?? {}
const queryClient = createConsoleQueryClient()
if (mockCurrentWorkspace)
queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), mockCurrentWorkspace)
queryClient.setQueryData(
consoleQuery.workspaces.current.summary.get.queryKey(),
mockCurrentWorkspace,
)
if (seedWorkspaces)
queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces })
if (systemFeaturesLicense) seedSystemFeaturesLicense(queryClient, systemFeaturesLicense)
@@ -209,6 +211,24 @@ describe('WorkspaceCard', () => {
).toHaveAttribute('href', '/integrations/model-provider')
})
it('renders unlimited credits from the summary contract', () => {
mockCurrentWorkspaceQuery({ ...currentWorkspaceValue, credits: -1 })
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
expect(screen.getByText('common.license.unlimited')).toBeInTheDocument()
})
it('hides the credits link when the summary has no effective credits', () => {
mockCurrentWorkspaceQuery({ ...currentWorkspaceValue, credits: null })
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
expect(
screen.queryByRole('link', { name: /common\.mainNav\.workspace\.credits/ }),
).not.toBeInTheDocument()
})
it('renders a stable skeleton while the current workspace is loading', () => {
mockCurrentWorkspaceQuery(undefined, true)
@@ -1,6 +1,6 @@
'use client'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
@@ -25,7 +25,7 @@ import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
import { basePath } from '@/utils/var'
import { formatCredits, getRemainingCredits } from '../utils'
import { formatCredits } from '../utils'
import { WorkspaceMenuItemContent } from './workspace-menu-content'
import WorkspacePlanBadge from './workspace-plan-badge'
import { WorkspaceSwitcher } from './workspace-switcher'
@@ -73,13 +73,13 @@ function WorkspaceCardSkeleton({
)
}
function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit: string }) {
const label = `${credits} ${unit}`
function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit?: string }) {
const label = [credits, unit].filter(Boolean).join(' ')
return (
<span className="flex min-w-0 flex-1 items-baseline gap-0.5" title={label}>
<span className="shrink-0 system-xs-medium">{credits}</span>
<span className="min-w-0 truncate system-xs-regular">{unit}</span>
{unit && <span className="min-w-0 truncate system-xs-regular">{unit}</span>}
</span>
)
}
@@ -97,7 +97,7 @@ function WorkspaceCardTrigger({
}: {
name: string
status: ReactNode
credits: number
credits: number | null
showCloudBilling: boolean
showPlanAction: boolean
planActionLabel: string
@@ -107,7 +107,12 @@ function WorkspaceCardTrigger({
}) {
const { t } = useTranslation()
const creditsUnit = t(($) => $['mainNav.workspace.creditsUnit'], { ns: 'common' })
const formattedCredits = formatCredits(credits)
const isUnlimited = credits === -1
const formattedCredits = isUnlimited
? t(($) => $['license.unlimited'], { ns: 'common' })
: credits === null
? ''
: formatCredits(credits)
const showStatus = status !== undefined && status !== null
return (
@@ -142,14 +147,23 @@ function WorkspaceCardTrigger({
</PopoverTrigger>
{showCloudBilling && (
<div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2">
<Link
href={creditsHref}
className="flex min-w-0 flex-1 items-center gap-0.5 px-1 text-left text-text-tertiary transition-colors hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
aria-label={t(($) => $['mainNav.workspace.credits'], { ns: 'common', count: credits })}
>
<span className="i-custom-vender-main-nav-credits h-3 w-3 shrink-0" aria-hidden />
<WorkspaceCreditsLabel credits={formattedCredits} unit={creditsUnit} />
</Link>
{credits !== null && (
<Link
href={creditsHref}
className="flex min-w-0 flex-1 items-center gap-0.5 px-1 text-left text-text-tertiary transition-colors hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
aria-label={
isUnlimited
? formattedCredits
: t(($) => $['mainNav.workspace.credits'], { ns: 'common', count: credits })
}
>
<span className="i-custom-vender-main-nav-credits h-3 w-3 shrink-0" aria-hidden />
<WorkspaceCreditsLabel
credits={formattedCredits}
unit={isUnlimited ? undefined : creditsUnit}
/>
</Link>
)}
{showPlanAction && (
<button
type="button"
@@ -228,15 +242,15 @@ function WorkspaceMenuHeader({
}
type CurrentWorkspaceCardSource = Pick<
PostWorkspacesCurrentResponse,
'id' | 'name' | 'plan' | 'trial_credits' | 'trial_credits_used'
GetWorkspacesCurrentSummaryResponse,
'id' | 'name' | 'plan' | 'credits'
>
const selectCurrentWorkspaceCardData = (workspace: CurrentWorkspaceCardSource) => ({
id: workspace.id,
name: workspace.name,
plan: workspace.plan,
credits: getRemainingCredits(workspace.trial_credits ?? 0, workspace.trial_credits_used ?? 0),
credits: workspace.credits,
})
export function WorkspaceCard() {
@@ -247,7 +261,7 @@ export function WorkspaceCard() {
select: ({ deployment_edition }) => deployment_edition,
})
const currentWorkspaceQuery = useQuery(
consoleQuery.workspaces.current.post.queryOptions({
consoleQuery.workspaces.current.summary.get.queryOptions({
select: selectCurrentWorkspaceCardData,
}),
)
-2
View File
@@ -1,3 +1 @@
export const getRemainingCredits = (total: number, used: number) => Math.max(total - used, 0)
export const formatCredits = (value: number) => new Intl.NumberFormat().format(value)
@@ -2,8 +2,8 @@ import type {
StepByStepTourStatePatchPayload,
StepByStepTourStateResponse,
} from '@dify/contracts/api/console/onboarding/types.gen'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { StepByStepTourSessionState, StepByStepTourTaskId } from '../types'
import type { ICurrentWorkspace } from '@/models/common'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
@@ -33,8 +33,6 @@ type StepByStepTourFixtureState = StepByStepTourSessionState & {
updatedAt?: string | null
}
type WorkspaceRole = ICurrentWorkspace['role']
const mockRouterPush = vi.fn()
const mockTrackEvent = vi.hoisted(() => vi.fn())
let mockPathname = '/apps'
@@ -54,7 +52,7 @@ const mockIsCurrentWorkspaceManager = vi.hoisted(() => ({
value: true,
}))
const mockCurrentWorkspaceRole = vi.hoisted(() => ({
value: 'owner' as WorkspaceRole,
value: 'owner' as GetWorkspacesCurrentSummaryResponse['role'],
}))
const mockEnableLearnApp = vi.hoisted(() => ({
value: true,
@@ -404,13 +402,7 @@ function getMockAppContextState() {
id: 'workspace-1',
name: 'Solar Studio',
plan: Plan.sandbox,
status: 'normal',
role: mockCurrentWorkspaceRole.value,
created_at: 0,
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager.value,
workspacePermissionKeys: mockWorkspacePermissionKeys.value,
+5 -3
View File
@@ -53,8 +53,10 @@ export default function DevicePage() {
refetchOnMount: false,
})
const account = userResp?.profile
const { data: currentWorkspace } = useQuery({
...consoleQuery.workspaces.current.post.queryOptions(),
const { data: currentWorkspaceName } = useQuery({
...consoleQuery.workspaces.current.summary.get.queryOptions({
select: (workspace) => workspace.name,
}),
enabled: !!account && !profileErr,
retry: false,
refetchOnWindowFocus: false,
@@ -175,7 +177,7 @@ export default function DevicePage() {
accountEmail={account?.email}
accountName={account?.name}
accountAvatarUrl={account?.avatar_url ?? null}
defaultWorkspace={currentWorkspace?.name ?? undefined}
defaultWorkspace={currentWorkspaceName ?? undefined}
onApproved={() => setView({ kind: 'success' })}
onDenied={() => setView({ kind: 'error_expired' })}
onError={(e) => setErrMsg(e)}
@@ -1,9 +1,11 @@
'use client'
import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type {
GetWorkspacesCurrentSummaryResponse,
TenantListItemResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace } from '@/models/common'
import { Select, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
@@ -12,7 +14,7 @@ import { PlanBadge } from '@/app/components/header/plan-badge'
type AppliedEducationContentProps = {
workspaces: TenantListItemResponse[]
currentWorkspace: ICurrentWorkspace
currentWorkspace: GetWorkspacesCurrentSummaryResponse
plan: PlanType
action: ReactNode
isSwitchingWorkspace: boolean
@@ -1,8 +1,8 @@
'use client'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { Plan as PlanType } from '@/app/components/billing/type'
import type { ICurrentWorkspace } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { toast } from '@langgenius/dify-ui/toast'
@@ -292,7 +292,7 @@ const EducationApplyAgeContent = () => {
}
type AppliedEducationWorkspaceBlockProps = {
currentWorkspace: ICurrentWorkspace
currentWorkspace: GetWorkspacesCurrentSummaryResponse
plan: PlanType
action: ReactNode
isSwitchingWorkspace: boolean
@@ -14,7 +14,7 @@ import { setZendeskConversationFields } from '@/app/components/base/zendesk/util
import { ZENDESK_FIELD_IDS } from '@/config'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import { refreshUserProfileAtom, userProfileAtom } from '../account-state'
import { initialWorkspaceInfo } from '../app-context-defaults'
import { initialWorkspaceSummary } from '../app-context-defaults'
import {
datasetDefaultPermissionKeysAtom,
refreshWorkspacePermissionKeysAfterMutationDenialAtom,
@@ -29,7 +29,6 @@ import {
isCurrentWorkspaceEditorAtom,
isCurrentWorkspaceManagerAtom,
isCurrentWorkspaceOwnerAtom,
refreshCurrentWorkspaceAtom,
} from '../workspace-state'
const mockGetRequest = vi.hoisted(() => vi.fn())
@@ -43,13 +42,8 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
id: 'workspace-1',
name: 'Workspace',
plan: 'sandbox',
status: 'normal',
created_at: 1704067200,
credits: 200,
role: 'editor',
trial_credits: 200,
trial_credits_used: 0,
next_credit_reset_date: 1706745600,
custom_config: {},
}))
const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({
data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined,
@@ -143,19 +137,21 @@ vi.mock('@/service/client', () => ({
},
workspaces: {
current: {
post: {
key: () => ['current-workspace'],
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryKey: ['current-workspace'],
queryFn: async () => {
if (mockCurrentWorkspaceQueryState.isPending) return new Promise(() => {})
summary: {
get: {
key: () => ['current-workspace-summary'],
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryKey: ['current-workspace-summary'],
queryFn: async () => {
if (mockCurrentWorkspaceQueryState.isPending) return new Promise(() => {})
return mockCurrentWorkspaceQueryState.data
},
...options,
}),
return mockCurrentWorkspaceQueryState.data
},
...options,
}),
},
},
rbac: {
myPermissions: {
@@ -227,7 +223,6 @@ function ConsoleBootstrapProbe() {
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
const refreshPermissionsAfterMutationDenial = useSetAtom(
refreshWorkspacePermissionKeysAfterMutationDenialAtom,
)
@@ -286,9 +281,6 @@ function ConsoleBootstrapProbe() {
<button type="button" onClick={refreshUserProfile}>
refresh user
</button>
<button type="button" onClick={refreshCurrentWorkspace}>
refresh workspace
</button>
<button type="button" onClick={() => void refreshPermissionsAfterMutationDenial()}>
refresh permissions after denial
</button>
@@ -425,8 +417,8 @@ describe('Console bootstrap', () => {
renderConsoleBootstrap()
expect(await screen.findByText('user:user@example.com')).toBeInTheDocument()
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
expect(screen.getByText(`workspace:${initialWorkspaceSummary.name}`)).toBeInTheDocument()
expect(screen.getByText(`role:${initialWorkspaceSummary.role}`)).toBeInTheDocument()
expect(screen.getByText('keys:')).toBeInTheDocument()
expect(screen.getByText('dataset keys:')).toBeInTheDocument()
expect(screen.getByText('version://')).toBeInTheDocument()
@@ -440,7 +432,7 @@ describe('Console bootstrap', () => {
renderConsoleBootstrap()
expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
expect(await screen.findByText(`role:${initialWorkspaceSummary.role}`)).toBeInTheDocument()
})
it('should derive role flags from the current workspace role', async () => {
@@ -469,15 +461,13 @@ describe('Console bootstrap', () => {
})
describe('Refresh actions', () => {
it('should invalidate the source queries when refresh actions are called', async () => {
it('should invalidate the user profile query when refresh is called', async () => {
const { queryClient } = renderConsoleBootstrap()
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: /refresh user/i }))
fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i }))
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] })
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] })
})
it('starts a fresh permission request without waiting for an older request', async () => {
+3 -3
View File
@@ -1,7 +1,7 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { ICurrentWorkspace } from '@/models/common'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { atom } from 'jotai'
import { atomEffect } from 'jotai-effect'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
@@ -17,7 +17,7 @@ function buildAmplitudeProperties({
currentWorkspace,
userProfile,
}: {
currentWorkspace: ICurrentWorkspace
currentWorkspace: GetWorkspacesCurrentSummaryResponse
userProfile: GetAccountProfileResponse
}) {
const properties: AmplitudeProperties = {
@@ -29,7 +29,7 @@ function buildAmplitudeProperties({
if (currentWorkspace.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
if (currentWorkspace.plan) properties.workspace_plan = currentWorkspace.plan
properties.workspace_role = currentWorkspace.role
}
+4 -10
View File
@@ -1,5 +1,5 @@
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { LangGeniusVersionInfo } from './app-context-types'
import type { ICurrentWorkspace } from '@/models/common'
export const initialLangGeniusVersionInfo: LangGeniusVersionInfo = {
current_env: '',
@@ -15,16 +15,10 @@ export const initialLangGeniusVersionInfo: LangGeniusVersionInfo = {
can_auto_update: false,
}
export const initialWorkspaceInfo: ICurrentWorkspace = {
export const initialWorkspaceSummary: GetWorkspacesCurrentSummaryResponse = {
id: '',
name: '',
plan: '',
status: '',
created_at: 0,
plan: null,
credits: null,
role: 'normal',
providers: [],
trial_credits: 200,
trial_credits_used: 0,
trial_credits_exhausted_at: 0,
next_credit_reset_date: 0,
}
+17 -32
View File
@@ -1,10 +1,9 @@
import type { GetVersionResponse } from '@dify/contracts/api/console/version/types.gen'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { LangGeniusVersionInfo } from './app-context-types'
import type { ICurrentWorkspace } from '@/models/common'
import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context-defaults'
import { initialLangGeniusVersionInfo, initialWorkspaceSummary } from './app-context-defaults'
const workspaceRoles = new Set<ICurrentWorkspace['role']>([
const workspaceRoles = new Set<GetWorkspacesCurrentSummaryResponse['role']>([
'owner',
'admin',
'editor',
@@ -27,44 +26,30 @@ export type ProfileMeta = {
}
function resolveWorkspaceRole(
role: PostWorkspacesCurrentResponse['role'],
): ICurrentWorkspace['role'] {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
role: GetWorkspacesCurrentSummaryResponse['role'],
): GetWorkspacesCurrentSummaryResponse['role'] {
if (workspaceRoles.has(role)) return role
return initialWorkspaceInfo.role
return initialWorkspaceSummary.role
}
export function normalizeCurrentWorkspace(
workspace?: PostWorkspacesCurrentResponse,
): ICurrentWorkspace {
if (!workspace) return initialWorkspaceInfo
export function normalizeCurrentWorkspaceSummary(
workspace?: GetWorkspacesCurrentSummaryResponse,
): GetWorkspacesCurrentSummaryResponse {
if (!workspace) return initialWorkspaceSummary
return {
id: workspace.id,
name: workspace.name ?? initialWorkspaceInfo.name,
plan: workspace.plan ?? initialWorkspaceInfo.plan,
status: workspace.status ?? initialWorkspaceInfo.status,
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
name: workspace.name,
plan: workspace.plan,
credits: workspace.credits,
role: resolveWorkspaceRole(workspace.role),
providers: initialWorkspaceInfo.providers,
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
trial_credits_exhausted_at:
workspace.trial_credits_exhausted_at ?? initialWorkspaceInfo.trial_credits_exhausted_at,
next_credit_reset_date:
workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
trial_end_reason: workspace.trial_end_reason ?? undefined,
custom_config: workspace.custom_config
? {
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
}
: undefined,
}
}
export function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags {
export function getWorkspaceRoleFlags(
currentWorkspace: GetWorkspacesCurrentSummaryResponse,
): WorkspaceRoleFlags {
return {
isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role),
isCurrentWorkspaceOwner: currentWorkspace.role === 'owner',
+6 -11
View File
@@ -1,19 +1,19 @@
'use client'
import { atom } from 'jotai'
import { atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { initialWorkspaceInfo } from './app-context-defaults'
import { getWorkspaceRoleFlags, normalizeCurrentWorkspace } from './app-context-normalizers'
import { initialWorkspaceSummary } from './app-context-defaults'
import { getWorkspaceRoleFlags, normalizeCurrentWorkspaceSummary } from './app-context-normalizers'
const currentWorkspaceQueryAtom = atomWithQuery(() => {
return consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
return consoleQuery.workspaces.current.summary.get.queryOptions({
select: normalizeCurrentWorkspaceSummary,
})
})
export const currentWorkspaceAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo
return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceSummary
})
export const currentWorkspaceIdAtom = atom((get) => {
@@ -43,8 +43,3 @@ export const isCurrentWorkspaceDatasetOperatorAtom = atom((get) => {
export const currentWorkspaceLoadingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isPending
})
export const refreshCurrentWorkspaceAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
})
-55
View File
@@ -26,61 +26,6 @@ export type Member = Pick<GetAccountProfileResponse, 'id' | 'name' | 'email' | '
roles: Role[]
}
const ProviderName = {
OPENAI: 'openai',
AZURE_OPENAI: 'azure_openai',
ANTHROPIC: 'anthropic',
Replicate: 'replicate',
HuggingfaceHub: 'huggingface_hub',
MiniMax: 'minimax',
Spark: 'spark',
Tongyi: 'tongyi',
ChatGLM: 'chatglm',
} as const
type ProviderName = (typeof ProviderName)[keyof typeof ProviderName]
type ProviderAzureToken = {
openai_api_base?: string
openai_api_key?: string
}
type ProviderAnthropicToken = {
anthropic_api_key?: string
}
type Provider = {
[Name in ProviderName]: {
provider_name: Name
} & {
provider_type: 'custom' | 'system'
is_valid: boolean
is_enabled: boolean
last_used: string
token?: string | ProviderAzureToken | ProviderAnthropicToken
}
}[ProviderName]
export type IWorkspace = {
id: string
name: string
plan: string
status: string
created_at: number
last_opened_at?: number | null
current: boolean
}
export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
role: 'owner' | 'admin' | 'editor' | 'dataset_operator' | 'normal'
providers: Provider[]
trial_credits: number
trial_credits_used: number
trial_credits_exhausted_at: number
next_credit_reset_date: number
trial_end_reason?: string
custom_config?: {
remove_webapp_brand?: boolean
replace_webapp_logo?: string
}
}
export type DataSourceNotionPage = {
page_icon: null | {
type: string | null
+8 -15
View File
@@ -1,3 +1,7 @@
import type {
PostWorkspacesInfoData,
PostWorkspacesInfoResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type {
DefaultModelResponse,
Model,
@@ -7,7 +11,6 @@ import type {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
CommonResponse,
ICurrentWorkspace,
InitValidateStatusResponse,
SetupStatusResponse,
} from '@/models/common'
@@ -105,24 +108,14 @@ export const ownershipTransfer = (
export const fetchFilePreview = ({ fileID }: { fileID: string }): Promise<{ content: string }> => {
return get<{ content: string }>(`/files/${fileID}/preview`)
}
export const updateCurrentWorkspace = ({
url,
body,
}: {
url: string
body: Record<string, any>
}): Promise<ICurrentWorkspace> => {
return post<ICurrentWorkspace>(url, { body })
}
export const updateWorkspaceInfo = ({
url,
body,
}: {
url: string
body: Record<string, any>
}): Promise<ICurrentWorkspace> => {
return post<ICurrentWorkspace>(url, { body })
url: PostWorkspacesInfoData['url']
body: PostWorkspacesInfoData['body']
}): Promise<PostWorkspacesInfoResponse> => {
return post<PostWorkspacesInfoResponse>(url, { body })
}
type InvitationCheckData = {
+6 -9
View File
@@ -1,17 +1,14 @@
import type { ICurrentWorkspace } from '@/models/common'
import type { GetWorkspacesCurrentPermissionResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { useQuery } from '@tanstack/react-query'
import { get } from './base'
type WorkspacePermissions = {
workspace_id: ICurrentWorkspace['id']
allow_member_invite: boolean
allow_owner_transfer: boolean
}
export function useWorkspacePermissions(workspaceId: ICurrentWorkspace['id'], enabled: boolean) {
export function useWorkspacePermissions(
workspaceId: GetWorkspacesCurrentPermissionResponse['workspace_id'],
enabled: boolean,
) {
return useQuery({
queryKey: ['workspace-permissions', workspaceId],
queryFn: () => get<WorkspacePermissions>('/workspaces/current/permission'),
queryFn: () => get<GetWorkspacesCurrentPermissionResponse>('/workspaces/current/permission'),
enabled: enabled && !!workspaceId,
})
}
+9 -14
View File
@@ -1,30 +1,25 @@
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { QueryClient, QueryKey } from '@tanstack/react-query'
export const currentWorkspaceQueryKey = [
['console', 'workspaces', 'current', 'post'],
['console', 'workspaces', 'current', 'summary', 'get'],
{ type: 'query' },
] as const
const createCurrentWorkspaceFixture = (
overrides: Partial<PostWorkspacesCurrentResponse> = {},
): PostWorkspacesCurrentResponse => ({
overrides: Partial<GetWorkspacesCurrentSummaryResponse> = {},
): GetWorkspacesCurrentSummaryResponse => ({
id: 'workspace-1',
name: 'Workspace',
plan: '',
plan: null,
role: 'owner',
status: '',
created_at: 0,
trial_credits: 0,
trial_credits_used: 0,
trial_credits_exhausted_at: 0,
next_credit_reset_date: 0,
credits: null,
...overrides,
})
export const seedCurrentWorkspaceQuery = (
queryClient: QueryClient,
workspace: Partial<PostWorkspacesCurrentResponse> = {},
workspace: Partial<GetWorkspacesCurrentSummaryResponse> = {},
queryKey: QueryKey = currentWorkspaceQueryKey,
) => {
const data = createCurrentWorkspaceFixture(workspace)
@@ -34,10 +29,10 @@ export const seedCurrentWorkspaceQuery = (
export const ensureCurrentWorkspaceQuery = (
queryClient: QueryClient,
workspace: Partial<PostWorkspacesCurrentResponse> = {},
workspace: Partial<GetWorkspacesCurrentSummaryResponse> = {},
queryKey: QueryKey = currentWorkspaceQueryKey,
) => {
const existingWorkspace = queryClient.getQueryData<PostWorkspacesCurrentResponse>(queryKey)
const existingWorkspace = queryClient.getQueryData<GetWorkspacesCurrentSummaryResponse>(queryKey)
if (existingWorkspace === undefined)
return seedCurrentWorkspaceQuery(queryClient, workspace, queryKey)
+6 -4
View File
@@ -3,7 +3,7 @@ import type {
GetSystemFeaturesLicenseResponse,
GetSystemFeaturesResponse,
} from '@dify/contracts/api/console/system-features/types.gen'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type {
RenderHookOptions,
@@ -45,7 +45,9 @@ type AppDslVersionQueryProvider = {
}
type CurrentWorkspaceQueryProvider = {
post?: QueryKeyProvider
summary?: {
get?: QueryKeyProvider
}
}
const fallbackTrialModelsQueryKey = ['console', 'trialModels', 'get'] as const
@@ -71,7 +73,7 @@ const getCurrentWorkspaceQueryKey = () => {
}
).workspaces?.current
return currentWorkspaceQuery?.post?.queryKey() ?? currentWorkspaceQueryKey
return currentWorkspaceQuery?.summary?.get?.queryKey() ?? currentWorkspaceQueryKey
}
/**
@@ -140,7 +142,7 @@ export type ConsoleQueryTestOptions = {
*/
systemFeatures?: DeepPartial<GetSystemFeaturesResponse> | null
accountProfile?: Partial<GetAccountProfileResponse> | null
currentWorkspace?: Partial<PostWorkspacesCurrentResponse> | null
currentWorkspace?: Partial<GetWorkspacesCurrentSummaryResponse> | null
trialModels?: readonly string[] | null
workspacePermissionKeys?: readonly string[] | null
/**
+6 -17
View File
@@ -1,6 +1,6 @@
import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { createStore } from 'jotai'
import type { LangGeniusVersionInfo } from '@/context/app-context-types'
import type { ICurrentWorkspace } from '@/models/common'
import { atom } from 'jotai'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
@@ -13,12 +13,7 @@ export type ConsoleStateFixture = {
avatar_url?: string | null
is_password_set?: boolean
} | null
currentWorkspace?:
| ({
id?: string
name?: string
} & Partial<ICurrentWorkspace>)
| null
currentWorkspace?: Partial<GetWorkspacesCurrentSummaryResponse> | null
isCurrentWorkspaceManager?: boolean
isCurrentWorkspaceOwner?: boolean
isCurrentWorkspaceEditor?: boolean
@@ -51,16 +46,10 @@ const defaultUserProfile = {
const defaultCurrentWorkspace = {
id: 'workspace-1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
plan: null,
credits: null,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
trial_credits_exhausted_at: 0,
next_credit_reset_date: 0,
} satisfies ICurrentWorkspace
} satisfies GetWorkspacesCurrentSummaryResponse
const defaultLangGeniusVersionInfo = {
current_env: 'CLOUD',
@@ -83,7 +72,7 @@ const accountProfileMetaAtom = atom({ currentVersion: null, currentEnv: null })
const refreshUserProfileCallbackAtom = atom({ callback: () => {} })
const refreshUserProfileAtom = atom(null, (get) => get(refreshUserProfileCallbackAtom).callback())
const currentWorkspaceAtom = atom<ICurrentWorkspace>(defaultCurrentWorkspace)
const currentWorkspaceAtom = atom<GetWorkspacesCurrentSummaryResponse>(defaultCurrentWorkspace)
const currentWorkspaceIdAtom = atom((get) => get(currentWorkspaceAtom).id)
const isCurrentWorkspaceManagerAtom = atom(false)
const isCurrentWorkspaceOwnerAtom = atom(false)