mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-08-14 09:46:06 +08:00
E-300 (#19726)
Signed-off-by: -LAN- <laipz8200@outlook.com> Co-authored-by: Hash Brown <hi@xzd.me> Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: GareArc <chen4851@purdue.edu> Co-authored-by: Byron.wang <byron@dify.ai> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: Garfield Dai <dai.hai@foxmail.com> Co-authored-by: KVOJJJin <jzongcode@gmail.com> Co-authored-by: Alexi.F <654973939@qq.com> Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Co-authored-by: kautsar_masuara <61046989+izon-masuara@users.noreply.github.com> Co-authored-by: achmad-kautsar <achmad.kautsar@insignia.co.id> Co-authored-by: Xin Zhang <sjhpzx@gmail.com> Co-authored-by: kelvintsim <83445753+kelvintsim@users.noreply.github.com> Co-authored-by: zxhlyh <jasonapring2015@outlook.com> Co-authored-by: Zixuan Cheng <61724187+Theysua@users.noreply.github.com>
This commit is contained in:
parent
6a8ca8296b
commit
d186daa131
1
.github/workflows/style.yml
vendored
1
.github/workflows/style.yml
vendored
@ -139,6 +139,7 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
|
@ -17,15 +17,13 @@ from controllers.console.wraps import (
|
|||||||
)
|
)
|
||||||
from core.ops.ops_trace_manager import OpsTraceManager
|
from core.ops.ops_trace_manager import OpsTraceManager
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.app_fields import (
|
from fields.app_fields import app_detail_fields, app_detail_fields_with_site, app_pagination_fields
|
||||||
app_detail_fields,
|
|
||||||
app_detail_fields_with_site,
|
|
||||||
app_pagination_fields,
|
|
||||||
)
|
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import Account, App
|
from models import Account, App
|
||||||
from services.app_dsl_service import AppDslService, ImportMode
|
from services.app_dsl_service import AppDslService, ImportMode
|
||||||
from services.app_service import AppService
|
from services.app_service import AppService
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||||
|
|
||||||
@ -75,7 +73,17 @@ class AppListApi(Resource):
|
|||||||
if not app_pagination:
|
if not app_pagination:
|
||||||
return {"data": [], "total": 0, "page": 1, "limit": 20, "has_more": False}
|
return {"data": [], "total": 0, "page": 1, "limit": 20, "has_more": False}
|
||||||
|
|
||||||
return marshal(app_pagination, app_pagination_fields)
|
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
|
app_ids = [str(app.id) for app in app_pagination.items]
|
||||||
|
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
|
||||||
|
if len(res) != len(app_ids):
|
||||||
|
raise BadRequest("Invalid app id in webapp auth")
|
||||||
|
|
||||||
|
for app in app_pagination.items:
|
||||||
|
if str(app.id) in res:
|
||||||
|
app.access_mode = res[str(app.id)].access_mode
|
||||||
|
|
||||||
|
return marshal(app_pagination, app_pagination_fields), 200
|
||||||
|
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@ -119,6 +127,10 @@ class AppApi(Resource):
|
|||||||
|
|
||||||
app_model = app_service.get_app(app_model)
|
app_model = app_service.get_app(app_model)
|
||||||
|
|
||||||
|
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
|
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||||
|
app_model.access_mode = app_setting.access_mode
|
||||||
|
|
||||||
return app_model
|
return app_model
|
||||||
|
|
||||||
@setup_required
|
@setup_required
|
||||||
|
@ -24,7 +24,7 @@ from libs.password import hash_password, valid_password
|
|||||||
from models.account import Account
|
from models.account import Account
|
||||||
from services.account_service import AccountService, TenantService
|
from services.account_service import AccountService, TenantService
|
||||||
from services.errors.account import AccountRegisterError
|
from services.errors.account import AccountRegisterError
|
||||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
@ -119,6 +119,9 @@ class ForgotPasswordResetApi(Resource):
|
|||||||
if not reset_data:
|
if not reset_data:
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
# Must use token in reset phase
|
# Must use token in reset phase
|
||||||
|
if reset_data.get("phase", "") != "reset":
|
||||||
|
raise InvalidTokenError()
|
||||||
|
# Must use token in reset phase
|
||||||
if reset_data.get("phase", "") != "reset":
|
if reset_data.get("phase", "") != "reset":
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
@ -168,6 +171,8 @@ class ForgotPasswordResetApi(Resource):
|
|||||||
)
|
)
|
||||||
except WorkSpaceNotAllowedCreateError:
|
except WorkSpaceNotAllowedCreateError:
|
||||||
pass
|
pass
|
||||||
|
except WorkspacesLimitExceededError:
|
||||||
|
pass
|
||||||
except AccountRegisterError:
|
except AccountRegisterError:
|
||||||
raise AccountInFreezeError()
|
raise AccountInFreezeError()
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ from controllers.console.error import (
|
|||||||
AccountNotFound,
|
AccountNotFound,
|
||||||
EmailSendIpLimitError,
|
EmailSendIpLimitError,
|
||||||
NotAllowedCreateWorkspace,
|
NotAllowedCreateWorkspace,
|
||||||
|
WorkspacesLimitExceeded,
|
||||||
)
|
)
|
||||||
from controllers.console.wraps import email_password_login_enabled, setup_required
|
from controllers.console.wraps import email_password_login_enabled, setup_required
|
||||||
from events.tenant_event import tenant_was_created
|
from events.tenant_event import tenant_was_created
|
||||||
@ -30,7 +31,7 @@ from models.account import Account
|
|||||||
from services.account_service import AccountService, RegisterService, TenantService
|
from services.account_service import AccountService, RegisterService, TenantService
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
from services.errors.account import AccountRegisterError
|
from services.errors.account import AccountRegisterError
|
||||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
@ -88,10 +89,15 @@ class LoginApi(Resource):
|
|||||||
# SELF_HOSTED only have one workspace
|
# SELF_HOSTED only have one workspace
|
||||||
tenants = TenantService.get_join_tenants(account)
|
tenants = TenantService.get_join_tenants(account)
|
||||||
if len(tenants) == 0:
|
if len(tenants) == 0:
|
||||||
return {
|
system_features = FeatureService.get_system_features()
|
||||||
"result": "fail",
|
|
||||||
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
|
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
|
||||||
}
|
raise WorkspacesLimitExceeded()
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"result": "fail",
|
||||||
|
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
|
||||||
|
}
|
||||||
|
|
||||||
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
|
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
|
||||||
AccountService.reset_login_error_rate_limit(args["email"])
|
AccountService.reset_login_error_rate_limit(args["email"])
|
||||||
@ -198,6 +204,9 @@ class EmailCodeLoginApi(Resource):
|
|||||||
if account:
|
if account:
|
||||||
tenant = TenantService.get_join_tenants(account)
|
tenant = TenantService.get_join_tenants(account)
|
||||||
if not tenant:
|
if not tenant:
|
||||||
|
workspaces = FeatureService.get_system_features().license.workspaces
|
||||||
|
if not workspaces.is_available():
|
||||||
|
raise WorkspacesLimitExceeded()
|
||||||
if not FeatureService.get_system_features().is_allow_create_workspace:
|
if not FeatureService.get_system_features().is_allow_create_workspace:
|
||||||
raise NotAllowedCreateWorkspace()
|
raise NotAllowedCreateWorkspace()
|
||||||
else:
|
else:
|
||||||
@ -215,6 +224,8 @@ class EmailCodeLoginApi(Resource):
|
|||||||
return NotAllowedCreateWorkspace()
|
return NotAllowedCreateWorkspace()
|
||||||
except AccountRegisterError as are:
|
except AccountRegisterError as are:
|
||||||
raise AccountInFreezeError()
|
raise AccountInFreezeError()
|
||||||
|
except WorkspacesLimitExceededError:
|
||||||
|
raise WorkspacesLimitExceeded()
|
||||||
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
|
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
|
||||||
AccountService.reset_login_error_rate_limit(args["email"])
|
AccountService.reset_login_error_rate_limit(args["email"])
|
||||||
return {"result": "success", "data": token_pair.model_dump()}
|
return {"result": "success", "data": token_pair.model_dump()}
|
||||||
|
@ -46,6 +46,18 @@ class NotAllowedCreateWorkspace(BaseHTTPException):
|
|||||||
code = 400
|
code = 400
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMembersLimitExceeded(BaseHTTPException):
|
||||||
|
error_code = "limit_exceeded"
|
||||||
|
description = "Unable to add member because the maximum workspace's member limit was exceeded"
|
||||||
|
code = 400
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacesLimitExceeded(BaseHTTPException):
|
||||||
|
error_code = "limit_exceeded"
|
||||||
|
description = "Unable to create workspace because the maximum workspace limit was exceeded"
|
||||||
|
code = 400
|
||||||
|
|
||||||
|
|
||||||
class AccountBannedError(BaseHTTPException):
|
class AccountBannedError(BaseHTTPException):
|
||||||
error_code = "account_banned"
|
error_code = "account_banned"
|
||||||
description = "Account is banned."
|
description = "Account is banned."
|
||||||
|
@ -23,3 +23,9 @@ class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
|
|||||||
error_code = "app_suggested_questions_after_answer_disabled"
|
error_code = "app_suggested_questions_after_answer_disabled"
|
||||||
description = "Function Suggested questions after answer disabled."
|
description = "Function Suggested questions after answer disabled."
|
||||||
code = 403
|
code = 403
|
||||||
|
|
||||||
|
|
||||||
|
class AppAccessDeniedError(BaseHTTPException):
|
||||||
|
error_code = "access_denied"
|
||||||
|
description = "App access denied."
|
||||||
|
code = 403
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -15,6 +16,11 @@ from fields.installed_app_fields import installed_app_list_fields
|
|||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import App, InstalledApp, RecommendedApp
|
from models import App, InstalledApp, RecommendedApp
|
||||||
from services.account_service import TenantService
|
from services.account_service import TenantService
|
||||||
|
from services.app_service import AppService
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class InstalledAppsListApi(Resource):
|
class InstalledAppsListApi(Resource):
|
||||||
@ -48,6 +54,21 @@ class InstalledAppsListApi(Resource):
|
|||||||
for installed_app in installed_apps
|
for installed_app in installed_apps
|
||||||
if installed_app.app is not None
|
if installed_app.app is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# filter out apps that user doesn't have access to
|
||||||
|
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
|
user_id = current_user.id
|
||||||
|
res = []
|
||||||
|
for installed_app in installed_app_list:
|
||||||
|
app_code = AppService.get_app_code_by_id(str(installed_app["app"].id))
|
||||||
|
if EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
|
||||||
|
user_id=user_id,
|
||||||
|
app_code=app_code,
|
||||||
|
):
|
||||||
|
res.append(installed_app)
|
||||||
|
installed_app_list = res
|
||||||
|
logger.debug(f"installed_app_list: {installed_app_list}, user_id: {user_id}")
|
||||||
|
|
||||||
installed_app_list.sort(
|
installed_app_list.sort(
|
||||||
key=lambda app: (
|
key=lambda app: (
|
||||||
-app["is_pinned"],
|
-app["is_pinned"],
|
||||||
|
@ -4,10 +4,14 @@ from flask_login import current_user
|
|||||||
from flask_restful import Resource
|
from flask_restful import Resource
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
|
from controllers.console.explore.error import AppAccessDeniedError
|
||||||
from controllers.console.wraps import account_initialization_required
|
from controllers.console.wraps import account_initialization_required
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import InstalledApp
|
from models import InstalledApp
|
||||||
|
from services.app_service import AppService
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
def installed_app_required(view=None):
|
def installed_app_required(view=None):
|
||||||
@ -48,6 +52,36 @@ def installed_app_required(view=None):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def user_allowed_to_access_app(view=None):
|
||||||
|
def decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
def decorated(installed_app: InstalledApp, *args, **kwargs):
|
||||||
|
feature = FeatureService.get_system_features()
|
||||||
|
if feature.webapp_auth.enabled:
|
||||||
|
app_id = installed_app.app_id
|
||||||
|
app_code = AppService.get_app_code_by_id(app_id)
|
||||||
|
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
app_code=app_code,
|
||||||
|
)
|
||||||
|
if not res:
|
||||||
|
raise AppAccessDeniedError()
|
||||||
|
|
||||||
|
return view(installed_app, *args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
if view:
|
||||||
|
return decorator(view)
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
class InstalledAppResource(Resource):
|
class InstalledAppResource(Resource):
|
||||||
# must be reversed if there are multiple decorators
|
# must be reversed if there are multiple decorators
|
||||||
method_decorators = [installed_app_required, account_initialization_required, login_required]
|
|
||||||
|
method_decorators = [
|
||||||
|
user_allowed_to_access_app,
|
||||||
|
installed_app_required,
|
||||||
|
account_initialization_required,
|
||||||
|
login_required,
|
||||||
|
]
|
||||||
|
@ -6,6 +6,7 @@ from flask_restful import Resource, abort, marshal_with, reqparse
|
|||||||
import services
|
import services
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from controllers.console import api
|
from controllers.console import api
|
||||||
|
from controllers.console.error import WorkspaceMembersLimitExceeded
|
||||||
from controllers.console.wraps import (
|
from controllers.console.wraps import (
|
||||||
account_initialization_required,
|
account_initialization_required,
|
||||||
cloud_edition_billing_resource_check,
|
cloud_edition_billing_resource_check,
|
||||||
@ -17,6 +18,7 @@ from libs.login import login_required
|
|||||||
from models.account import Account, TenantAccountRole
|
from models.account import Account, TenantAccountRole
|
||||||
from services.account_service import RegisterService, TenantService
|
from services.account_service import RegisterService, TenantService
|
||||||
from services.errors.account import AccountAlreadyInTenantError
|
from services.errors.account import AccountAlreadyInTenantError
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
class MemberListApi(Resource):
|
class MemberListApi(Resource):
|
||||||
@ -54,6 +56,12 @@ class MemberInviteEmailApi(Resource):
|
|||||||
inviter = current_user
|
inviter = current_user
|
||||||
invitation_results = []
|
invitation_results = []
|
||||||
console_web_url = dify_config.CONSOLE_WEB_URL
|
console_web_url = dify_config.CONSOLE_WEB_URL
|
||||||
|
|
||||||
|
workspace_members = FeatureService.get_features(tenant_id=inviter.current_tenant.id).workspace_members
|
||||||
|
|
||||||
|
if not workspace_members.is_available(len(invitee_emails)):
|
||||||
|
raise WorkspaceMembersLimitExceeded()
|
||||||
|
|
||||||
for invitee_email in invitee_emails:
|
for invitee_email in invitee_emails:
|
||||||
try:
|
try:
|
||||||
token = RegisterService.invite_new_member(
|
token = RegisterService.invite_new_member(
|
||||||
|
@ -5,5 +5,6 @@ from libs.external_api import ExternalApi
|
|||||||
bp = Blueprint("inner_api", __name__, url_prefix="/inner/api")
|
bp = Blueprint("inner_api", __name__, url_prefix="/inner/api")
|
||||||
api = ExternalApi(bp)
|
api = ExternalApi(bp)
|
||||||
|
|
||||||
|
from . import mail
|
||||||
from .plugin import plugin
|
from .plugin import plugin
|
||||||
from .workspace import workspace
|
from .workspace import workspace
|
||||||
|
27
api/controllers/inner_api/mail.py
Normal file
27
api/controllers/inner_api/mail.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from flask_restful import (
|
||||||
|
Resource, # type: ignore
|
||||||
|
reqparse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from controllers.console.wraps import setup_required
|
||||||
|
from controllers.inner_api import api
|
||||||
|
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||||
|
from services.enterprise.mail_service import DifyMail, EnterpriseMailService
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseMail(Resource):
|
||||||
|
@setup_required
|
||||||
|
@enterprise_inner_api_only
|
||||||
|
def post(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("to", type=str, action="append", required=True)
|
||||||
|
parser.add_argument("subject", type=str, required=True)
|
||||||
|
parser.add_argument("body", type=str, required=True)
|
||||||
|
parser.add_argument("substitutions", type=dict, required=False)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
EnterpriseMailService.send_mail(DifyMail(**args))
|
||||||
|
return {"message": "success"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
api.add_resource(EnterpriseMail, "/enterprise/mail")
|
@ -1,12 +1,15 @@
|
|||||||
from flask_restful import marshal_with
|
from flask import request
|
||||||
|
from flask_restful import Resource, marshal_with, reqparse
|
||||||
|
|
||||||
from controllers.common import fields
|
from controllers.common import fields
|
||||||
from controllers.web import api
|
from controllers.web import api
|
||||||
from controllers.web.error import AppUnavailableError
|
from controllers.web.error import AppUnavailableError
|
||||||
from controllers.web.wraps import WebApiResource
|
from controllers.web.wraps import WebApiResource
|
||||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||||
|
from libs.passport import PassportService
|
||||||
from models.model import App, AppMode
|
from models.model import App, AppMode
|
||||||
from services.app_service import AppService
|
from services.app_service import AppService
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
|
||||||
|
|
||||||
class AppParameterApi(WebApiResource):
|
class AppParameterApi(WebApiResource):
|
||||||
@ -40,5 +43,51 @@ class AppMeta(WebApiResource):
|
|||||||
return AppService().get_app_meta(app_model)
|
return AppService().get_app_meta(app_model)
|
||||||
|
|
||||||
|
|
||||||
|
class AppAccessMode(Resource):
|
||||||
|
def get(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("appId", type=str, required=True, location="args")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app_id = args["appId"]
|
||||||
|
res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
|
||||||
|
|
||||||
|
return {"accessMode": res.access_mode}
|
||||||
|
|
||||||
|
|
||||||
|
class AppWebAuthPermission(Resource):
|
||||||
|
def get(self):
|
||||||
|
user_id = "visitor"
|
||||||
|
try:
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header is None:
|
||||||
|
raise
|
||||||
|
if " " not in auth_header:
|
||||||
|
raise
|
||||||
|
|
||||||
|
auth_scheme, tk = auth_header.split(None, 1)
|
||||||
|
auth_scheme = auth_scheme.lower()
|
||||||
|
if auth_scheme != "bearer":
|
||||||
|
raise
|
||||||
|
|
||||||
|
decoded = PassportService().verify(tk)
|
||||||
|
user_id = decoded.get("user_id", "visitor")
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("appId", type=str, required=True, location="args")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app_id = args["appId"]
|
||||||
|
app_code = AppService.get_app_code_by_id(app_id)
|
||||||
|
|
||||||
|
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_code)
|
||||||
|
return {"result": res}
|
||||||
|
|
||||||
|
|
||||||
api.add_resource(AppParameterApi, "/parameters")
|
api.add_resource(AppParameterApi, "/parameters")
|
||||||
api.add_resource(AppMeta, "/meta")
|
api.add_resource(AppMeta, "/meta")
|
||||||
|
# webapp auth apis
|
||||||
|
api.add_resource(AppAccessMode, "/webapp/access-mode")
|
||||||
|
api.add_resource(AppWebAuthPermission, "/webapp/permission")
|
||||||
|
@ -121,9 +121,15 @@ class UnsupportedFileTypeError(BaseHTTPException):
|
|||||||
code = 415
|
code = 415
|
||||||
|
|
||||||
|
|
||||||
class WebSSOAuthRequiredError(BaseHTTPException):
|
class WebAppAuthRequiredError(BaseHTTPException):
|
||||||
error_code = "web_sso_auth_required"
|
error_code = "web_sso_auth_required"
|
||||||
description = "Web SSO authentication required."
|
description = "Web app authentication required."
|
||||||
|
code = 401
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppAuthAccessDeniedError(BaseHTTPException):
|
||||||
|
error_code = "web_app_access_denied"
|
||||||
|
description = "You do not have permission to access this web app."
|
||||||
code = 401
|
code = 401
|
||||||
|
|
||||||
|
|
||||||
|
120
api/controllers/web/login.py
Normal file
120
api/controllers/web/login.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
from flask import request
|
||||||
|
from flask_restful import Resource, reqparse
|
||||||
|
from jwt import InvalidTokenError # type: ignore
|
||||||
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
|
import services
|
||||||
|
from controllers.console.auth.error import EmailCodeError, EmailOrPasswordMismatchError, InvalidEmailError
|
||||||
|
from controllers.console.error import AccountBannedError, AccountNotFound
|
||||||
|
from controllers.console.wraps import setup_required
|
||||||
|
from libs.helper import email
|
||||||
|
from libs.password import valid_password
|
||||||
|
from services.account_service import AccountService
|
||||||
|
from services.webapp_auth_service import WebAppAuthService
|
||||||
|
|
||||||
|
|
||||||
|
class LoginApi(Resource):
|
||||||
|
"""Resource for web app email/password login."""
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
"""Authenticate user and login."""
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("email", type=email, required=True, location="json")
|
||||||
|
parser.add_argument("password", type=valid_password, required=True, location="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app_code = request.headers.get("X-App-Code")
|
||||||
|
if app_code is None:
|
||||||
|
raise BadRequest("X-App-Code header is missing.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
account = WebAppAuthService.authenticate(args["email"], args["password"])
|
||||||
|
except services.errors.account.AccountLoginError:
|
||||||
|
raise AccountBannedError()
|
||||||
|
except services.errors.account.AccountPasswordError:
|
||||||
|
raise EmailOrPasswordMismatchError()
|
||||||
|
except services.errors.account.AccountNotFoundError:
|
||||||
|
raise AccountNotFound()
|
||||||
|
|
||||||
|
WebAppAuthService._validate_user_accessibility(account=account, app_code=app_code)
|
||||||
|
|
||||||
|
end_user = WebAppAuthService.create_end_user(email=args["email"], app_code=app_code)
|
||||||
|
|
||||||
|
token = WebAppAuthService.login(account=account, app_code=app_code, end_user_id=end_user.id)
|
||||||
|
return {"result": "success", "token": token}
|
||||||
|
|
||||||
|
|
||||||
|
# class LogoutApi(Resource):
|
||||||
|
# @setup_required
|
||||||
|
# def get(self):
|
||||||
|
# account = cast(Account, flask_login.current_user)
|
||||||
|
# if isinstance(account, flask_login.AnonymousUserMixin):
|
||||||
|
# return {"result": "success"}
|
||||||
|
# flask_login.logout_user()
|
||||||
|
# return {"result": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodeLoginSendEmailApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
def post(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("email", type=email, required=True, location="json")
|
||||||
|
parser.add_argument("language", type=str, required=False, location="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args["language"] is not None and args["language"] == "zh-Hans":
|
||||||
|
language = "zh-Hans"
|
||||||
|
else:
|
||||||
|
language = "en-US"
|
||||||
|
|
||||||
|
account = WebAppAuthService.get_user_through_email(args["email"])
|
||||||
|
if account is None:
|
||||||
|
raise AccountNotFound()
|
||||||
|
else:
|
||||||
|
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
|
||||||
|
|
||||||
|
return {"result": "success", "data": token}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodeLoginApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
def post(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("email", type=str, required=True, location="json")
|
||||||
|
parser.add_argument("code", type=str, required=True, location="json")
|
||||||
|
parser.add_argument("token", type=str, required=True, location="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
user_email = args["email"]
|
||||||
|
app_code = request.headers.get("X-App-Code")
|
||||||
|
if app_code is None:
|
||||||
|
raise BadRequest("X-App-Code header is missing.")
|
||||||
|
|
||||||
|
token_data = WebAppAuthService.get_email_code_login_data(args["token"])
|
||||||
|
if token_data is None:
|
||||||
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
if token_data["email"] != args["email"]:
|
||||||
|
raise InvalidEmailError()
|
||||||
|
|
||||||
|
if token_data["code"] != args["code"]:
|
||||||
|
raise EmailCodeError()
|
||||||
|
|
||||||
|
WebAppAuthService.revoke_email_code_login_token(args["token"])
|
||||||
|
account = WebAppAuthService.get_user_through_email(user_email)
|
||||||
|
if not account:
|
||||||
|
raise AccountNotFound()
|
||||||
|
|
||||||
|
WebAppAuthService._validate_user_accessibility(account=account, app_code=app_code)
|
||||||
|
|
||||||
|
end_user = WebAppAuthService.create_end_user(email=user_email, app_code=app_code)
|
||||||
|
|
||||||
|
token = WebAppAuthService.login(account=account, app_code=app_code, end_user_id=end_user.id)
|
||||||
|
AccountService.reset_login_error_rate_limit(args["email"])
|
||||||
|
return {"result": "success", "token": token}
|
||||||
|
|
||||||
|
|
||||||
|
# api.add_resource(LoginApi, "/login")
|
||||||
|
# api.add_resource(LogoutApi, "/logout")
|
||||||
|
# api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
|
||||||
|
# api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")
|
@ -5,7 +5,7 @@ from flask_restful import Resource
|
|||||||
from werkzeug.exceptions import NotFound, Unauthorized
|
from werkzeug.exceptions import NotFound, Unauthorized
|
||||||
|
|
||||||
from controllers.web import api
|
from controllers.web import api
|
||||||
from controllers.web.error import WebSSOAuthRequiredError
|
from controllers.web.error import WebAppAuthRequiredError
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.passport import PassportService
|
from libs.passport import PassportService
|
||||||
from models.model import App, EndUser, Site
|
from models.model import App, EndUser, Site
|
||||||
@ -24,10 +24,10 @@ class PassportResource(Resource):
|
|||||||
if app_code is None:
|
if app_code is None:
|
||||||
raise Unauthorized("X-App-Code header is missing.")
|
raise Unauthorized("X-App-Code header is missing.")
|
||||||
|
|
||||||
if system_features.sso_enforced_for_web:
|
if system_features.webapp_auth.enabled:
|
||||||
app_web_sso_enabled = EnterpriseService.get_app_web_sso_enabled(app_code).get("enabled", False)
|
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
|
||||||
if app_web_sso_enabled:
|
if not app_settings or not app_settings.access_mode == "public":
|
||||||
raise WebSSOAuthRequiredError()
|
raise WebAppAuthRequiredError()
|
||||||
|
|
||||||
# get site from db and check if it is normal
|
# get site from db and check if it is normal
|
||||||
site = db.session.query(Site).filter(Site.code == app_code, Site.status == "normal").first()
|
site = db.session.query(Site).filter(Site.code == app_code, Site.status == "normal").first()
|
||||||
|
@ -4,7 +4,7 @@ from flask import request
|
|||||||
from flask_restful import Resource
|
from flask_restful import Resource
|
||||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
|
|
||||||
from controllers.web.error import WebSSOAuthRequiredError
|
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.passport import PassportService
|
from libs.passport import PassportService
|
||||||
from models.model import App, EndUser, Site
|
from models.model import App, EndUser, Site
|
||||||
@ -29,7 +29,7 @@ def validate_jwt_token(view=None):
|
|||||||
|
|
||||||
def decode_jwt_token():
|
def decode_jwt_token():
|
||||||
system_features = FeatureService.get_system_features()
|
system_features = FeatureService.get_system_features()
|
||||||
app_code = request.headers.get("X-App-Code")
|
app_code = str(request.headers.get("X-App-Code"))
|
||||||
try:
|
try:
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header is None:
|
if auth_header is None:
|
||||||
@ -57,35 +57,53 @@ def decode_jwt_token():
|
|||||||
if not end_user:
|
if not end_user:
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
|
|
||||||
_validate_web_sso_token(decoded, system_features, app_code)
|
# for enterprise webapp auth
|
||||||
|
app_web_auth_enabled = False
|
||||||
|
if system_features.webapp_auth.enabled:
|
||||||
|
app_web_auth_enabled = (
|
||||||
|
EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code).access_mode != "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled)
|
||||||
|
_validate_user_accessibility(decoded, app_code, app_web_auth_enabled, system_features.webapp_auth.enabled)
|
||||||
|
|
||||||
return app_model, end_user
|
return app_model, end_user
|
||||||
except Unauthorized as e:
|
except Unauthorized as e:
|
||||||
if system_features.sso_enforced_for_web:
|
if system_features.webapp_auth.enabled:
|
||||||
app_web_sso_enabled = EnterpriseService.get_app_web_sso_enabled(app_code).get("enabled", False)
|
app_web_auth_enabled = (
|
||||||
if app_web_sso_enabled:
|
EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=str(app_code)).access_mode != "public"
|
||||||
raise WebSSOAuthRequiredError()
|
)
|
||||||
|
if app_web_auth_enabled:
|
||||||
|
raise WebAppAuthRequiredError()
|
||||||
|
|
||||||
raise Unauthorized(e.description)
|
raise Unauthorized(e.description)
|
||||||
|
|
||||||
|
|
||||||
def _validate_web_sso_token(decoded, system_features, app_code):
|
def _validate_webapp_token(decoded, app_web_auth_enabled: bool, system_webapp_auth_enabled: bool):
|
||||||
app_web_sso_enabled = False
|
# Check if authentication is enforced for web app, and if the token source is not webapp,
|
||||||
|
# raise an error and redirect to login
|
||||||
# Check if SSO is enforced for web, and if the token source is not SSO, raise an error and redirect to SSO login
|
if system_webapp_auth_enabled and app_web_auth_enabled:
|
||||||
if system_features.sso_enforced_for_web:
|
|
||||||
app_web_sso_enabled = EnterpriseService.get_app_web_sso_enabled(app_code).get("enabled", False)
|
|
||||||
if app_web_sso_enabled:
|
|
||||||
source = decoded.get("token_source")
|
|
||||||
if not source or source != "sso":
|
|
||||||
raise WebSSOAuthRequiredError()
|
|
||||||
|
|
||||||
# Check if SSO is not enforced for web, and if the token source is SSO,
|
|
||||||
# raise an error and redirect to normal passport login
|
|
||||||
if not system_features.sso_enforced_for_web or not app_web_sso_enabled:
|
|
||||||
source = decoded.get("token_source")
|
source = decoded.get("token_source")
|
||||||
if source and source == "sso":
|
if not source or source != "webapp":
|
||||||
raise Unauthorized("sso token expired.")
|
raise WebAppAuthRequiredError()
|
||||||
|
|
||||||
|
# Check if authentication is not enforced for web, and if the token source is webapp,
|
||||||
|
# raise an error and redirect to normal passport login
|
||||||
|
if not system_webapp_auth_enabled or not app_web_auth_enabled:
|
||||||
|
source = decoded.get("token_source")
|
||||||
|
if source and source == "webapp":
|
||||||
|
raise Unauthorized("webapp token expired.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_user_accessibility(decoded, app_code, app_web_auth_enabled: bool, system_webapp_auth_enabled: bool):
|
||||||
|
if system_webapp_auth_enabled and app_web_auth_enabled:
|
||||||
|
# Check if the user is allowed to access the web app
|
||||||
|
user_id = decoded.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
raise WebAppAuthRequiredError()
|
||||||
|
|
||||||
|
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_code=app_code):
|
||||||
|
raise WebAppAuthAccessDeniedError()
|
||||||
|
|
||||||
|
|
||||||
class WebApiResource(Resource):
|
class WebApiResource(Resource):
|
||||||
|
@ -63,6 +63,7 @@ app_detail_fields = {
|
|||||||
"created_at": TimestampField,
|
"created_at": TimestampField,
|
||||||
"updated_by": fields.String,
|
"updated_by": fields.String,
|
||||||
"updated_at": TimestampField,
|
"updated_at": TimestampField,
|
||||||
|
"access_mode": fields.String,
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt_config_fields = {
|
prompt_config_fields = {
|
||||||
@ -98,6 +99,7 @@ app_partial_fields = {
|
|||||||
"updated_by": fields.String,
|
"updated_by": fields.String,
|
||||||
"updated_at": TimestampField,
|
"updated_at": TimestampField,
|
||||||
"tags": fields.List(fields.Nested(tag_fields)),
|
"tags": fields.List(fields.Nested(tag_fields)),
|
||||||
|
"access_mode": fields.String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -176,6 +178,7 @@ app_detail_fields_with_site = {
|
|||||||
"updated_by": fields.String,
|
"updated_by": fields.String,
|
||||||
"updated_at": TimestampField,
|
"updated_at": TimestampField,
|
||||||
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
|
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
|
||||||
|
"access_mode": fields.String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ from services.errors.account import (
|
|||||||
RoleAlreadyAssignedError,
|
RoleAlreadyAssignedError,
|
||||||
TenantNotFoundError,
|
TenantNotFoundError,
|
||||||
)
|
)
|
||||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError
|
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||||
from services.feature_service import FeatureService
|
from services.feature_service import FeatureService
|
||||||
from tasks.delete_account_task import delete_account_task
|
from tasks.delete_account_task import delete_account_task
|
||||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||||
@ -628,6 +628,10 @@ class TenantService:
|
|||||||
if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup:
|
if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup:
|
||||||
raise WorkSpaceNotAllowedCreateError()
|
raise WorkSpaceNotAllowedCreateError()
|
||||||
|
|
||||||
|
workspaces = FeatureService.get_system_features().license.workspaces
|
||||||
|
if not workspaces.is_available():
|
||||||
|
raise WorkspacesLimitExceededError()
|
||||||
|
|
||||||
if name:
|
if name:
|
||||||
tenant = TenantService.create_tenant(name=name, is_setup=is_setup)
|
tenant = TenantService.create_tenant(name=name, is_setup=is_setup)
|
||||||
else:
|
else:
|
||||||
@ -928,7 +932,11 @@ class RegisterService:
|
|||||||
if open_id is not None and provider is not None:
|
if open_id is not None and provider is not None:
|
||||||
AccountService.link_account_integrate(provider, open_id, account)
|
AccountService.link_account_integrate(provider, open_id, account)
|
||||||
|
|
||||||
if FeatureService.get_system_features().is_allow_create_workspace and create_workspace_required:
|
if (
|
||||||
|
FeatureService.get_system_features().is_allow_create_workspace
|
||||||
|
and create_workspace_required
|
||||||
|
and FeatureService.get_system_features().license.workspaces.is_available()
|
||||||
|
):
|
||||||
tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
|
tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
|
||||||
TenantService.create_tenant_member(tenant, account, role="owner")
|
TenantService.create_tenant_member(tenant, account, role="owner")
|
||||||
account.current_tenant = tenant
|
account.current_tenant = tenant
|
||||||
|
@ -18,8 +18,10 @@ from core.tools.utils.configuration import ToolParameterConfigurationManager
|
|||||||
from events.app_event import app_was_created
|
from events.app_event import app_was_created
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from models.account import Account
|
from models.account import Account
|
||||||
from models.model import App, AppMode, AppModelConfig
|
from models.model import App, AppMode, AppModelConfig, Site
|
||||||
from models.tools import ApiToolProvider
|
from models.tools import ApiToolProvider
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.feature_service import FeatureService
|
||||||
from services.tag_service import TagService
|
from services.tag_service import TagService
|
||||||
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
|
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
|
||||||
|
|
||||||
@ -155,6 +157,10 @@ class AppService:
|
|||||||
|
|
||||||
app_was_created.send(app, account=account)
|
app_was_created.send(app, account=account)
|
||||||
|
|
||||||
|
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
|
# update web app setting as private
|
||||||
|
EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
def get_app(self, app: App) -> App:
|
def get_app(self, app: App) -> App:
|
||||||
@ -307,6 +313,10 @@ class AppService:
|
|||||||
db.session.delete(app)
|
db.session.delete(app)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# clean up web app settings
|
||||||
|
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
|
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
|
||||||
|
|
||||||
# Trigger asynchronous deletion of app and related data
|
# Trigger asynchronous deletion of app and related data
|
||||||
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
|
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
|
||||||
|
|
||||||
@ -373,3 +383,15 @@ class AppService:
|
|||||||
meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"}
|
meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"}
|
||||||
|
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_app_code_by_id(app_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get app code by app id
|
||||||
|
:param app_id: app id
|
||||||
|
:return: app code
|
||||||
|
"""
|
||||||
|
site = db.session.query(Site).filter(Site.app_id == app_id).first()
|
||||||
|
if not site:
|
||||||
|
raise ValueError(f"App with id {app_id} not found")
|
||||||
|
return str(site.code)
|
||||||
|
@ -1,11 +1,90 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from services.enterprise.base import EnterpriseRequest
|
from services.enterprise.base import EnterpriseRequest
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppSettings(BaseModel):
|
||||||
|
access_mode: str = Field(
|
||||||
|
description="Access mode for the web app. Can be 'public' or 'private'",
|
||||||
|
default="private",
|
||||||
|
alias="accessMode",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EnterpriseService:
|
class EnterpriseService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_info(cls):
|
def get_info(cls):
|
||||||
return EnterpriseRequest.send_request("GET", "/info")
|
return EnterpriseRequest.send_request("GET", "/info")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_app_web_sso_enabled(cls, app_code):
|
def get_workspace_info(cls, tenant_id: str):
|
||||||
return EnterpriseRequest.send_request("GET", f"/app-sso-setting?appCode={app_code}")
|
return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
|
||||||
|
|
||||||
|
class WebAppAuth:
|
||||||
|
@classmethod
|
||||||
|
def is_user_allowed_to_access_webapp(cls, user_id: str, app_code: str):
|
||||||
|
params = {"userId": user_id, "appCode": app_code}
|
||||||
|
data = EnterpriseRequest.send_request("GET", "/webapp/permission", params=params)
|
||||||
|
|
||||||
|
return data.get("result", False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_app_access_mode_by_id(cls, app_id: str) -> WebAppSettings:
|
||||||
|
if not app_id:
|
||||||
|
raise ValueError("app_id must be provided.")
|
||||||
|
params = {"appId": app_id}
|
||||||
|
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/id", params=params)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No data found.")
|
||||||
|
return WebAppSettings(**data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def batch_get_app_access_mode_by_id(cls, app_ids: list[str]) -> dict[str, WebAppSettings]:
|
||||||
|
if not app_ids:
|
||||||
|
return {}
|
||||||
|
body = {"appIds": app_ids}
|
||||||
|
data: dict[str, str] = EnterpriseRequest.send_request("POST", "/webapp/access-mode/batch/id", json=body)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No data found.")
|
||||||
|
|
||||||
|
if not isinstance(data["accessModes"], dict):
|
||||||
|
raise ValueError("Invalid data format.")
|
||||||
|
|
||||||
|
ret = {}
|
||||||
|
for key, value in data["accessModes"].items():
|
||||||
|
curr = WebAppSettings()
|
||||||
|
curr.access_mode = value
|
||||||
|
ret[key] = curr
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_app_access_mode_by_code(cls, app_code: str) -> WebAppSettings:
|
||||||
|
if not app_code:
|
||||||
|
raise ValueError("app_code must be provided.")
|
||||||
|
params = {"appCode": app_code}
|
||||||
|
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/code", params=params)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No data found.")
|
||||||
|
return WebAppSettings(**data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_app_access_mode(cls, app_id: str, access_mode: str):
|
||||||
|
if not app_id:
|
||||||
|
raise ValueError("app_id must be provided.")
|
||||||
|
if access_mode not in ["public", "private", "private_all"]:
|
||||||
|
raise ValueError("access_mode must be either 'public', 'private', or 'private_all'")
|
||||||
|
|
||||||
|
data = {"appId": app_id, "accessMode": access_mode}
|
||||||
|
|
||||||
|
response = EnterpriseRequest.send_request("POST", "/webapp/access-mode", json=data)
|
||||||
|
|
||||||
|
return response.get("result", False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cleanup_webapp(cls, app_id: str):
|
||||||
|
if not app_id:
|
||||||
|
raise ValueError("app_id must be provided.")
|
||||||
|
|
||||||
|
body = {"appId": app_id}
|
||||||
|
EnterpriseRequest.send_request("DELETE", "/webapp/clean", json=body)
|
||||||
|
18
api/services/enterprise/mail_service.py
Normal file
18
api/services/enterprise/mail_service.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from tasks.mail_enterprise_task import send_enterprise_email_task
|
||||||
|
|
||||||
|
|
||||||
|
class DifyMail(BaseModel):
|
||||||
|
to: list[str]
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
substitutions: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseMailService:
|
||||||
|
@classmethod
|
||||||
|
def send_mail(cls, mail: DifyMail):
|
||||||
|
send_enterprise_email_task.delay(
|
||||||
|
to=mail.to, subject=mail.subject, body=mail.body, substitutions=mail.substitutions
|
||||||
|
)
|
@ -7,3 +7,7 @@ class WorkSpaceNotAllowedCreateError(BaseServiceError):
|
|||||||
|
|
||||||
class WorkSpaceNotFoundError(BaseServiceError):
|
class WorkSpaceNotFoundError(BaseServiceError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacesLimitExceededError(BaseServiceError):
|
||||||
|
pass
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from services.billing_service import BillingService
|
from services.billing_service import BillingService
|
||||||
@ -27,6 +27,32 @@ class LimitationModel(BaseModel):
|
|||||||
limit: int = 0
|
limit: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseLimitationModel(BaseModel):
|
||||||
|
"""
|
||||||
|
- enabled: whether this limit is enforced
|
||||||
|
- size: current usage count
|
||||||
|
- limit: maximum allowed count; 0 means unlimited
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = Field(False, description="Whether this limit is currently active")
|
||||||
|
size: int = Field(0, description="Number of resources already consumed")
|
||||||
|
limit: int = Field(0, description="Maximum number of resources allowed; 0 means no limit")
|
||||||
|
|
||||||
|
def is_available(self, required: int = 1) -> bool:
|
||||||
|
"""
|
||||||
|
Determine whether the requested amount can be allocated.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- this limit is not active, or
|
||||||
|
- the limit is zero (unlimited), or
|
||||||
|
- there is enough remaining quota.
|
||||||
|
"""
|
||||||
|
if not self.enabled or self.limit == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return (self.limit - self.size) >= required
|
||||||
|
|
||||||
|
|
||||||
class LicenseStatus(StrEnum):
|
class LicenseStatus(StrEnum):
|
||||||
NONE = "none"
|
NONE = "none"
|
||||||
INACTIVE = "inactive"
|
INACTIVE = "inactive"
|
||||||
@ -39,6 +65,27 @@ class LicenseStatus(StrEnum):
|
|||||||
class LicenseModel(BaseModel):
|
class LicenseModel(BaseModel):
|
||||||
status: LicenseStatus = LicenseStatus.NONE
|
status: LicenseStatus = LicenseStatus.NONE
|
||||||
expired_at: str = ""
|
expired_at: str = ""
|
||||||
|
workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BrandingModel(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
application_title: str = ""
|
||||||
|
login_page_logo: str = ""
|
||||||
|
workspace_logo: str = ""
|
||||||
|
favicon: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppAuthSSOModel(BaseModel):
|
||||||
|
protocol: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppAuthModel(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
allow_sso: bool = False
|
||||||
|
sso_config: WebAppAuthSSOModel = WebAppAuthSSOModel()
|
||||||
|
allow_email_code_login: bool = False
|
||||||
|
allow_email_password_login: bool = False
|
||||||
|
|
||||||
|
|
||||||
class FeatureModel(BaseModel):
|
class FeatureModel(BaseModel):
|
||||||
@ -54,6 +101,8 @@ class FeatureModel(BaseModel):
|
|||||||
can_replace_logo: bool = False
|
can_replace_logo: bool = False
|
||||||
model_load_balancing_enabled: bool = False
|
model_load_balancing_enabled: bool = False
|
||||||
dataset_operator_enabled: bool = False
|
dataset_operator_enabled: bool = False
|
||||||
|
webapp_copyright_enabled: bool = False
|
||||||
|
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
|
||||||
|
|
||||||
# pydantic configs
|
# pydantic configs
|
||||||
model_config = ConfigDict(protected_namespaces=())
|
model_config = ConfigDict(protected_namespaces=())
|
||||||
@ -68,9 +117,6 @@ class KnowledgeRateLimitModel(BaseModel):
|
|||||||
class SystemFeatureModel(BaseModel):
|
class SystemFeatureModel(BaseModel):
|
||||||
sso_enforced_for_signin: bool = False
|
sso_enforced_for_signin: bool = False
|
||||||
sso_enforced_for_signin_protocol: str = ""
|
sso_enforced_for_signin_protocol: str = ""
|
||||||
sso_enforced_for_web: bool = False
|
|
||||||
sso_enforced_for_web_protocol: str = ""
|
|
||||||
enable_web_sso_switch_component: bool = False
|
|
||||||
enable_marketplace: bool = False
|
enable_marketplace: bool = False
|
||||||
max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE
|
max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE
|
||||||
enable_email_code_login: bool = False
|
enable_email_code_login: bool = False
|
||||||
@ -80,6 +126,8 @@ class SystemFeatureModel(BaseModel):
|
|||||||
is_allow_create_workspace: bool = False
|
is_allow_create_workspace: bool = False
|
||||||
is_email_setup: bool = False
|
is_email_setup: bool = False
|
||||||
license: LicenseModel = LicenseModel()
|
license: LicenseModel = LicenseModel()
|
||||||
|
branding: BrandingModel = BrandingModel()
|
||||||
|
webapp_auth: WebAppAuthModel = WebAppAuthModel()
|
||||||
|
|
||||||
|
|
||||||
class FeatureService:
|
class FeatureService:
|
||||||
@ -92,6 +140,10 @@ class FeatureService:
|
|||||||
if dify_config.BILLING_ENABLED and tenant_id:
|
if dify_config.BILLING_ENABLED and tenant_id:
|
||||||
cls._fulfill_params_from_billing_api(features, tenant_id)
|
cls._fulfill_params_from_billing_api(features, tenant_id)
|
||||||
|
|
||||||
|
if dify_config.ENTERPRISE_ENABLED:
|
||||||
|
features.webapp_copyright_enabled = True
|
||||||
|
cls._fulfill_params_from_workspace_info(features, tenant_id)
|
||||||
|
|
||||||
return features
|
return features
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -111,8 +163,8 @@ class FeatureService:
|
|||||||
cls._fulfill_system_params_from_env(system_features)
|
cls._fulfill_system_params_from_env(system_features)
|
||||||
|
|
||||||
if dify_config.ENTERPRISE_ENABLED:
|
if dify_config.ENTERPRISE_ENABLED:
|
||||||
system_features.enable_web_sso_switch_component = True
|
system_features.branding.enabled = True
|
||||||
|
system_features.webapp_auth.enabled = True
|
||||||
cls._fulfill_params_from_enterprise(system_features)
|
cls._fulfill_params_from_enterprise(system_features)
|
||||||
|
|
||||||
if dify_config.MARKETPLACE_ENABLED:
|
if dify_config.MARKETPLACE_ENABLED:
|
||||||
@ -136,6 +188,14 @@ class FeatureService:
|
|||||||
features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED
|
features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED
|
||||||
features.education.enabled = dify_config.EDUCATION_ENABLED
|
features.education.enabled = dify_config.EDUCATION_ENABLED
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _fulfill_params_from_workspace_info(cls, features: FeatureModel, tenant_id: str):
|
||||||
|
workspace_info = EnterpriseService.get_workspace_info(tenant_id)
|
||||||
|
if "WorkspaceMembers" in workspace_info:
|
||||||
|
features.workspace_members.size = workspace_info["WorkspaceMembers"]["used"]
|
||||||
|
features.workspace_members.limit = workspace_info["WorkspaceMembers"]["limit"]
|
||||||
|
features.workspace_members.enabled = workspace_info["WorkspaceMembers"]["enabled"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
|
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
|
||||||
billing_info = BillingService.get_info(tenant_id)
|
billing_info = BillingService.get_info(tenant_id)
|
||||||
@ -145,6 +205,9 @@ class FeatureService:
|
|||||||
features.billing.subscription.interval = billing_info["subscription"]["interval"]
|
features.billing.subscription.interval = billing_info["subscription"]["interval"]
|
||||||
features.education.activated = billing_info["subscription"].get("education", False)
|
features.education.activated = billing_info["subscription"].get("education", False)
|
||||||
|
|
||||||
|
if features.billing.subscription.plan != "sandbox":
|
||||||
|
features.webapp_copyright_enabled = True
|
||||||
|
|
||||||
if "members" in billing_info:
|
if "members" in billing_info:
|
||||||
features.members.size = billing_info["members"]["size"]
|
features.members.size = billing_info["members"]["size"]
|
||||||
features.members.limit = billing_info["members"]["limit"]
|
features.members.limit = billing_info["members"]["limit"]
|
||||||
@ -178,38 +241,53 @@ class FeatureService:
|
|||||||
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
|
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _fulfill_params_from_enterprise(cls, features):
|
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
|
||||||
enterprise_info = EnterpriseService.get_info()
|
enterprise_info = EnterpriseService.get_info()
|
||||||
|
|
||||||
if "sso_enforced_for_signin" in enterprise_info:
|
if "SSOEnforcedForSignin" in enterprise_info:
|
||||||
features.sso_enforced_for_signin = enterprise_info["sso_enforced_for_signin"]
|
features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"]
|
||||||
|
|
||||||
if "sso_enforced_for_signin_protocol" in enterprise_info:
|
if "SSOEnforcedForSigninProtocol" in enterprise_info:
|
||||||
features.sso_enforced_for_signin_protocol = enterprise_info["sso_enforced_for_signin_protocol"]
|
features.sso_enforced_for_signin_protocol = enterprise_info["SSOEnforcedForSigninProtocol"]
|
||||||
|
|
||||||
if "sso_enforced_for_web" in enterprise_info:
|
if "EnableEmailCodeLogin" in enterprise_info:
|
||||||
features.sso_enforced_for_web = enterprise_info["sso_enforced_for_web"]
|
features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"]
|
||||||
|
|
||||||
if "sso_enforced_for_web_protocol" in enterprise_info:
|
if "EnableEmailPasswordLogin" in enterprise_info:
|
||||||
features.sso_enforced_for_web_protocol = enterprise_info["sso_enforced_for_web_protocol"]
|
features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"]
|
||||||
|
|
||||||
if "enable_email_code_login" in enterprise_info:
|
if "IsAllowRegister" in enterprise_info:
|
||||||
features.enable_email_code_login = enterprise_info["enable_email_code_login"]
|
features.is_allow_register = enterprise_info["IsAllowRegister"]
|
||||||
|
|
||||||
if "enable_email_password_login" in enterprise_info:
|
if "IsAllowCreateWorkspace" in enterprise_info:
|
||||||
features.enable_email_password_login = enterprise_info["enable_email_password_login"]
|
features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"]
|
||||||
|
|
||||||
if "is_allow_register" in enterprise_info:
|
if "Branding" in enterprise_info:
|
||||||
features.is_allow_register = enterprise_info["is_allow_register"]
|
features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
|
||||||
|
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")
|
||||||
|
features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "")
|
||||||
|
features.branding.favicon = enterprise_info["Branding"].get("favicon", "")
|
||||||
|
|
||||||
if "is_allow_create_workspace" in enterprise_info:
|
if "WebAppAuth" in enterprise_info:
|
||||||
features.is_allow_create_workspace = enterprise_info["is_allow_create_workspace"]
|
features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False)
|
||||||
|
features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get(
|
||||||
|
"allowEmailCodeLogin", False
|
||||||
|
)
|
||||||
|
features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get(
|
||||||
|
"allowEmailPasswordLogin", False
|
||||||
|
)
|
||||||
|
features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "")
|
||||||
|
|
||||||
if "license" in enterprise_info:
|
if "License" in enterprise_info:
|
||||||
license_info = enterprise_info["license"]
|
license_info = enterprise_info["License"]
|
||||||
|
|
||||||
if "status" in license_info:
|
if "status" in license_info:
|
||||||
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
|
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
|
||||||
|
|
||||||
if "expired_at" in license_info:
|
if "expiredAt" in license_info:
|
||||||
features.license.expired_at = license_info["expired_at"]
|
features.license.expired_at = license_info["expiredAt"]
|
||||||
|
|
||||||
|
if "workspaces" in license_info:
|
||||||
|
features.license.workspaces.enabled = license_info["workspaces"]["enabled"]
|
||||||
|
features.license.workspaces.limit = license_info["workspaces"]["limit"]
|
||||||
|
features.license.workspaces.size = license_info["workspaces"]["used"]
|
||||||
|
141
api/services/webapp_auth_service.py
Normal file
141
api/services/webapp_auth_service.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import random
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
|
from werkzeug.exceptions import NotFound, Unauthorized
|
||||||
|
|
||||||
|
from configs import dify_config
|
||||||
|
from controllers.web.error import WebAppAuthAccessDeniedError
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from libs.helper import TokenManager
|
||||||
|
from libs.passport import PassportService
|
||||||
|
from libs.password import compare_password
|
||||||
|
from models.account import Account, AccountStatus
|
||||||
|
from models.model import App, EndUser, Site
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
from tasks.mail_email_code_login import send_email_code_login_mail_task
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppAuthService:
|
||||||
|
"""Service for web app authentication."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate(email: str, password: str) -> Account:
|
||||||
|
"""authenticate account with email and password"""
|
||||||
|
|
||||||
|
account = Account.query.filter_by(email=email).first()
|
||||||
|
if not account:
|
||||||
|
raise AccountNotFoundError()
|
||||||
|
|
||||||
|
if account.status == AccountStatus.BANNED.value:
|
||||||
|
raise AccountLoginError("Account is banned.")
|
||||||
|
|
||||||
|
if account.password is None or not compare_password(password, account.password, account.password_salt):
|
||||||
|
raise AccountPasswordError("Invalid email or password.")
|
||||||
|
|
||||||
|
return cast(Account, account)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def login(cls, account: Account, app_code: str, end_user_id: str) -> str:
|
||||||
|
site = db.session.query(Site).filter(Site.code == app_code).first()
|
||||||
|
if not site:
|
||||||
|
raise NotFound("Site not found.")
|
||||||
|
|
||||||
|
access_token = cls._get_account_jwt_token(account=account, site=site, end_user_id=end_user_id)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_user_through_email(cls, email: str):
|
||||||
|
account = db.session.query(Account).filter(Account.email == email).first()
|
||||||
|
if not account:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if account.status == AccountStatus.BANNED.value:
|
||||||
|
raise Unauthorized("Account is banned.")
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def send_email_code_login_email(
|
||||||
|
cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
|
||||||
|
):
|
||||||
|
email = account.email if account else email
|
||||||
|
if email is None:
|
||||||
|
raise ValueError("Email must be provided.")
|
||||||
|
|
||||||
|
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||||
|
token = TokenManager.generate_token(
|
||||||
|
account=account, email=email, token_type="webapp_email_code_login", additional_data={"code": code}
|
||||||
|
)
|
||||||
|
send_email_code_login_mail_task.delay(
|
||||||
|
language=language,
|
||||||
|
to=account.email if account else email,
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_email_code_login_data(cls, token: str) -> Optional[dict[str, Any]]:
|
||||||
|
return TokenManager.get_token_data(token, "webapp_email_code_login")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def revoke_email_code_login_token(cls, token: str):
|
||||||
|
TokenManager.revoke_token(token, "webapp_email_code_login")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_end_user(cls, app_code, email) -> EndUser:
|
||||||
|
site = db.session.query(Site).filter(Site.code == app_code).first()
|
||||||
|
if not site:
|
||||||
|
raise NotFound("Site not found.")
|
||||||
|
app_model = db.session.query(App).filter(App.id == site.app_id).first()
|
||||||
|
if not app_model:
|
||||||
|
raise NotFound("App not found.")
|
||||||
|
end_user = EndUser(
|
||||||
|
tenant_id=app_model.tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
type="browser",
|
||||||
|
is_anonymous=False,
|
||||||
|
session_id=email,
|
||||||
|
name="enterpriseuser",
|
||||||
|
external_user_id="enterpriseuser",
|
||||||
|
)
|
||||||
|
db.session.add(end_user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return end_user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_user_accessibility(cls, account: Account, app_code: str):
|
||||||
|
"""Check if the user is allowed to access the app."""
|
||||||
|
system_features = FeatureService.get_system_features()
|
||||||
|
if system_features.webapp_auth.enabled:
|
||||||
|
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
|
||||||
|
|
||||||
|
if (
|
||||||
|
app_settings.access_mode != "public"
|
||||||
|
and not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(account.id, app_code=app_code)
|
||||||
|
):
|
||||||
|
raise WebAppAuthAccessDeniedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_account_jwt_token(cls, account: Account, site: Site, end_user_id: str) -> str:
|
||||||
|
exp_dt = datetime.now(UTC) + timedelta(hours=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 24)
|
||||||
|
exp = int(exp_dt.timestamp())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": site.id,
|
||||||
|
"sub": "Web API Passport",
|
||||||
|
"app_id": site.app_id,
|
||||||
|
"app_code": site.code,
|
||||||
|
"user_id": account.id,
|
||||||
|
"end_user_id": end_user_id,
|
||||||
|
"token_source": "webapp",
|
||||||
|
"exp": exp,
|
||||||
|
}
|
||||||
|
|
||||||
|
token: str = PassportService().issue(payload)
|
||||||
|
return token
|
@ -6,6 +6,7 @@ from celery import shared_task # type: ignore
|
|||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
|
||||||
from extensions.ext_mail import mail
|
from extensions.ext_mail import mail
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
@shared_task(queue="mail")
|
@shared_task(queue="mail")
|
||||||
@ -25,10 +26,24 @@ def send_email_code_login_mail_task(language: str, to: str, code: str):
|
|||||||
# send email code login mail using different languages
|
# send email code login mail using different languages
|
||||||
try:
|
try:
|
||||||
if language == "zh-Hans":
|
if language == "zh-Hans":
|
||||||
html_content = render_template("email_code_login_mail_template_zh-CN.html", to=to, code=code)
|
template = "email_code_login_mail_template_zh-CN.html"
|
||||||
|
system_features = FeatureService.get_system_features()
|
||||||
|
if system_features.branding.enabled:
|
||||||
|
application_title = system_features.branding.application_title
|
||||||
|
template = "without-brand/email_code_login_mail_template_zh-CN.html"
|
||||||
|
html_content = render_template(template, to=to, code=code, application_title=application_title)
|
||||||
|
else:
|
||||||
|
html_content = render_template(template, to=to, code=code)
|
||||||
mail.send(to=to, subject="邮箱验证码", html=html_content)
|
mail.send(to=to, subject="邮箱验证码", html=html_content)
|
||||||
else:
|
else:
|
||||||
html_content = render_template("email_code_login_mail_template_en-US.html", to=to, code=code)
|
template = "email_code_login_mail_template_en-US.html"
|
||||||
|
system_features = FeatureService.get_system_features()
|
||||||
|
if system_features.branding.enabled:
|
||||||
|
application_title = system_features.branding.application_title
|
||||||
|
template = "without-brand/email_code_login_mail_template_en-US.html"
|
||||||
|
html_content = render_template(template, to=to, code=code, application_title=application_title)
|
||||||
|
else:
|
||||||
|
html_content = render_template(template, to=to, code=code)
|
||||||
mail.send(to=to, subject="Email Code", html=html_content)
|
mail.send(to=to, subject="Email Code", html=html_content)
|
||||||
|
|
||||||
end_at = time.perf_counter()
|
end_at = time.perf_counter()
|
||||||
|
33
api/tasks/mail_enterprise_task.py
Normal file
33
api/tasks/mail_enterprise_task.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import click
|
||||||
|
from celery import shared_task # type: ignore
|
||||||
|
from flask import render_template_string
|
||||||
|
|
||||||
|
from extensions.ext_mail import mail
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(queue="mail")
|
||||||
|
def send_enterprise_email_task(to, subject, body, substitutions):
|
||||||
|
if not mail.is_inited():
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info(click.style("Start enterprise mail to {} with subject {}".format(to, subject), fg="green"))
|
||||||
|
start_at = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
html_content = render_template_string(body, **substitutions)
|
||||||
|
|
||||||
|
if isinstance(to, list):
|
||||||
|
for t in to:
|
||||||
|
mail.send(to=t, subject=subject, html=html_content)
|
||||||
|
else:
|
||||||
|
mail.send(to=to, subject=subject, html=html_content)
|
||||||
|
|
||||||
|
end_at = time.perf_counter()
|
||||||
|
logging.info(
|
||||||
|
click.style("Send enterprise mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Send enterprise mail to {} failed".format(to))
|
@ -7,6 +7,7 @@ from flask import render_template
|
|||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from extensions.ext_mail import mail
|
from extensions.ext_mail import mail
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
@shared_task(queue="mail")
|
@shared_task(queue="mail")
|
||||||
@ -33,23 +34,45 @@ def send_invite_member_mail_task(language: str, to: str, token: str, inviter_nam
|
|||||||
try:
|
try:
|
||||||
url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}"
|
url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}"
|
||||||
if language == "zh-Hans":
|
if language == "zh-Hans":
|
||||||
html_content = render_template(
|
template = "invite_member_mail_template_zh-CN.html"
|
||||||
"invite_member_mail_template_zh-CN.html",
|
system_features = FeatureService.get_system_features()
|
||||||
to=to,
|
if system_features.branding.enabled:
|
||||||
inviter_name=inviter_name,
|
application_title = system_features.branding.application_title
|
||||||
workspace_name=workspace_name,
|
template = "without-brand/invite_member_mail_template_zh-CN.html"
|
||||||
url=url,
|
html_content = render_template(
|
||||||
)
|
template,
|
||||||
mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
|
to=to,
|
||||||
|
inviter_name=inviter_name,
|
||||||
|
workspace_name=workspace_name,
|
||||||
|
url=url,
|
||||||
|
application_title=application_title,
|
||||||
|
)
|
||||||
|
mail.send(to=to, subject=f"立即加入 {application_title} 工作空间", html=html_content)
|
||||||
|
else:
|
||||||
|
html_content = render_template(
|
||||||
|
template, to=to, inviter_name=inviter_name, workspace_name=workspace_name, url=url
|
||||||
|
)
|
||||||
|
mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
|
||||||
else:
|
else:
|
||||||
html_content = render_template(
|
template = "invite_member_mail_template_en-US.html"
|
||||||
"invite_member_mail_template_en-US.html",
|
system_features = FeatureService.get_system_features()
|
||||||
to=to,
|
if system_features.branding.enabled:
|
||||||
inviter_name=inviter_name,
|
application_title = system_features.branding.application_title
|
||||||
workspace_name=workspace_name,
|
template = "without-brand/invite_member_mail_template_en-US.html"
|
||||||
url=url,
|
html_content = render_template(
|
||||||
)
|
template,
|
||||||
mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)
|
to=to,
|
||||||
|
inviter_name=inviter_name,
|
||||||
|
workspace_name=workspace_name,
|
||||||
|
url=url,
|
||||||
|
application_title=application_title,
|
||||||
|
)
|
||||||
|
mail.send(to=to, subject=f"Join {application_title} Workspace Now", html=html_content)
|
||||||
|
else:
|
||||||
|
html_content = render_template(
|
||||||
|
template, to=to, inviter_name=inviter_name, workspace_name=workspace_name, url=url
|
||||||
|
)
|
||||||
|
mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)
|
||||||
|
|
||||||
end_at = time.perf_counter()
|
end_at = time.perf_counter()
|
||||||
logging.info(
|
logging.info(
|
||||||
|
@ -6,6 +6,7 @@ from celery import shared_task # type: ignore
|
|||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
|
||||||
from extensions.ext_mail import mail
|
from extensions.ext_mail import mail
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
|
||||||
|
|
||||||
@shared_task(queue="mail")
|
@shared_task(queue="mail")
|
||||||
@ -25,11 +26,27 @@ def send_reset_password_mail_task(language: str, to: str, code: str):
|
|||||||
# send reset password mail using different languages
|
# send reset password mail using different languages
|
||||||
try:
|
try:
|
||||||
if language == "zh-Hans":
|
if language == "zh-Hans":
|
||||||
html_content = render_template("reset_password_mail_template_zh-CN.html", to=to, code=code)
|
template = "reset_password_mail_template_zh-CN.html"
|
||||||
mail.send(to=to, subject="设置您的 Dify 密码", html=html_content)
|
system_features = FeatureService.get_system_features()
|
||||||
|
if system_features.branding.enabled:
|
||||||
|
application_title = system_features.branding.application_title
|
||||||
|
template = "without-brand/reset_password_mail_template_zh-CN.html"
|
||||||
|
html_content = render_template(template, to=to, code=code, application_title=application_title)
|
||||||
|
mail.send(to=to, subject=f"设置您的 {application_title} 密码", html=html_content)
|
||||||
|
else:
|
||||||
|
html_content = render_template(template, to=to, code=code)
|
||||||
|
mail.send(to=to, subject="设置您的 Dify 密码", html=html_content)
|
||||||
else:
|
else:
|
||||||
html_content = render_template("reset_password_mail_template_en-US.html", to=to, code=code)
|
template = "reset_password_mail_template_en-US.html"
|
||||||
mail.send(to=to, subject="Set Your Dify Password", html=html_content)
|
system_features = FeatureService.get_system_features()
|
||||||
|
if system_features.branding.enabled:
|
||||||
|
application_title = system_features.branding.application_title
|
||||||
|
template = "without-brand/reset_password_mail_template_en-US.html"
|
||||||
|
html_content = render_template(template, to=to, code=code, application_title=application_title)
|
||||||
|
mail.send(to=to, subject=f"Set Your {application_title} Password", html=html_content)
|
||||||
|
else:
|
||||||
|
html_content = render_template(template, to=to, code=code)
|
||||||
|
mail.send(to=to, subject="Set Your Dify Password", html=html_content)
|
||||||
|
|
||||||
end_at = time.perf_counter()
|
end_at = time.perf_counter()
|
||||||
logging.info(
|
logging.info(
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">Your login code for {{application_title}}</p>
|
||||||
|
<p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">If you didn't request a login, don't worry. You can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">{{application_title}} 的登录验证码</p>
|
||||||
|
<p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">如果您没有请求登录,请不要担心。您可以安全地忽略此电子邮件。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #374151;
|
||||||
|
background-color: #E5E7EB;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #F3F4F6;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: #2970FF;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #265DD4;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #777777;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear {{ to }},</p>
|
||||||
|
<p>{{ inviter_name }} is pleased to invite you to join our workspace on {{application_title}}, a platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.</p>
|
||||||
|
<p>Click the button below to log in to {{application_title}} and join the workspace.</p>
|
||||||
|
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Best regards,</p>
|
||||||
|
<p>{{application_title}} Team</p>
|
||||||
|
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #374151;
|
||||||
|
background-color: #E5E7EB;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #F3F4F6;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: #2970FF;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #265DD4;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #777777;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="content">
|
||||||
|
<p>尊敬的 {{ to }},</p>
|
||||||
|
<p>{{ inviter_name }} 现邀请您加入我们在 {{application_title}} 的工作区,这是一个专为 LLM 应用开发而设计的平台。在 {{application_title}} 上,您可以探索、创造和合作,构建和运营 AI 应用。</p>
|
||||||
|
<p>点击下方按钮即可登录 {{application_title}} 并且加入空间。</p>
|
||||||
|
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">在此登录</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>此致,</p>
|
||||||
|
<p>{{application_title}} 团队</p>
|
||||||
|
<p>请不要直接回复此电子邮件;由系统自动发送。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">Set your {{application_title}} password</p>
|
||||||
|
<p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">If you didn't request, don't worry. You can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">设置您的 {{application_title}} 账户密码</p>
|
||||||
|
<p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -15,17 +15,17 @@ import {
|
|||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useContextSelector } from 'use-context-selector'
|
|
||||||
import s from './style.module.css'
|
import s from './style.module.css'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import { useStore } from '@/app/components/app/store'
|
import { useStore } from '@/app/components/app/store'
|
||||||
import AppSideBar from '@/app/components/app-sidebar'
|
import AppSideBar from '@/app/components/app-sidebar'
|
||||||
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
|
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
|
||||||
import { fetchAppDetail, fetchAppSSO } from '@/service/apps'
|
import { fetchAppDetail } from '@/service/apps'
|
||||||
import AppContext, { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||||
import type { App } from '@/types/app'
|
import type { App } from '@/types/app'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
export type IAppDetailLayoutProps = {
|
export type IAppDetailLayoutProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -56,7 +56,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
|||||||
icon: NavIcon
|
icon: NavIcon
|
||||||
selectedIcon: NavIcon
|
selectedIcon: NavIcon
|
||||||
}>>([])
|
}>>([])
|
||||||
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
|
|
||||||
|
|
||||||
const getNavigations = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => {
|
const getNavigations = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => {
|
||||||
const navs = [
|
const navs = [
|
||||||
@ -96,9 +95,10 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
|||||||
return navs
|
return navs
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useDocumentTitle(appDetail?.name || t('common.menus.appDetail'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appDetail) {
|
if (appDetail) {
|
||||||
document.title = `${(appDetail.name || 'App')} - Dify`
|
|
||||||
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||||
const mode = isMobile ? 'collapse' : 'expand'
|
const mode = isMobile ? 'collapse' : 'expand'
|
||||||
setAppSiderbarExpand(isMobile ? mode : localeMode)
|
setAppSiderbarExpand(isMobile ? mode : localeMode)
|
||||||
@ -142,14 +142,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
|||||||
else {
|
else {
|
||||||
setAppDetail({ ...res, enable_sso: false })
|
setAppDetail({ ...res, enable_sso: false })
|
||||||
setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode))
|
setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode))
|
||||||
if (systemFeatures.enable_web_sso_switch_component && canIEditApp) {
|
|
||||||
fetchAppSSO({ appId }).then((ssoRes) => {
|
|
||||||
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace, systemFeatures.enable_web_sso_switch_component])
|
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
|
||||||
|
|
||||||
useUnmount(() => {
|
useUnmount(() => {
|
||||||
setAppDetail()
|
setAppDetail()
|
||||||
|
@ -2,25 +2,22 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useContext, useContextSelector } from 'use-context-selector'
|
import { useContext } from 'use-context-selector'
|
||||||
import AppCard from '@/app/components/app/overview/appCard'
|
import AppCard from '@/app/components/app/overview/appCard'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import { ToastContext } from '@/app/components/base/toast'
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
import {
|
import {
|
||||||
fetchAppDetail,
|
fetchAppDetail,
|
||||||
fetchAppSSO,
|
|
||||||
updateAppSSO,
|
|
||||||
updateAppSiteAccessToken,
|
updateAppSiteAccessToken,
|
||||||
updateAppSiteConfig,
|
updateAppSiteConfig,
|
||||||
updateAppSiteStatus,
|
updateAppSiteStatus,
|
||||||
} from '@/service/apps'
|
} from '@/service/apps'
|
||||||
import type { App, AppSSO } from '@/types/app'
|
import type { App } from '@/types/app'
|
||||||
import type { UpdateAppSiteCodeResponse } from '@/models/app'
|
import type { UpdateAppSiteCodeResponse } from '@/models/app'
|
||||||
import { asyncRunSafe } from '@/utils'
|
import { asyncRunSafe } from '@/utils'
|
||||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||||
import type { IAppCardProps } from '@/app/components/app/overview/appCard'
|
import type { IAppCardProps } from '@/app/components/app/overview/appCard'
|
||||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
import AppContext from '@/context/app-context'
|
|
||||||
|
|
||||||
export type ICardViewProps = {
|
export type ICardViewProps = {
|
||||||
appId: string
|
appId: string
|
||||||
@ -33,18 +30,11 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
|
|||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
const appDetail = useAppStore(state => state.appDetail)
|
const appDetail = useAppStore(state => state.appDetail)
|
||||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||||
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
|
|
||||||
|
|
||||||
const updateAppDetail = async () => {
|
const updateAppDetail = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||||
if (systemFeatures.enable_web_sso_switch_component) {
|
setAppDetail({ ...res })
|
||||||
const ssoRes = await fetchAppSSO({ appId })
|
|
||||||
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setAppDetail({ ...res })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (error) { console.error(error) }
|
catch (error) { console.error(error) }
|
||||||
}
|
}
|
||||||
@ -95,16 +85,6 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
|
|||||||
if (!err)
|
if (!err)
|
||||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||||
|
|
||||||
if (systemFeatures.enable_web_sso_switch_component) {
|
|
||||||
const [sso_err] = await asyncRunSafe<AppSSO>(
|
|
||||||
updateAppSSO({ id: appId, enabled: Boolean(params.enable_sso) }) as Promise<AppSSO>,
|
|
||||||
)
|
|
||||||
if (sso_err) {
|
|
||||||
handleCallbackResult(sso_err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCallbackResult(err)
|
handleCallbackResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
export type IAppDetail = {
|
export type IAppDetail = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -11,12 +13,13 @@ export type IAppDetail = {
|
|||||||
const AppDetail: FC<IAppDetail> = ({ children }) => {
|
const AppDetail: FC<IAppDetail> = ({ children }) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t('common.menus.appDetail'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCurrentWorkspaceDatasetOperator)
|
if (isCurrentWorkspaceDatasetOperator)
|
||||||
return router.replace('/datasets')
|
return router.replace('/datasets')
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [isCurrentWorkspaceDatasetOperator, router])
|
||||||
}, [isCurrentWorkspaceDatasetOperator])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -4,7 +4,8 @@ import { useContext, useContextSelector } from 'use-context-selector'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiMoreFill } from '@remixicon/react'
|
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
import type { App } from '@/types/app'
|
import type { App } from '@/types/app'
|
||||||
import Confirm from '@/app/components/base/confirm'
|
import Confirm from '@/app/components/base/confirm'
|
||||||
import Toast, { ToastContext } from '@/app/components/base/toast'
|
import Toast, { ToastContext } from '@/app/components/base/toast'
|
||||||
@ -30,7 +31,10 @@ import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm-
|
|||||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||||
import { fetchInstalledAppList } from '@/service/explore'
|
import { fetchInstalledAppList } from '@/service/explore'
|
||||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||||
import cn from '@/utils/classnames'
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
|
import AccessControl from '@/app/components/app/app-access-control'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
export type AppCardProps = {
|
export type AppCardProps = {
|
||||||
app: App
|
app: App
|
||||||
@ -40,6 +44,7 @@ export type AppCardProps = {
|
|||||||
const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||||
const { onPlanInfoChanged } = useProviderContext()
|
const { onPlanInfoChanged } = useProviderContext()
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
@ -53,6 +58,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||||
|
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
@ -71,8 +77,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
setShowConfirmDelete(false)
|
setShowConfirmDelete(false)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [app.id, mutateApps, notify, onPlanInfoChanged, onRefresh, t])
|
||||||
}, [app.id])
|
|
||||||
|
|
||||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||||
name,
|
name,
|
||||||
@ -176,6 +181,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
setShowSwitchModal(false)
|
setShowSwitchModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onUpdateAccessControl = useCallback(() => {
|
||||||
|
if (onRefresh)
|
||||||
|
onRefresh()
|
||||||
|
mutateApps()
|
||||||
|
setShowAccessControl(false)
|
||||||
|
}, [onRefresh, mutateApps, setShowAccessControl])
|
||||||
|
|
||||||
const Operations = (props: HtmlContentProps) => {
|
const Operations = (props: HtmlContentProps) => {
|
||||||
const onMouseLeave = async () => {
|
const onMouseLeave = async () => {
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
@ -198,18 +210,24 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
exportCheck()
|
exportCheck()
|
||||||
}
|
}
|
||||||
const onClickSwitch = async (e: React.MouseEvent<HTMLDivElement>) => {
|
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
props.onClick?.()
|
props.onClick?.()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowSwitchModal(true)
|
setShowSwitchModal(true)
|
||||||
}
|
}
|
||||||
const onClickDelete = async (e: React.MouseEvent<HTMLDivElement>) => {
|
const onClickDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
props.onClick?.()
|
props.onClick?.()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowConfirmDelete(true)
|
setShowConfirmDelete(true)
|
||||||
}
|
}
|
||||||
|
const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.onClick?.()
|
||||||
|
e.preventDefault()
|
||||||
|
setShowAccessControl(true)
|
||||||
|
}
|
||||||
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
props.onClick?.()
|
props.onClick?.()
|
||||||
@ -226,41 +244,49 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full py-1" onMouseLeave={onMouseLeave}>
|
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||||
<button className='mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover' onClick={onClickSettings}>
|
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickSettings}>
|
||||||
<span className='system-sm-regular text-text-secondary'>{t('app.editApp')}</span>
|
<span className='system-sm-regular text-text-secondary'>{t('app.editApp')}</span>
|
||||||
</button>
|
</button>
|
||||||
<Divider className="!my-1" />
|
<Divider className="my-1" />
|
||||||
<button className='mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover' onClick={onClickDuplicate}>
|
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickDuplicate}>
|
||||||
<span className='system-sm-regular text-text-secondary'>{t('app.duplicate')}</span>
|
<span className='system-sm-regular text-text-secondary'>{t('app.duplicate')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className='mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover' onClick={onClickExport}>
|
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickExport}>
|
||||||
<span className='system-sm-regular text-text-secondary'>{t('app.export')}</span>
|
<span className='system-sm-regular text-text-secondary'>{t('app.export')}</span>
|
||||||
</button>
|
</button>
|
||||||
{(app.mode === 'completion' || app.mode === 'chat') && (
|
{(app.mode === 'completion' || app.mode === 'chat') && (
|
||||||
<>
|
<>
|
||||||
<Divider className="!my-1" />
|
<Divider className="my-1" />
|
||||||
<div
|
<button
|
||||||
className='mx-1 flex h-9 cursor-pointer items-center rounded-lg px-3 py-2 hover:bg-state-base-hover'
|
className='mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||||
onClick={onClickSwitch}
|
onClick={onClickSwitch}
|
||||||
>
|
>
|
||||||
<span className='text-sm leading-5 text-text-secondary'>{t('app.switch')}</span>
|
<span className='text-sm leading-5 text-text-secondary'>{t('app.switch')}</span>
|
||||||
</div>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Divider className="!my-1" />
|
<Divider className="my-1" />
|
||||||
<button className='mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
||||||
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
||||||
</button>
|
</button>
|
||||||
<Divider className="!my-1" />
|
<Divider className="my-1" />
|
||||||
<div
|
{
|
||||||
className='group mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover'
|
systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && <>
|
||||||
|
<button className='mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickAccessControl}>
|
||||||
|
<span className='text-sm leading-5 text-text-secondary'>{t('app.accessControl')}</span>
|
||||||
|
</button>
|
||||||
|
<Divider className='my-1' />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
className='group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover'
|
||||||
onClick={onClickDelete}
|
onClick={onClickDelete}
|
||||||
>
|
>
|
||||||
<span className='system-sm-regular text-text-secondary group-hover:text-text-destructive'>
|
<span className='system-sm-regular text-text-secondary group-hover:text-text-destructive'>
|
||||||
{t('common.operation.delete')}
|
{t('common.operation.delete')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -302,6 +328,17 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
{app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>}
|
{app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex h-5 w-5 shrink-0 items-center justify-center'>
|
||||||
|
{app.access_mode === AccessMode.PUBLIC && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.anyone')}>
|
||||||
|
<RiGlobalLine className='h-4 w-4 text-text-accent' />
|
||||||
|
</Tooltip>}
|
||||||
|
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.specific')}>
|
||||||
|
<RiLockLine className='h-4 w-4 text-text-quaternary' />
|
||||||
|
</Tooltip>}
|
||||||
|
{app.access_mode === AccessMode.ORGANIZATION && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.organization')}>
|
||||||
|
<RiBuildingLine className='h-4 w-4 text-text-quaternary' />
|
||||||
|
</Tooltip>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
|
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
|
||||||
<div
|
<div
|
||||||
@ -358,7 +395,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
popupClassName={
|
popupClassName={
|
||||||
(app.mode === 'completion' || app.mode === 'chat')
|
(app.mode === 'completion' || app.mode === 'chat')
|
||||||
? '!w-[256px] translate-x-[-224px]'
|
? '!w-[256px] translate-x-[-224px]'
|
||||||
: '!w-[160px] translate-x-[-128px]'
|
: '!w-[216px] translate-x-[-128px]'
|
||||||
}
|
}
|
||||||
className={'!z-20 h-fit'}
|
className={'!z-20 h-fit'}
|
||||||
/>
|
/>
|
||||||
@ -419,6 +456,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
|||||||
onClose={() => setSecretEnvList([])}
|
onClose={() => setSecretEnvList([])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showAccessControl && (
|
||||||
|
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,6 @@ const Apps = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t('common.menus.apps')} - Dify`
|
|
||||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||||
mutate()
|
mutate()
|
||||||
|
12
web/app/(commonLayout)/apps/layout.tsx
Normal file
12
web/app/(commonLayout)/apps/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t('common.menus.apps'))
|
||||||
|
return (<>
|
||||||
|
{children}
|
||||||
|
</>)
|
||||||
|
}
|
@ -1,24 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useContextSelector } from 'use-context-selector'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
|
import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import style from '../list.module.css'
|
import style from '../list.module.css'
|
||||||
import Apps from './Apps'
|
import Apps from './Apps'
|
||||||
import AppContext from '@/context/app-context'
|
|
||||||
import { LicenseStatus } from '@/types/feature'
|
|
||||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const AppList = () => {
|
const AppList = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
useEducationInit()
|
useEducationInit()
|
||||||
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
|
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
|
||||||
<Apps />
|
<Apps />
|
||||||
{systemFeatures.license.status === LicenseStatus.NONE && <footer className='shrink-0 grow-0 px-12 py-6'>
|
{!systemFeatures.branding.enabled && <footer className='shrink-0 grow-0 px-12 py-6'>
|
||||||
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
|
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
|
||||||
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>
|
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>
|
||||||
<div className='mt-3 flex items-center gap-2'>
|
<div className='mt-3 flex items-center gap-2'>
|
||||||
|
@ -31,6 +31,7 @@ import { getLocaleOnClient } from '@/i18n'
|
|||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
import LinkedAppsPanel from '@/app/components/base/linked-apps-panel'
|
import LinkedAppsPanel from '@/app/components/base/linked-apps-panel'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
export type IAppDetailLayoutProps = {
|
export type IAppDetailLayoutProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -158,10 +159,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
|||||||
return baseNavigation
|
return baseNavigation
|
||||||
}, [datasetRes?.provider, datasetId, t])
|
}, [datasetRes?.provider, datasetId, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useDocumentTitle(datasetRes?.name || t('common.menus.datasets'))
|
||||||
if (datasetRes)
|
|
||||||
document.title = `${datasetRes.name || 'Dataset'} - Dify`
|
|
||||||
}, [datasetRes])
|
|
||||||
|
|
||||||
const setAppSiderbarExpand = useStore(state => state.setAppSiderbarExpand)
|
const setAppSiderbarExpand = useStore(state => state.setAppSiderbarExpand)
|
||||||
|
|
||||||
|
@ -29,16 +29,18 @@ import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
|||||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
const Container = () => {
|
const Container = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
|
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
|
||||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||||
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
|
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
|
||||||
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
|
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
|
||||||
|
useDocumentTitle(t('dataset.knowledge'))
|
||||||
document.title = `${t('dataset.knowledge')} - Dify`
|
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -125,7 +127,7 @@ const Container = () => {
|
|||||||
{activeTab === 'dataset' && (
|
{activeTab === 'dataset' && (
|
||||||
<>
|
<>
|
||||||
<Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} />
|
<Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} />
|
||||||
<DatasetFooter />
|
{!systemFeatures.branding.enabled && <DatasetFooter />}
|
||||||
{showTagManagementModal && (
|
{showTagManagementModal && (
|
||||||
<TagManagementModal type='knowledge' show={showTagManagementModal} />
|
<TagManagementModal type='knowledge' show={showTagManagementModal} />
|
||||||
)}
|
)}
|
||||||
|
@ -3,12 +3,12 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import useSWRInfinite from 'swr/infinite'
|
import useSWRInfinite from 'swr/infinite'
|
||||||
import { debounce } from 'lodash-es'
|
import { debounce } from 'lodash-es'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import NewDatasetCard from './NewDatasetCard'
|
import NewDatasetCard from './NewDatasetCard'
|
||||||
import DatasetCard from './DatasetCard'
|
import DatasetCard from './DatasetCard'
|
||||||
import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets'
|
import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets'
|
||||||
import { fetchDatasets } from '@/service/datasets'
|
import { fetchDatasets } from '@/service/datasets'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const getKey = (
|
const getKey = (
|
||||||
pageIndex: number,
|
pageIndex: number,
|
||||||
@ -48,6 +48,7 @@ const Datasets = ({
|
|||||||
keywords,
|
keywords,
|
||||||
includeAll,
|
includeAll,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||||
const { data, isLoading, setSize, mutate } = useSWRInfinite(
|
const { data, isLoading, setSize, mutate } = useSWRInfinite(
|
||||||
(pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll),
|
(pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll),
|
||||||
@ -57,11 +58,8 @@ const Datasets = ({
|
|||||||
const loadingStateRef = useRef(false)
|
const loadingStateRef = useRef(false)
|
||||||
const anchorRef = useRef<HTMLAnchorElement>(null)
|
const anchorRef = useRef<HTMLAnchorElement>(null)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadingStateRef.current = isLoading
|
loadingStateRef.current = isLoading
|
||||||
document.title = `${t('dataset.knowledge')} - Dify`
|
|
||||||
}, [isLoading, t])
|
}, [isLoading, t])
|
||||||
|
|
||||||
const onScroll = useCallback(
|
const onScroll = useCallback(
|
||||||
@ -87,7 +85,7 @@ const Datasets = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
|
<nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
|
||||||
{ isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} /> }
|
{isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} />}
|
||||||
{data?.map(({ data: datasets }) => datasets.map(dataset => (
|
{data?.map(({ data: datasets }) => datasets.map(dataset => (
|
||||||
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />),
|
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />),
|
||||||
))}
|
))}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import Container from './Container'
|
import Container from './Container'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
const AppList = async () => {
|
const AppList = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t('common.menus.datasets'))
|
||||||
return <Container />
|
return <Container />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
|
|||||||
<div>
|
<div>
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Service API of Dify authenticates using an `API-Key`.
|
Service API authenticates using an `API-Key`.
|
||||||
|
|
||||||
It is suggested that developers store the `API-Key` in the backend instead of sharing or storing it in the client side to avoid the leakage of the `API-Key`, which may lead to property loss.
|
It is suggested that developers store the `API-Key` in the backend instead of sharing or storing it in the client side to avoid the leakage of the `API-Key`, which may lead to property loss.
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, PropertyInstructi
|
|||||||
<div>
|
<div>
|
||||||
### 鉴权
|
### 鉴权
|
||||||
|
|
||||||
Dify Service API 使用 `API-Key` 进行鉴权。
|
Service API 使用 `API-Key` 进行鉴权。
|
||||||
|
|
||||||
建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。
|
建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。
|
||||||
|
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import type { FC } from 'react'
|
'use client'
|
||||||
|
import type { FC, PropsWithChildren } from 'react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import ExploreClient from '@/app/components/explore'
|
import ExploreClient from '@/app/components/explore'
|
||||||
export type IAppDetail = {
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppDetail: FC<IAppDetail> = ({ children }) => {
|
const ExploreLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t('common.menus.explore'))
|
||||||
return (
|
return (
|
||||||
<ExploreClient>
|
<ExploreClient>
|
||||||
{children}
|
{children}
|
||||||
@ -13,4 +15,4 @@ const AppDetail: FC<IAppDetail> = ({ children }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(AppDetail)
|
export default React.memo(ExploreLayout)
|
||||||
|
@ -30,9 +30,4 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Dify',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout
|
export default Layout
|
||||||
|
@ -1,22 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import ToolProviderList from '@/app/components/tools/provider-list'
|
import ToolProviderList from '@/app/components/tools/provider-list'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
const Layout: FC = () => {
|
const ToolsList: FC = () => {
|
||||||
const { t } = useTranslation()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||||
|
const { t } = useTranslation()
|
||||||
useEffect(() => {
|
useDocumentTitle(t('common.menus.tools'))
|
||||||
if (typeof window !== 'undefined')
|
|
||||||
document.title = `${t('tools.title')} - Dify`
|
|
||||||
if (isCurrentWorkspaceDatasetOperator)
|
|
||||||
return router.replace('/datasets')
|
|
||||||
}, [isCurrentWorkspaceDatasetOperator, router, t])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCurrentWorkspaceDatasetOperator)
|
if (isCurrentWorkspaceDatasetOperator)
|
||||||
@ -25,4 +19,4 @@ const Layout: FC = () => {
|
|||||||
|
|
||||||
return <ToolProviderList />
|
return <ToolProviderList />
|
||||||
}
|
}
|
||||||
export default React.memo(Layout)
|
export default React.memo(ToolsList)
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiDoorLockLine } from '@remixicon/react'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import Toast from '@/app/components/base/toast'
|
import Toast from '@/app/components/base/toast'
|
||||||
import { fetchSystemFeatures, fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
|
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
|
||||||
import { setAccessToken } from '@/app/components/share/utils'
|
import { setAccessToken } from '@/app/components/share/utils'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import { SSOProtocol } from '@/types/feature'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
|
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||||
|
|
||||||
const WebSSOForm: FC = () => {
|
const WebSSOForm: FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -23,15 +30,15 @@ const WebSSOForm: FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAppCodeFromRedirectUrl = () => {
|
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||||
const appCode = redirectUrl?.split('/').pop()
|
const appCode = redirectUrl?.split('/').pop()
|
||||||
if (!appCode)
|
if (!appCode)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return appCode
|
return appCode
|
||||||
}
|
}, [redirectUrl])
|
||||||
|
|
||||||
const processTokenAndRedirect = async () => {
|
const processTokenAndRedirect = useCallback(async () => {
|
||||||
const appCode = getAppCodeFromRedirectUrl()
|
const appCode = getAppCodeFromRedirectUrl()
|
||||||
if (!appCode || !tokenFromUrl || !redirectUrl) {
|
if (!appCode || !tokenFromUrl || !redirectUrl) {
|
||||||
showErrorToast('redirect url or app code or token is invalid.')
|
showErrorToast('redirect url or app code or token is invalid.')
|
||||||
@ -40,48 +47,47 @@ const WebSSOForm: FC = () => {
|
|||||||
|
|
||||||
await setAccessToken(appCode, tokenFromUrl)
|
await setAccessToken(appCode, tokenFromUrl)
|
||||||
router.push(redirectUrl)
|
router.push(redirectUrl)
|
||||||
}
|
}, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl])
|
||||||
|
|
||||||
const handleSSOLogin = async (protocol: string) => {
|
const handleSSOLogin = useCallback(async () => {
|
||||||
const appCode = getAppCodeFromRedirectUrl()
|
const appCode = getAppCodeFromRedirectUrl()
|
||||||
if (!appCode || !redirectUrl) {
|
if (!appCode || !redirectUrl) {
|
||||||
showErrorToast('redirect url or app code is invalid.')
|
showErrorToast('redirect url or app code is invalid.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (protocol) {
|
switch (systemFeatures.webapp_auth.sso_config.protocol) {
|
||||||
case 'saml': {
|
case SSOProtocol.SAML: {
|
||||||
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
|
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
|
||||||
router.push(samlRes.url)
|
router.push(samlRes.url)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'oidc': {
|
case SSOProtocol.OIDC: {
|
||||||
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
|
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
|
||||||
router.push(oidcRes.url)
|
router.push(oidcRes.url)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'oauth2': {
|
case SSOProtocol.OAuth2: {
|
||||||
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
|
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
|
||||||
router.push(oauth2Res.url)
|
router.push(oauth2Res.url)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case '':
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
showErrorToast('SSO protocol is not supported.')
|
showErrorToast('SSO protocol is not supported.')
|
||||||
}
|
}
|
||||||
}
|
}, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const res = await fetchSystemFeatures()
|
|
||||||
const protocol = res.sso_enforced_for_web_protocol
|
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
showErrorToast(message)
|
showErrorToast(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenFromUrl) {
|
if (!tokenFromUrl) {
|
||||||
await handleSSOLogin(protocol)
|
await handleSSOLogin()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,15 +95,45 @@ const WebSSOForm: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
}, [message, tokenFromUrl]) // Added dependencies to useEffect
|
}, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin])
|
||||||
|
if (tokenFromUrl)
|
||||||
|
return <div className='flex h-full items-center justify-center'><Loading /></div>
|
||||||
|
if (message) {
|
||||||
|
return <div className='flex h-full items-center justify-center'>
|
||||||
|
<AppUnavailable code={'App Unavailable'} unknownReason={message} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (systemFeatures.webapp_auth.enabled) {
|
||||||
<div className="flex h-full items-center justify-center">
|
if (systemFeatures.webapp_auth.allow_sso) {
|
||||||
<div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}>
|
return (
|
||||||
<Loading type='area' />
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className="flex h-full items-center justify-center">
|
||||||
|
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
|
||||||
|
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
|
||||||
|
<RiDoorLockLine className='h-5 w-5' />
|
||||||
|
</div>
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('login.webapp.noLoginMethod')}</p>
|
||||||
|
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.webapp.noLoginMethodTip')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative my-2 py-2">
|
||||||
|
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
}
|
||||||
|
else {
|
||||||
|
return <div className="flex h-full items-center justify-center">
|
||||||
|
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(WebSSOForm)
|
export default React.memo(WebSSOForm)
|
||||||
|
@ -20,6 +20,7 @@ import AppIcon from '@/app/components/base/app-icon'
|
|||||||
import { IS_CE_EDITION } from '@/config'
|
import { IS_CE_EDITION } from '@/config'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const titleClassName = `
|
const titleClassName = `
|
||||||
system-sm-semibold text-text-secondary
|
system-sm-semibold text-text-secondary
|
||||||
@ -32,7 +33,7 @@ const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
|||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { systemFeatures } = useAppContext()
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
const { mutateUserProfile, userProfile, apps } = useAppContext()
|
const { mutateUserProfile, userProfile, apps } = useAppContext()
|
||||||
const { isEducationAccount } = useProviderContext()
|
const { isEducationAccount } = useProviderContext()
|
||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
@ -138,7 +139,7 @@ export default function AccountPage() {
|
|||||||
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
|
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'>
|
<div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'>
|
||||||
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} />
|
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} />
|
||||||
<div className='ml-4'>
|
<div className='ml-4'>
|
||||||
<p className='system-xl-semibold text-text-primary'>
|
<p className='system-xl-semibold text-text-primary'>
|
||||||
{userProfile.name}
|
{userProfile.name}
|
||||||
|
@ -32,9 +32,4 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Dify',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout
|
export default Layout
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import AccountPage from './account-page'
|
import AccountPage from './account-page'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t('common.menus.account'))
|
||||||
return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'>
|
return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'>
|
||||||
<AccountPage />
|
<AccountPage />
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,8 +7,10 @@ import Button from '@/app/components/base/button'
|
|||||||
|
|
||||||
import { invitationCheck } from '@/service/common'
|
import { invitationCheck } from '@/service/common'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
const ActivateForm = () => {
|
const ActivateForm = () => {
|
||||||
|
useDocumentTitle('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
|
'use client'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../signin/_header'
|
import Header from '../signin/_header'
|
||||||
import ActivateForm from './activateForm'
|
import ActivateForm from './activateForm'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const Activate = () => {
|
const Activate = () => {
|
||||||
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||||
<Header />
|
<Header />
|
||||||
<ActivateForm />
|
<ActivateForm />
|
||||||
<div className='px-8 py-6 text-sm font-normal text-text-tertiary'>
|
{!systemFeatures.branding.enabled && <div className='px-8 py-6 text-sm font-normal text-text-tertiary'>
|
||||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -34,6 +34,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
|
|||||||
import ContentDialog from '@/app/components/base/content-dialog'
|
import ContentDialog from '@/app/components/base/content-dialog'
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView'
|
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView'
|
||||||
|
import Divider from '../base/divider'
|
||||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
|
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
|
||||||
|
|
||||||
export type IAppInfoProps = {
|
export type IAppInfoProps = {
|
||||||
@ -270,8 +271,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setShowDuplicateModal(true)
|
setShowDuplicateModal(true)
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
|
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
|
||||||
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span>
|
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -337,6 +337,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
|||||||
className='flex grow flex-col gap-2 overflow-auto px-2 py-1'
|
className='flex grow flex-col gap-2 overflow-auto px-2 py-1'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Divider />
|
||||||
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'>
|
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'>
|
||||||
<Button
|
<Button
|
||||||
size={'medium'}
|
size={'medium'}
|
||||||
|
@ -16,7 +16,7 @@ export type IAppDetailNavProps = {
|
|||||||
desc: string
|
desc: string
|
||||||
isExternal?: boolean
|
isExternal?: boolean
|
||||||
icon: string
|
icon: string
|
||||||
icon_background: string
|
icon_background: string | null
|
||||||
navigation: Array<{
|
navigation: Array<{
|
||||||
name: string
|
name: string
|
||||||
href: string
|
href: string
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
import { Fragment, useCallback } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type DialogProps = {
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
show: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessControlDialog = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
}: DialogProps) => {
|
||||||
|
const close = useCallback(() => {
|
||||||
|
onClose?.()
|
||||||
|
}, [onClose])
|
||||||
|
return (
|
||||||
|
<Transition appear show={show} as={Fragment}>
|
||||||
|
<Dialog as="div" open={true} className="relative z-20" onClose={() => null}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-background-overlay bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
|
||||||
|
<div onClick={() => close()} className="absolute right-5 top-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
|
||||||
|
<RiCloseLine className='h-5 w-5' />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccessControlDialog
|
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC, PropsWithChildren } from 'react'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import type { AccessMode } from '@/models/access-control'
|
||||||
|
|
||||||
|
type AccessControlItemProps = PropsWithChildren<{
|
||||||
|
type: AccessMode
|
||||||
|
}>
|
||||||
|
|
||||||
|
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||||
|
const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu }))
|
||||||
|
if (currentMenu !== type) {
|
||||||
|
return <div
|
||||||
|
className="cursor-pointer rounded-[10px] border-[1px]
|
||||||
|
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||||
|
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||||
|
onClick={() => setCurrentMenu(type)} >
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="rounded-[10px] border-[1.5px]
|
||||||
|
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessControlItem.displayName = 'AccessControlItem'
|
||||||
|
|
||||||
|
export default AccessControlItem
|
@ -0,0 +1,204 @@
|
|||||||
|
'use client'
|
||||||
|
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useDebounce } from 'ahooks'
|
||||||
|
import { FloatingOverlay } from '@floating-ui/react'
|
||||||
|
import Avatar from '../../base/avatar'
|
||||||
|
import Button from '../../base/button'
|
||||||
|
import Checkbox from '../../base/checkbox'
|
||||||
|
import Input from '../../base/input'
|
||||||
|
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||||
|
import Loading from '../../base/loading'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import classNames from '@/utils/classnames'
|
||||||
|
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||||
|
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||||
|
import { SubjectType } from '@/models/access-control'
|
||||||
|
import { useSelector } from '@/context/app-context'
|
||||||
|
|
||||||
|
export default function AddMemberOrGroupDialog() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||||
|
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||||
|
|
||||||
|
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||||
|
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||||
|
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeyword(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const hasMore = data?.pages?.[0].hasMore ?? false
|
||||||
|
let observer: IntersectionObserver | undefined
|
||||||
|
if (anchorRef.current) {
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isLoading && hasMore)
|
||||||
|
fetchNextPage()
|
||||||
|
}, { rootMargin: '20px' })
|
||||||
|
observer.observe(anchorRef.current)
|
||||||
|
}
|
||||||
|
return () => observer?.disconnect()
|
||||||
|
}, [isLoading, fetchNextPage, anchorRef, data])
|
||||||
|
|
||||||
|
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
|
||||||
|
<PortalToFollowElemTrigger asChild>
|
||||||
|
<Button variant='ghost-accent' size='small' className='flex shrink-0 items-center gap-x-0.5' onClick={() => setOpen(!open)}>
|
||||||
|
<RiAddCircleFill className='h-4 w-4' />
|
||||||
|
<span>{t('common.operation.add')}</span>
|
||||||
|
</Button>
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
{open && <FloatingOverlay />}
|
||||||
|
<PortalToFollowElemContent className='z-[25]'>
|
||||||
|
<div className='relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||||
|
<div className='sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]'>
|
||||||
|
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
isLoading
|
||||||
|
? <div className='p-1'><Loading /></div>
|
||||||
|
: (data?.pages?.length ?? 0) > 0
|
||||||
|
? <>
|
||||||
|
<div className='flex h-7 items-center px-2 py-0.5'>
|
||||||
|
<SelectedGroupsBreadCrumb />
|
||||||
|
</div>
|
||||||
|
<div className='p-1'>
|
||||||
|
{renderGroupOrMember(data?.pages ?? [])}
|
||||||
|
{isFetchingNextPage && <Loading />}
|
||||||
|
</div>
|
||||||
|
<div ref={anchorRef} className='h-0'> </div>
|
||||||
|
</>
|
||||||
|
: <div className='flex h-7 items-center justify-center px-2 py-0.5'>
|
||||||
|
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.noResult')}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupOrMemberData = { subjects: Subject[]; currPage: number }[]
|
||||||
|
function renderGroupOrMember(data: GroupOrMemberData) {
|
||||||
|
return data?.map((page) => {
|
||||||
|
return <div key={`search_group_member_page_${page.currPage}`}>
|
||||||
|
{page.subjects?.map((item, index) => {
|
||||||
|
if (item.subjectType === SubjectType.GROUP)
|
||||||
|
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
|
||||||
|
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedGroupsBreadCrumb() {
|
||||||
|
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||||
|
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||||
|
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||||
|
setSelectedGroupsForBreadcrumb(newGroups)
|
||||||
|
}, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb])
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setSelectedGroupsForBreadcrumb([])
|
||||||
|
}, [setSelectedGroupsForBreadcrumb])
|
||||||
|
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
|
||||||
|
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'text-text-accent cursor-pointer')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||||
|
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||||
|
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
|
||||||
|
<span>/</span>
|
||||||
|
<span className={index === selectedGroupsForBreadcrumb.length - 1 ? '' : 'cursor-pointer text-text-accent'} onClick={() => handleBreadCrumbClick(index)}>{group.name}</span>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupItemProps = {
|
||||||
|
group: AccessControlGroup
|
||||||
|
}
|
||||||
|
function GroupItem({ group }: GroupItemProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||||
|
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||||
|
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||||
|
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||||
|
const handleCheckChange = useCallback(() => {
|
||||||
|
if (!isChecked) {
|
||||||
|
const newGroups = [...specificGroups, group]
|
||||||
|
setSpecificGroups(newGroups)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const newGroups = specificGroups.filter(g => g.id !== group.id)
|
||||||
|
setSpecificGroups(newGroups)
|
||||||
|
}
|
||||||
|
}, [specificGroups, setSpecificGroups, group, isChecked])
|
||||||
|
|
||||||
|
const handleExpandClick = useCallback(() => {
|
||||||
|
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||||
|
}, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group])
|
||||||
|
return <BaseItem>
|
||||||
|
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||||
|
<div className='item-center flex grow'>
|
||||||
|
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||||
|
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||||
|
<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className='system-sm-medium mr-1 text-text-secondary'>{group.name}</p>
|
||||||
|
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||||
|
</div>
|
||||||
|
<Button size="small" disabled={isChecked} variant='ghost-accent'
|
||||||
|
className='flex shrink-0 items-center justify-between px-1.5 py-1' onClick={handleExpandClick}>
|
||||||
|
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
|
||||||
|
<RiArrowRightSLine className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberItemProps = {
|
||||||
|
member: AccessControlAccount
|
||||||
|
}
|
||||||
|
function MemberItem({ member }: MemberItemProps) {
|
||||||
|
const currentUser = useSelector(s => s.userProfile)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||||
|
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||||
|
const handleCheckChange = useCallback(() => {
|
||||||
|
if (!isChecked) {
|
||||||
|
const newMembers = [...specificMembers, member]
|
||||||
|
setSpecificMembers(newMembers)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const newMembers = specificMembers.filter(m => m.id !== member.id)
|
||||||
|
setSpecificMembers(newMembers)
|
||||||
|
}
|
||||||
|
}, [specificMembers, setSpecificMembers, member, isChecked])
|
||||||
|
return <BaseItem className='pr-3'>
|
||||||
|
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||||
|
<div className='flex grow items-center'>
|
||||||
|
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||||
|
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||||
|
<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className='system-sm-medium mr-1 text-text-secondary'>{member.name}</p>
|
||||||
|
{currentUser.email === member.email && <p className='system-xs-regular text-text-tertiary'>({t('common.you')})</p>}
|
||||||
|
</div>
|
||||||
|
<p className='system-xs-regular text-text-quaternary'>{member.email}</p>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseItemProps = {
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
function BaseItem({ children, className }: BaseItemProps) {
|
||||||
|
return <div className={classNames('p-1 pl-2 flex items-center space-x-2 hover:rounded-lg hover:bg-state-base-hover cursor-pointer', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
102
web/app/components/app/app-access-control/index.tsx
Normal file
102
web/app/components/app/app-access-control/index.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
import { Dialog } from '@headlessui/react'
|
||||||
|
import { RiBuildingLine, RiGlobalLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import Button from '../../base/button'
|
||||||
|
import Toast from '../../base/toast'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import AccessControlDialog from './access-control-dialog'
|
||||||
|
import AccessControlItem from './access-control-item'
|
||||||
|
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import type { App } from '@/types/app'
|
||||||
|
import type { Subject } from '@/models/access-control'
|
||||||
|
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||||
|
import { useUpdateAccessMode } from '@/service/access-control'
|
||||||
|
|
||||||
|
type AccessControlProps = {
|
||||||
|
app: App
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccessControl(props: AccessControlProps) {
|
||||||
|
const { app, onClose, onConfirm } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||||
|
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||||
|
const hideTip = systemFeatures.webapp_auth.enabled
|
||||||
|
&& (systemFeatures.webapp_auth.allow_sso
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAppId(app.id)
|
||||||
|
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||||
|
}, [app, setAppId, setCurrentMenu])
|
||||||
|
|
||||||
|
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
const submitData: {
|
||||||
|
appId: string
|
||||||
|
accessMode: AccessMode
|
||||||
|
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||||
|
} = { appId: app.id, accessMode: currentMenu }
|
||||||
|
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||||
|
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||||
|
specificGroups.forEach((group) => {
|
||||||
|
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||||
|
})
|
||||||
|
specificMembers.forEach((member) => {
|
||||||
|
subjects.push({
|
||||||
|
subjectId: member.id,
|
||||||
|
subjectType: SubjectType.ACCOUNT,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
submitData.subjects = subjects
|
||||||
|
}
|
||||||
|
await updateAccessMode(submitData)
|
||||||
|
Toast.notify({ type: 'success', message: t('app.accessControlDialog.updateSuccess') })
|
||||||
|
onConfirm?.()
|
||||||
|
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||||
|
return <AccessControlDialog show onClose={onClose}>
|
||||||
|
<div className='flex flex-col gap-y-3'>
|
||||||
|
<div className='pb-3 pl-6 pr-14 pt-6'>
|
||||||
|
<Dialog.Title className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</Dialog.Title>
|
||||||
|
<Dialog.Description className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-y-1 px-6 pb-3'>
|
||||||
|
<div className='leading-6'>
|
||||||
|
<p className='system-sm-medium'>{t('app.accessControlDialog.accessLabel')}</p>
|
||||||
|
</div>
|
||||||
|
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||||
|
<div className='flex items-center p-3'>
|
||||||
|
<div className='flex grow items-center gap-x-2'>
|
||||||
|
<RiBuildingLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p>
|
||||||
|
</div>
|
||||||
|
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||||
|
</div>
|
||||||
|
</AccessControlItem>
|
||||||
|
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||||
|
<SpecificGroupsOrMembers />
|
||||||
|
</AccessControlItem>
|
||||||
|
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||||
|
<div className='flex items-center gap-x-2 p-3'>
|
||||||
|
<RiGlobalLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
|
||||||
|
</div>
|
||||||
|
</AccessControlItem>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
|
||||||
|
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||||
|
<Button disabled={isPending} loading={isPending} variant='primary' onClick={handleConfirm}>{t('common.operation.confirm')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccessControlDialog>
|
||||||
|
}
|
@ -0,0 +1,139 @@
|
|||||||
|
'use client'
|
||||||
|
import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import Avatar from '../../base/avatar'
|
||||||
|
import Divider from '../../base/divider'
|
||||||
|
import Tooltip from '../../base/tooltip'
|
||||||
|
import Loading from '../../base/loading'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||||
|
|
||||||
|
export default function SpecificGroupsOrMembers() {
|
||||||
|
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||||
|
const appId = useAccessControlStore(s => s.appId)
|
||||||
|
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||||
|
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
const hideTip = systemFeatures.webapp_auth.enabled
|
||||||
|
&& (systemFeatures.webapp_auth.allow_sso
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||||
|
|
||||||
|
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||||
|
useEffect(() => {
|
||||||
|
setSpecificGroups(data?.groups ?? [])
|
||||||
|
setSpecificMembers(data?.members ?? [])
|
||||||
|
}, [data, setSpecificGroups, setSpecificMembers])
|
||||||
|
|
||||||
|
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||||
|
return <div className='flex items-center p-3'>
|
||||||
|
<div className='flex grow items-center gap-x-2'>
|
||||||
|
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||||
|
</div>
|
||||||
|
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<div className='flex items-center gap-x-1 p-3'>
|
||||||
|
<div className='flex grow items-center gap-x-1'>
|
||||||
|
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-x-1'>
|
||||||
|
{!hideTip && <>
|
||||||
|
<WebAppSSONotEnabledTip />
|
||||||
|
<Divider className='ml-2 mr-0 h-[14px]' type="vertical" />
|
||||||
|
</>}
|
||||||
|
<AddMemberOrGroupDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='px-1 pb-1'>
|
||||||
|
<div className='flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2'>
|
||||||
|
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderGroupsAndMembers() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||||
|
return <div className='px-2 pb-1.5 pt-5'><p className='system-xs-regular text-center text-text-tertiary'>{t('app.accessControlDialog.noGroupsOrMembers')}</p></div>
|
||||||
|
return <>
|
||||||
|
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.groups', { count: specificGroups.length ?? 0 })}</p>
|
||||||
|
<div className='flex flex-row flex-wrap gap-1'>
|
||||||
|
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||||
|
</div>
|
||||||
|
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.members', { count: specificMembers.length ?? 0 })}</p>
|
||||||
|
<div className='flex flex-row flex-wrap gap-1'>
|
||||||
|
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupItemProps = {
|
||||||
|
group: AccessControlGroup
|
||||||
|
}
|
||||||
|
function GroupItem({ group }: GroupItemProps) {
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||||
|
const handleRemoveGroup = useCallback(() => {
|
||||||
|
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||||
|
}, [group, setSpecificGroups, specificGroups])
|
||||||
|
return <BaseItem icon={<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />}
|
||||||
|
onRemove={handleRemoveGroup}>
|
||||||
|
<p className='system-xs-regular text-text-primary'>{group.name}</p>
|
||||||
|
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberItemProps = {
|
||||||
|
member: AccessControlAccount
|
||||||
|
}
|
||||||
|
function MemberItem({ member }: MemberItemProps) {
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||||
|
const handleRemoveMember = useCallback(() => {
|
||||||
|
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||||
|
}, [member, setSpecificMembers, specificMembers])
|
||||||
|
return <BaseItem icon={<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />}
|
||||||
|
onRemove={handleRemoveMember}>
|
||||||
|
<p className='system-xs-regular text-text-primary'>{member.name}</p>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseItemProps = {
|
||||||
|
icon: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||||
|
return <div className='group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs'>
|
||||||
|
<div className='h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||||
|
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className='flex h-4 w-4 cursor-pointer items-center justify-center' onClick={onRemove}>
|
||||||
|
<RiCloseCircleFill className='h-[14px] w-[14px] text-text-quaternary' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebAppSSONotEnabledTip() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return <Tooltip asChild={false} popupContent={t('app.accessControlDialog.webAppSSONotEnabledTip')}>
|
||||||
|
<RiAlertFill className='h-4 w-4 shrink-0 text-text-warning-secondary' />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
@ -1,21 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiLockLine,
|
||||||
RiPlanetLine,
|
RiPlanetLine,
|
||||||
RiPlayCircleLine,
|
RiPlayCircleLine,
|
||||||
RiPlayList2Line,
|
RiPlayList2Line,
|
||||||
RiTerminalBoxLine,
|
RiTerminalBoxLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useKeyPress } from 'ahooks'
|
import { useKeyPress } from 'ahooks'
|
||||||
|
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||||
import Toast from '../../base/toast'
|
import Toast from '../../base/toast'
|
||||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
import Divider from '../../base/divider'
|
||||||
|
import AccessControl from '../app-access-control'
|
||||||
|
import Loading from '../../base/loading'
|
||||||
|
import Tooltip from '../../base/tooltip'
|
||||||
import SuggestedAction from './suggested-action'
|
import SuggestedAction from './suggested-action'
|
||||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
@ -34,6 +41,10 @@ import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/co
|
|||||||
import type { InputVar } from '@/app/components/workflow/types'
|
import type { InputVar } from '@/app/components/workflow/types'
|
||||||
import { appDefaultIconBackground } from '@/config'
|
import { appDefaultIconBackground } from '@/config'
|
||||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||||
|
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import { fetchAppDetail } from '@/service/apps'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
export type AppPublisherProps = {
|
export type AppPublisherProps = {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@ -74,11 +85,33 @@ const AppPublisher = ({
|
|||||||
const [published, setPublished] = useState(false)
|
const [published, setPublished] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const appDetail = useAppStore(state => state.appDetail)
|
const appDetail = useAppStore(state => state.appDetail)
|
||||||
|
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||||
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
|
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
|
||||||
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
|
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
|
||||||
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
|
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
|
||||||
|
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||||
|
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||||
|
refetch()
|
||||||
|
}, [open, appDetail, refetch, systemFeatures])
|
||||||
|
|
||||||
|
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||||
|
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (appDetail && appAccessSubjects) {
|
||||||
|
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||||
|
setIsAppAccessSet(false)
|
||||||
|
else
|
||||||
|
setIsAppAccessSet(true)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setIsAppAccessSet(true)
|
||||||
|
}
|
||||||
|
}, [appAccessSubjects, appDetail])
|
||||||
const language = useGetLanguage()
|
const language = useGetLanguage()
|
||||||
const formatTimeFromNow = useCallback((time: number) => {
|
const formatTimeFromNow = useCallback((time: number) => {
|
||||||
return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
|
return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
|
||||||
@ -99,7 +132,7 @@ const AppPublisher = ({
|
|||||||
await onRestore?.()
|
await onRestore?.()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
catch {}
|
catch { }
|
||||||
}, [onRestore])
|
}, [onRestore])
|
||||||
|
|
||||||
const handleTrigger = useCallback(() => {
|
const handleTrigger = useCallback(() => {
|
||||||
@ -130,6 +163,13 @@ const AppPublisher = ({
|
|||||||
}
|
}
|
||||||
}, [appDetail?.id])
|
}, [appDetail?.id])
|
||||||
|
|
||||||
|
const handleAccessControlUpdate = useCallback(() => {
|
||||||
|
fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => {
|
||||||
|
setAppDetail(res)
|
||||||
|
setShowAppAccessControl(false)
|
||||||
|
})
|
||||||
|
}, [appDetail, setAppDetail])
|
||||||
|
|
||||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||||
|
|
||||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||||
@ -138,7 +178,7 @@ const AppPublisher = ({
|
|||||||
return
|
return
|
||||||
handlePublish()
|
handlePublish()
|
||||||
},
|
},
|
||||||
{ exactMatch: true, useCapture: true })
|
{ exactMatch: true, useCapture: true })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -223,70 +263,105 @@ const AppPublisher = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className='border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
|
||||||
<SuggestedAction
|
? <div className='py-2'><Loading /></div>
|
||||||
disabled={!publishedAt}
|
: <>
|
||||||
link={appURL}
|
<Divider className='my-0' />
|
||||||
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
{systemFeatures.webapp_auth.enabled && <div className='p-4 pt-3'>
|
||||||
>
|
<div className='flex h-6 items-center'>
|
||||||
{t('workflow.common.runApp')}
|
<p className='system-xs-medium text-text-tertiary'>{t('app.publishApp.title')}</p>
|
||||||
</SuggestedAction>
|
</div>
|
||||||
{appDetail?.mode === 'workflow' || appDetail?.mode === 'completion'
|
<div className='flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent'
|
||||||
? (
|
|
||||||
<SuggestedAction
|
|
||||||
disabled={!publishedAt}
|
|
||||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
|
||||||
icon={<RiPlayList2Line className='h-4 w-4' />}
|
|
||||||
>
|
|
||||||
{t('workflow.common.batchRunApp')}
|
|
||||||
</SuggestedAction>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<SuggestedAction
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEmbeddingModalOpen(true)
|
setShowAppAccessControl(true)
|
||||||
handleTrigger()
|
}}>
|
||||||
}}
|
<div className='flex grow items-center gap-x-1.5 pr-1'>
|
||||||
|
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||||
|
{appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>}
|
||||||
|
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>}
|
||||||
|
{appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>}
|
||||||
|
</div>
|
||||||
|
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
|
||||||
|
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||||
|
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isAppAccessSet && <p className='system-xs-regular mt-1 text-text-warning'>{t('app.publishApp.notSetDesc')}</p>}
|
||||||
|
</div>}
|
||||||
|
<div className='flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
||||||
|
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
|
||||||
|
<SuggestedAction
|
||||||
|
className='flex-1'
|
||||||
|
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
|
||||||
|
link={appURL}
|
||||||
|
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
{t('workflow.common.runApp')}
|
||||||
|
</SuggestedAction>
|
||||||
|
</Tooltip>
|
||||||
|
{appDetail?.mode === 'workflow' || appDetail?.mode === 'completion'
|
||||||
|
? (
|
||||||
|
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
|
||||||
|
<SuggestedAction
|
||||||
|
className='flex-1'
|
||||||
|
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
|
||||||
|
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||||
|
icon={<RiPlayList2Line className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
{t('workflow.common.batchRunApp')}
|
||||||
|
</SuggestedAction>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<SuggestedAction
|
||||||
|
onClick={() => {
|
||||||
|
setEmbeddingModalOpen(true)
|
||||||
|
handleTrigger()
|
||||||
|
}}
|
||||||
|
disabled={!publishedAt}
|
||||||
|
icon={<CodeBrowser className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
{t('workflow.common.embedIntoSite')}
|
||||||
|
</SuggestedAction>
|
||||||
|
)}
|
||||||
|
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
|
||||||
|
<SuggestedAction
|
||||||
|
className='flex-1'
|
||||||
|
onClick={() => {
|
||||||
|
publishedAt && handleOpenInExplore()
|
||||||
|
}}
|
||||||
|
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
|
||||||
|
icon={<RiPlanetLine className='h-4 w-4' />}
|
||||||
|
>
|
||||||
|
{t('workflow.common.openInExplore')}
|
||||||
|
</SuggestedAction>
|
||||||
|
</Tooltip>
|
||||||
|
<SuggestedAction
|
||||||
disabled={!publishedAt}
|
disabled={!publishedAt}
|
||||||
icon={<CodeBrowser className='h-4 w-4' />}
|
link='./develop'
|
||||||
|
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
||||||
>
|
>
|
||||||
{t('workflow.common.embedIntoSite')}
|
{t('workflow.common.accessAPIReference')}
|
||||||
</SuggestedAction>
|
</SuggestedAction>
|
||||||
)}
|
{appDetail?.mode === 'workflow' && (
|
||||||
<SuggestedAction
|
<WorkflowToolConfigureButton
|
||||||
onClick={() => {
|
disabled={!publishedAt}
|
||||||
publishedAt && handleOpenInExplore()
|
published={!!toolPublished}
|
||||||
}}
|
detailNeedUpdate={!!toolPublished && published}
|
||||||
disabled={!publishedAt}
|
workflowAppId={appDetail?.id}
|
||||||
icon={<RiPlanetLine className='h-4 w-4' />}
|
icon={{
|
||||||
>
|
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||||
{t('workflow.common.openInExplore')}
|
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||||
</SuggestedAction>
|
}}
|
||||||
<SuggestedAction
|
name={appDetail?.name}
|
||||||
disabled={!publishedAt}
|
description={appDetail?.description}
|
||||||
link='./develop'
|
inputs={inputs}
|
||||||
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
handlePublish={handlePublish}
|
||||||
>
|
onRefreshData={onRefreshData}
|
||||||
{t('workflow.common.accessAPIReference')}
|
/>
|
||||||
</SuggestedAction>
|
)}
|
||||||
{appDetail?.mode === 'workflow' && (
|
</div>
|
||||||
<WorkflowToolConfigureButton
|
</>}
|
||||||
disabled={!publishedAt}
|
|
||||||
published={!!toolPublished}
|
|
||||||
detailNeedUpdate={!!toolPublished && published}
|
|
||||||
workflowAppId={appDetail?.id}
|
|
||||||
icon={{
|
|
||||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
|
||||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
|
||||||
}}
|
|
||||||
name={appDetail?.name}
|
|
||||||
description={appDetail?.description}
|
|
||||||
inputs={inputs}
|
|
||||||
handlePublish={handlePublish}
|
|
||||||
onRefreshData={onRefreshData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PortalToFollowElemContent>
|
||||||
<EmbeddedModal
|
<EmbeddedModal
|
||||||
@ -296,9 +371,9 @@ const AppPublisher = ({
|
|||||||
appBaseUrl={appBaseURL}
|
appBaseUrl={appBaseURL}
|
||||||
accessToken={accessToken}
|
accessToken={accessToken}
|
||||||
/>
|
/>
|
||||||
|
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
|
||||||
</PortalToFollowElem >
|
</PortalToFollowElem >
|
||||||
</>
|
</>)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(AppPublisher)
|
export default memo(AppPublisher)
|
||||||
|
@ -8,22 +8,30 @@ export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>
|
}>
|
||||||
|
|
||||||
const SuggestedAction = ({ icon, link, disabled, children, className, ...props }: SuggestedActionProps) => (
|
const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
|
||||||
<a
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
href={disabled ? undefined : link}
|
if (disabled)
|
||||||
target='_blank'
|
return
|
||||||
rel='noreferrer'
|
onClick?.(e)
|
||||||
className={classNames(
|
}
|
||||||
'flex justify-start items-center gap-2 py-2 px-2.5 bg-background-section-burn rounded-lg text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
|
return (
|
||||||
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
|
<a
|
||||||
className,
|
href={disabled ? undefined : link}
|
||||||
)}
|
target='_blank'
|
||||||
{...props}
|
rel='noreferrer'
|
||||||
>
|
className={classNames(
|
||||||
<div className='relative h-4 w-4'>{icon}</div>
|
'flex justify-start items-center gap-2 py-2 px-2.5 bg-background-section-burn rounded-lg text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
|
||||||
<div className='system-sm-medium shrink grow basis-0'>{children}</div>
|
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
|
||||||
<RiArrowRightUpLine className='h-3.5 w-3.5' />
|
className,
|
||||||
</a>
|
)}
|
||||||
)
|
onClick={handleClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className='relative h-4 w-4'>{icon}</div>
|
||||||
|
<div className='system-sm-medium shrink grow basis-0'>{children}</div>
|
||||||
|
<RiArrowRightUpLine className='h-3.5 w-3.5' />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default SuggestedAction
|
export default SuggestedAction
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
|
RiArrowRightSLine,
|
||||||
RiBookOpenLine,
|
RiBookOpenLine,
|
||||||
RiEqualizer2Line,
|
RiEqualizer2Line,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
|
RiLockLine,
|
||||||
RiPaintBrushLine,
|
RiPaintBrushLine,
|
||||||
RiWindowLine,
|
RiWindowLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
@ -18,6 +20,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
|||||||
import AppBasic from '@/app/components/app-sidebar/basic'
|
import AppBasic from '@/app/components/app-sidebar/basic'
|
||||||
import { asyncRunSafe, randomString } from '@/utils'
|
import { asyncRunSafe, randomString } from '@/utils'
|
||||||
import { basePath } from '@/utils/var'
|
import { basePath } from '@/utils/var'
|
||||||
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import Switch from '@/app/components/base/switch'
|
import Switch from '@/app/components/base/switch'
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
@ -29,6 +32,11 @@ import type { AppDetailResponse } from '@/models/app'
|
|||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import type { AppSSO } from '@/types/app'
|
import type { AppSSO } from '@/types/app'
|
||||||
import Indicator from '@/app/components/header/indicator'
|
import Indicator from '@/app/components/header/indicator'
|
||||||
|
import { fetchAppDetail } from '@/service/apps'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import AccessControl from '../app-access-control'
|
||||||
|
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
export type IAppCardProps = {
|
export type IAppCardProps = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -54,13 +62,17 @@ function AppCard({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||||
|
const appDetail = useAppStore(state => state.appDetail)
|
||||||
|
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||||
const [showEmbedded, setShowEmbedded] = useState(false)
|
const [showEmbedded, setShowEmbedded] = useState(false)
|
||||||
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
|
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
|
||||||
const [genLoading, setGenLoading] = useState(false)
|
const [genLoading, setGenLoading] = useState(false)
|
||||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||||
|
const [showAccessControl, setShowAccessControl] = useState<boolean>(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
const { data: appAccessSubjects } = useAppWhiteListSubjects(appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||||
|
|
||||||
const OPERATIONS_MAP = useMemo(() => {
|
const OPERATIONS_MAP = useMemo(() => {
|
||||||
const operationsMap = {
|
const operationsMap = {
|
||||||
@ -128,6 +140,31 @@ function AppCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (appDetail && appAccessSubjects) {
|
||||||
|
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||||
|
setIsAppAccessSet(false)
|
||||||
|
else
|
||||||
|
setIsAppAccessSet(true)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setIsAppAccessSet(true)
|
||||||
|
}
|
||||||
|
}, [appAccessSubjects, appDetail])
|
||||||
|
|
||||||
|
const handleClickAccessControl = useCallback(() => {
|
||||||
|
if (!appDetail)
|
||||||
|
return
|
||||||
|
setShowAccessControl(true)
|
||||||
|
}, [appDetail])
|
||||||
|
const handleAccessControlUpdate = useCallback(() => {
|
||||||
|
fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => {
|
||||||
|
setAppDetail(res)
|
||||||
|
setShowAccessControl(false)
|
||||||
|
})
|
||||||
|
}, [appDetail, setAppDetail])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
@ -206,6 +243,22 @@ function AppCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isApp && systemFeatures.webapp_auth.enabled && appDetail && <div className='flex flex-col items-start justify-center self-stretch'>
|
||||||
|
<div className="system-xs-medium pb-1 text-text-tertiary">{t('app.publishApp.title')}</div>
|
||||||
|
<div className='flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2'
|
||||||
|
onClick={handleClickAccessControl}>
|
||||||
|
<div className='flex grow items-center gap-x-1.5 pr-1'>
|
||||||
|
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||||
|
{appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>}
|
||||||
|
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>}
|
||||||
|
{appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>}
|
||||||
|
</div>
|
||||||
|
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
|
||||||
|
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||||
|
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center gap-1 self-stretch p-3'}>
|
<div className={'flex items-center gap-1 self-stretch p-3'}>
|
||||||
{!isApp && <SecretKeyButton appId={appInfo.id} />}
|
{!isApp && <SecretKeyButton appId={appInfo.id} />}
|
||||||
@ -264,6 +317,11 @@ function AppCard({
|
|||||||
api_base_url={appInfo.api_base_url}
|
api_base_url={appInfo.api_base_url}
|
||||||
mode={appInfo.mode}
|
mode={appInfo.mode}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
showAccessControl && <AccessControl app={appDetail!}
|
||||||
|
onConfirm={handleAccessControlUpdate}
|
||||||
|
onClose={() => { setShowAccessControl(false) }} />
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useState } from 'react'
|
|||||||
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
|
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useContext, useContextSelector } from 'use-context-selector'
|
import { useContext } from 'use-context-selector'
|
||||||
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
|
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
|
||||||
import Modal from '@/app/components/base/modal'
|
import Modal from '@/app/components/base/modal'
|
||||||
import ActionButton from '@/app/components/base/action-button'
|
import ActionButton from '@/app/components/base/action-button'
|
||||||
@ -21,7 +21,6 @@ import type { AppIconType, AppSSO, Language } from '@/types/app'
|
|||||||
import { useToastContext } from '@/app/components/base/toast'
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
import { LanguagesSupported, languages } from '@/i18n/language'
|
import { LanguagesSupported, languages } from '@/i18n/language'
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
import AppContext, { useAppContext } from '@/context/app-context'
|
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import { useModalContext } from '@/context/modal-context'
|
import { useModalContext } from '@/context/modal-context'
|
||||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||||
@ -65,8 +64,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
|
|
||||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
|
||||||
const { notify } = useToastContext()
|
const { notify } = useToastContext()
|
||||||
const [isShowMore, setIsShowMore] = useState(false)
|
const [isShowMore, setIsShowMore] = useState(false)
|
||||||
const {
|
const {
|
||||||
@ -110,7 +107,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
: { type: 'emoji', icon, background: icon_background! },
|
: { type: 'emoji', icon, background: icon_background! },
|
||||||
)
|
)
|
||||||
|
|
||||||
const { enableBilling, plan } = useProviderContext()
|
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
|
||||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||||
const isFreePlan = plan.type === 'sandbox'
|
const isFreePlan = plan.type === 'sandbox'
|
||||||
const handlePlanClick = useCallback(() => {
|
const handlePlanClick = useCallback(() => {
|
||||||
@ -138,7 +135,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
setAppIcon(icon_type === 'image'
|
setAppIcon(icon_type === 'image'
|
||||||
? { type: 'image', url: icon_url!, fileId: icon }
|
? { type: 'image', url: icon_url!, fileId: icon }
|
||||||
: { type: 'emoji', icon, background: icon_background! })
|
: { type: 'emoji', icon, background: icon_background! })
|
||||||
}, [appInfo])
|
}, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon])
|
||||||
|
|
||||||
const onHide = () => {
|
const onHide = () => {
|
||||||
onClose()
|
onClose()
|
||||||
@ -188,7 +185,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
chat_color_theme: inputInfo.chatColorTheme,
|
chat_color_theme: inputInfo.chatColorTheme,
|
||||||
chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
|
chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
|
||||||
prompt_public: false,
|
prompt_public: false,
|
||||||
copyright: isFreePlan
|
copyright: !webappCopyrightEnabled
|
||||||
? ''
|
? ''
|
||||||
: inputInfo.copyrightSwitchValue
|
: inputInfo.copyrightSwitchValue
|
||||||
? inputInfo.copyright
|
? inputInfo.copyright
|
||||||
@ -336,28 +333,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p>
|
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* SSO */}
|
|
||||||
{systemFeatures.enable_web_sso_switch_component && (
|
|
||||||
<>
|
|
||||||
<Divider className="my-0 h-px" />
|
|
||||||
<div className='w-full'>
|
|
||||||
<p className='system-xs-medium-uppercase mb-1 text-text-tertiary'>{t(`${prefixSettings}.sso.label`)}</p>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.sso.title`)}</div>
|
|
||||||
<Tooltip
|
|
||||||
disabled={systemFeatures.sso_enforced_for_web}
|
|
||||||
popupContent={
|
|
||||||
<div className='w-[180px]'>{t(`${prefixSettings}.sso.tooltip`)}</div>
|
|
||||||
}
|
|
||||||
asChild={false}
|
|
||||||
>
|
|
||||||
<Switch disabled={!systemFeatures.sso_enforced_for_web || !isCurrentWorkspaceEditor} defaultValue={systemFeatures.sso_enforced_for_web && inputInfo.enable_sso} onChange={v => setInputInfo({ ...inputInfo, enable_sso: v })}></Switch>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.sso.description`)}</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* more settings switch */}
|
{/* more settings switch */}
|
||||||
<Divider className="my-0 h-px" />
|
<Divider className="my-0 h-px" />
|
||||||
{!isShowMore && (
|
{!isShowMore && (
|
||||||
@ -392,14 +367,14 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
disabled={!isFreePlan}
|
disabled={webappCopyrightEnabled}
|
||||||
popupContent={
|
popupContent={
|
||||||
<div className='w-[260px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div>
|
<div className='w-[180px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div>
|
||||||
}
|
}
|
||||||
asChild={false}
|
asChild={false}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
disabled={isFreePlan}
|
disabled={!webappCopyrightEnabled}
|
||||||
defaultValue={inputInfo.copyrightSwitchValue}
|
defaultValue={inputInfo.copyrightSwitchValue}
|
||||||
onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||||
/>
|
/>
|
||||||
@ -450,7 +425,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
|||||||
<Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
<Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||||
<Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
|
<Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAppIconPicker && (
|
{showAppIconPicker && (
|
||||||
<div onClick={e => e.stopPropagation()}>
|
<div onClick={e => e.stopPropagation()}>
|
||||||
<AppIconPicker
|
<AppIconPicker
|
||||||
|
@ -4,7 +4,7 @@ import React from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
type IAppUnavailableProps = {
|
type IAppUnavailableProps = {
|
||||||
code?: number
|
code?: number | string
|
||||||
isUnknownReason?: boolean
|
isUnknownReason?: boolean
|
||||||
unknownReason?: string
|
unknownReason?: string
|
||||||
}
|
}
|
||||||
|
@ -16,12 +16,15 @@ import type {
|
|||||||
ConversationItem,
|
ConversationItem,
|
||||||
} from '@/models/share'
|
} from '@/models/share'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
|
||||||
export type ChatWithHistoryContextValue = {
|
export type ChatWithHistoryContextValue = {
|
||||||
appInfoError?: any
|
appInfoError?: any
|
||||||
appInfoLoading?: boolean
|
appInfoLoading?: boolean
|
||||||
appMeta?: AppMeta
|
appMeta?: AppMeta
|
||||||
appData?: AppData
|
appData?: AppData
|
||||||
|
accessMode?: AccessMode
|
||||||
|
userCanAccess?: boolean
|
||||||
appParams?: ChatConfig
|
appParams?: ChatConfig
|
||||||
appChatListDataLoading?: boolean
|
appChatListDataLoading?: boolean
|
||||||
currentConversationId: string
|
currentConversationId: string
|
||||||
@ -60,6 +63,8 @@ export type ChatWithHistoryContextValue = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
|
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
|
||||||
|
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||||
|
userCanAccess: false,
|
||||||
currentConversationId: '',
|
currentConversationId: '',
|
||||||
appPrevChatTree: [],
|
appPrevChatTree: [],
|
||||||
pinnedConversationList: [],
|
pinnedConversationList: [],
|
||||||
|
@ -43,6 +43,9 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
|
|||||||
import { InputVarType } from '@/app/components/workflow/types'
|
import { InputVarType } from '@/app/components/workflow/types'
|
||||||
import { TransferMethod } from '@/types/app'
|
import { TransferMethod } from '@/types/app'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
|
||||||
function getFormattedChatList(messages: any[]) {
|
function getFormattedChatList(messages: any[]) {
|
||||||
const newChatList: ChatItem[] = []
|
const newChatList: ChatItem[] = []
|
||||||
@ -72,7 +75,18 @@ function getFormattedChatList(messages: any[]) {
|
|||||||
|
|
||||||
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||||
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
|
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
|
||||||
|
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
|
||||||
|
appId: installedAppInfo?.app.id || appInfo?.app_id,
|
||||||
|
isInstalledApp,
|
||||||
|
enabled: systemFeatures.webapp_auth.enabled,
|
||||||
|
})
|
||||||
|
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||||
|
appId: installedAppInfo?.app.id || appInfo?.app_id,
|
||||||
|
isInstalledApp,
|
||||||
|
enabled: systemFeatures.webapp_auth.enabled,
|
||||||
|
})
|
||||||
|
|
||||||
useAppFavicon({
|
useAppFavicon({
|
||||||
enable: !installedAppInfo,
|
enable: !installedAppInfo,
|
||||||
@ -447,7 +461,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
appInfoError,
|
appInfoError,
|
||||||
appInfoLoading,
|
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)),
|
||||||
|
accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC,
|
||||||
|
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
|
||||||
isInstalledApp,
|
isInstalledApp,
|
||||||
appId,
|
appId,
|
||||||
currentConversationId,
|
currentConversationId,
|
||||||
|
@ -20,6 +20,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
|||||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
type ChatWithHistoryProps = {
|
type ChatWithHistoryProps = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -28,6 +29,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
|||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
|
userCanAccess,
|
||||||
appInfoError,
|
appInfoError,
|
||||||
appData,
|
appData,
|
||||||
appInfoLoading,
|
appInfoLoading,
|
||||||
@ -45,19 +47,17 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||||
if (site) {
|
|
||||||
if (customConfig)
|
|
||||||
document.title = `${site.title}`
|
|
||||||
else
|
|
||||||
document.title = `${site.title} - Powered by Dify`
|
|
||||||
}
|
|
||||||
}, [site, customConfig, themeBuilder])
|
}, [site, customConfig, themeBuilder])
|
||||||
|
|
||||||
|
useDocumentTitle(site?.title || 'Chat')
|
||||||
|
|
||||||
if (appInfoLoading) {
|
if (appInfoLoading) {
|
||||||
return (
|
return (
|
||||||
<Loading type='app' />
|
<Loading type='app' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!userCanAccess)
|
||||||
|
return <AppUnavailable code={403} unknownReason='no permission.' />
|
||||||
|
|
||||||
if (appInfoError) {
|
if (appInfoError) {
|
||||||
return (
|
return (
|
||||||
@ -124,6 +124,8 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
|||||||
const {
|
const {
|
||||||
appInfoError,
|
appInfoError,
|
||||||
appInfoLoading,
|
appInfoLoading,
|
||||||
|
accessMode,
|
||||||
|
userCanAccess,
|
||||||
appData,
|
appData,
|
||||||
appParams,
|
appParams,
|
||||||
appMeta,
|
appMeta,
|
||||||
@ -166,6 +168,8 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
|||||||
appInfoError,
|
appInfoError,
|
||||||
appInfoLoading,
|
appInfoLoading,
|
||||||
appData,
|
appData,
|
||||||
|
accessMode,
|
||||||
|
userCanAccess,
|
||||||
appParams,
|
appParams,
|
||||||
appMeta,
|
appMeta,
|
||||||
appChatListDataLoading,
|
appChatListDataLoading,
|
||||||
|
@ -19,6 +19,8 @@ import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/re
|
|||||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||||
import type { ConversationItem } from '@/models/share'
|
import type { ConversationItem } from '@/models/share'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isPanel?: boolean
|
isPanel?: boolean
|
||||||
@ -27,6 +29,8 @@ type Props = {
|
|||||||
const Sidebar = ({ isPanel }: Props) => {
|
const Sidebar = ({ isPanel }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
|
isInstalledApp,
|
||||||
|
accessMode,
|
||||||
appData,
|
appData,
|
||||||
handleNewConversation,
|
handleNewConversation,
|
||||||
pinnedConversationList,
|
pinnedConversationList,
|
||||||
@ -44,7 +48,7 @@ const Sidebar = ({ isPanel }: Props) => {
|
|||||||
isResponding,
|
isResponding,
|
||||||
} = useChatWithHistoryContext()
|
} = useChatWithHistoryContext()
|
||||||
const isSidebarCollapsed = sidebarCollapseState
|
const isSidebarCollapsed = sidebarCollapseState
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
||||||
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
||||||
|
|
||||||
@ -136,7 +140,7 @@ const Sidebar = ({ isPanel }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex shrink-0 items-center justify-between p-3'>
|
<div className='flex shrink-0 items-center justify-between p-3'>
|
||||||
<MenuDropdown placement='top-start' data={appData?.site} />
|
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} placement='top-start' data={appData?.site} />
|
||||||
{/* powered by */}
|
{/* powered by */}
|
||||||
<div className='shrink-0'>
|
<div className='shrink-0'>
|
||||||
{!appData?.custom_config?.remove_webapp_brand && (
|
{!appData?.custom_config?.remove_webapp_brand && (
|
||||||
@ -144,34 +148,33 @@ const Sidebar = ({ isPanel }: Props) => {
|
|||||||
'flex shrink-0 items-center gap-1.5 px-1',
|
'flex shrink-0 items-center gap-1.5 px-1',
|
||||||
)}>
|
)}>
|
||||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||||
{appData?.custom_config?.replace_webapp_logo && (
|
{systemFeatures.branding.enabled ? (
|
||||||
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
|
<img src={systemFeatures.branding.login_page_logo} alt='logo' className='block h-5 w-auto' />
|
||||||
)}
|
) : (
|
||||||
{!appData?.custom_config?.replace_webapp_logo && (
|
<DifyLogo size='small' />)
|
||||||
<DifyLogo size='small' />
|
}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!!showConfirm && (
|
||||||
|
<Confirm
|
||||||
|
title={t('share.chat.deleteConversation.title')}
|
||||||
|
content={t('share.chat.deleteConversation.content') || ''}
|
||||||
|
isShow
|
||||||
|
onCancel={handleCancelConfirm}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showRename && (
|
||||||
|
<RenameModal
|
||||||
|
isShow
|
||||||
|
onClose={handleCancelRename}
|
||||||
|
saveLoading={conversationRenaming}
|
||||||
|
name={showRename?.name || ''}
|
||||||
|
onSave={handleRename}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!showConfirm && (
|
|
||||||
<Confirm
|
|
||||||
title={t('share.chat.deleteConversation.title')}
|
|
||||||
content={t('share.chat.deleteConversation.content') || ''}
|
|
||||||
isShow
|
|
||||||
onCancel={handleCancelConfirm}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showRename && (
|
|
||||||
<RenameModal
|
|
||||||
isShow
|
|
||||||
onClose={handleCancelRename}
|
|
||||||
saveLoading={conversationRenaming}
|
|
||||||
name={showRename?.name || ''}
|
|
||||||
onSave={handleRename}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -15,8 +15,11 @@ import type {
|
|||||||
ConversationItem,
|
ConversationItem,
|
||||||
} from '@/models/share'
|
} from '@/models/share'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
|
||||||
export type EmbeddedChatbotContextValue = {
|
export type EmbeddedChatbotContextValue = {
|
||||||
|
accessMode?: AccessMode
|
||||||
|
userCanAccess?: boolean
|
||||||
appInfoError?: any
|
appInfoError?: any
|
||||||
appInfoLoading?: boolean
|
appInfoLoading?: boolean
|
||||||
appMeta?: AppMeta
|
appMeta?: AppMeta
|
||||||
@ -53,6 +56,8 @@ export type EmbeddedChatbotContextValue = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
|
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
|
||||||
|
userCanAccess: false,
|
||||||
|
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||||
currentConversationId: '',
|
currentConversationId: '',
|
||||||
appPrevChatList: [],
|
appPrevChatList: [],
|
||||||
pinnedConversationList: [],
|
pinnedConversationList: [],
|
||||||
|
@ -36,6 +36,9 @@ import { InputVarType } from '@/app/components/workflow/types'
|
|||||||
import { TransferMethod } from '@/types/app'
|
import { TransferMethod } from '@/types/app'
|
||||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
|
||||||
function getFormattedChatList(messages: any[]) {
|
function getFormattedChatList(messages: any[]) {
|
||||||
const newChatList: ChatItem[] = []
|
const newChatList: ChatItem[] = []
|
||||||
@ -65,7 +68,18 @@ function getFormattedChatList(messages: any[]) {
|
|||||||
|
|
||||||
export const useEmbeddedChatbot = () => {
|
export const useEmbeddedChatbot = () => {
|
||||||
const isInstalledApp = false
|
const isInstalledApp = false
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
|
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
|
||||||
|
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
|
||||||
|
appId: appInfo?.app_id,
|
||||||
|
isInstalledApp,
|
||||||
|
enabled: systemFeatures.webapp_auth.enabled,
|
||||||
|
})
|
||||||
|
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||||
|
appId: appInfo?.app_id,
|
||||||
|
isInstalledApp,
|
||||||
|
enabled: systemFeatures.webapp_auth.enabled,
|
||||||
|
})
|
||||||
|
|
||||||
const appData = useMemo(() => {
|
const appData = useMemo(() => {
|
||||||
return appInfo
|
return appInfo
|
||||||
@ -364,7 +378,9 @@ export const useEmbeddedChatbot = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
appInfoError,
|
appInfoError,
|
||||||
appInfoLoading,
|
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)),
|
||||||
|
accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC,
|
||||||
|
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
|
||||||
isInstalledApp,
|
isInstalledApp,
|
||||||
allowResetChat,
|
allowResetChat,
|
||||||
appId,
|
appId,
|
||||||
|
@ -21,9 +21,11 @@ import Header from '@/app/components/base/chat/embedded-chatbot/header'
|
|||||||
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
|
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
|
||||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
const Chatbot = () => {
|
const Chatbot = () => {
|
||||||
const {
|
const {
|
||||||
|
userCanAccess,
|
||||||
isMobile,
|
isMobile,
|
||||||
allowResetChat,
|
allowResetChat,
|
||||||
appInfoError,
|
appInfoError,
|
||||||
@ -43,14 +45,10 @@ const Chatbot = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||||
if (site) {
|
|
||||||
if (customConfig)
|
|
||||||
document.title = `${site.title}`
|
|
||||||
else
|
|
||||||
document.title = `${site.title} - Powered by Dify`
|
|
||||||
}
|
|
||||||
}, [site, customConfig, themeBuilder])
|
}, [site, customConfig, themeBuilder])
|
||||||
|
|
||||||
|
useDocumentTitle(site?.title || 'Chat')
|
||||||
|
|
||||||
if (appInfoLoading) {
|
if (appInfoLoading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -66,6 +64,9 @@ const Chatbot = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!userCanAccess)
|
||||||
|
return <AppUnavailable code={403} unknownReason='no permission.' />
|
||||||
|
|
||||||
if (appInfoError) {
|
if (appInfoError) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -137,6 +138,8 @@ const EmbeddedChatbotWrapper = () => {
|
|||||||
appInfoError,
|
appInfoError,
|
||||||
appInfoLoading,
|
appInfoLoading,
|
||||||
appData,
|
appData,
|
||||||
|
accessMode,
|
||||||
|
userCanAccess,
|
||||||
appParams,
|
appParams,
|
||||||
appMeta,
|
appMeta,
|
||||||
appChatListDataLoading,
|
appChatListDataLoading,
|
||||||
@ -168,6 +171,8 @@ const EmbeddedChatbotWrapper = () => {
|
|||||||
} = useEmbeddedChatbot()
|
} = useEmbeddedChatbot()
|
||||||
|
|
||||||
return <EmbeddedChatbotContext.Provider value={{
|
return <EmbeddedChatbotContext.Provider value={{
|
||||||
|
userCanAccess,
|
||||||
|
accessMode,
|
||||||
appInfoError,
|
appInfoError,
|
||||||
appInfoLoading,
|
appInfoLoading,
|
||||||
appData,
|
appData,
|
||||||
|
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
|||||||
import classNames from '@/utils/classnames'
|
import classNames from '@/utils/classnames'
|
||||||
import useTheme from '@/hooks/use-theme'
|
import useTheme from '@/hooks/use-theme'
|
||||||
import { basePath } from '@/utils/var'
|
import { basePath } from '@/utils/var'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
export type LogoStyle = 'default' | 'monochromeWhite'
|
export type LogoStyle = 'default' | 'monochromeWhite'
|
||||||
|
|
||||||
export const logoPathMap: Record<LogoStyle, string> = {
|
export const logoPathMap: Record<LogoStyle, string> = {
|
||||||
@ -32,10 +32,15 @@ const DifyLogo: FC<DifyLogoProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style
|
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style
|
||||||
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
|
|
||||||
|
let src = `${basePath}${logoPathMap[themedStyle]}`
|
||||||
|
if (systemFeatures.branding.enabled)
|
||||||
|
src = systemFeatures.branding.workspace_logo
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={`${basePath}${logoPathMap[themedStyle]}`}
|
src={src}
|
||||||
className={classNames('block object-contain', logoSizeMap[size], className)}
|
className={classNames('block object-contain', logoSizeMap[size], className)}
|
||||||
alt='Dify logo'
|
alt='Dify logo'
|
||||||
/>
|
/>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { SVG } from '@svgdotjs/svg.js'
|
import { SVG } from '@svgdotjs/svg.js'
|
||||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||||
|
|
||||||
export const SVGRenderer = ({ content }: { content: string }) => {
|
export const SVGRenderer = ({ content }: { content: string }) => {
|
||||||
const svgRef = useRef<HTMLDivElement>(null)
|
const svgRef = useRef<HTMLDivElement>(null)
|
||||||
|
@ -92,6 +92,7 @@ const Tooltip: FC<TooltipProps> = ({
|
|||||||
}}
|
}}
|
||||||
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
|
onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)}
|
||||||
asChild={asChild}
|
asChild={asChild}
|
||||||
|
className={!asChild ? triggerClassName : ''}
|
||||||
>
|
>
|
||||||
{children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>}
|
{children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>}
|
||||||
</PortalToFollowElemTrigger>
|
</PortalToFollowElemTrigger>
|
||||||
|
@ -94,6 +94,11 @@ export type CurrentPlanInfoBackend = {
|
|||||||
education: {
|
education: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
activated: boolean
|
activated: boolean
|
||||||
|
},
|
||||||
|
webapp_copyright_enabled: boolean
|
||||||
|
workspace_members: {
|
||||||
|
size: number
|
||||||
|
limit: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
|||||||
import classNames from '@/utils/classnames'
|
import classNames from '@/utils/classnames'
|
||||||
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
|
import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others'
|
||||||
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
|
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
|
||||||
|
|
||||||
type IStepOneProps = {
|
type IStepOneProps = {
|
||||||
datasetId?: string
|
datasetId?: string
|
||||||
dataSourceType?: DataSourceType
|
dataSourceType?: DataSourceType
|
||||||
@ -45,7 +46,8 @@ type IStepOneProps = {
|
|||||||
type NotionConnectorProps = {
|
type NotionConnectorProps = {
|
||||||
onSetting: () => void
|
onSetting: () => void
|
||||||
}
|
}
|
||||||
export const NotionConnector = ({ onSetting }: NotionConnectorProps) => {
|
export const NotionConnector = (props: NotionConnectorProps) => {
|
||||||
|
const { onSetting } = props
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -162,7 +164,7 @@ const StepOne = ({
|
|||||||
>
|
>
|
||||||
<span className={cn(s.datasetIcon)} />
|
<span className={cn(s.datasetIcon)} />
|
||||||
<span
|
<span
|
||||||
title={t('datasetCreation.stepOne.dataSourceType.file')}
|
title={t('datasetCreation.stepOne.dataSourceType.file')!}
|
||||||
className='truncate'
|
className='truncate'
|
||||||
>
|
>
|
||||||
{t('datasetCreation.stepOne.dataSourceType.file')}
|
{t('datasetCreation.stepOne.dataSourceType.file')}
|
||||||
@ -185,7 +187,7 @@ const StepOne = ({
|
|||||||
>
|
>
|
||||||
<span className={cn(s.datasetIcon, s.notion)} />
|
<span className={cn(s.datasetIcon, s.notion)} />
|
||||||
<span
|
<span
|
||||||
title={t('datasetCreation.stepOne.dataSourceType.notion')}
|
title={t('datasetCreation.stepOne.dataSourceType.notion')!}
|
||||||
className='truncate'
|
className='truncate'
|
||||||
>
|
>
|
||||||
{t('datasetCreation.stepOne.dataSourceType.notion')}
|
{t('datasetCreation.stepOne.dataSourceType.notion')}
|
||||||
@ -193,21 +195,21 @@ const StepOne = ({
|
|||||||
</div>
|
</div>
|
||||||
{(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && (
|
{(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
s.dataSourceItem,
|
s.dataSourceItem,
|
||||||
'system-sm-medium',
|
'system-sm-medium',
|
||||||
dataSourceType === DataSourceType.WEB && s.active,
|
dataSourceType === DataSourceType.WEB && s.active,
|
||||||
dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled,
|
dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled,
|
||||||
)}
|
)}
|
||||||
onClick={() => changeType(DataSourceType.WEB)}
|
onClick={() => changeType(DataSourceType.WEB)}
|
||||||
>
|
>
|
||||||
<span className={cn(s.datasetIcon, s.web)} />
|
<span className={cn(s.datasetIcon, s.web)} />
|
||||||
<span
|
<span
|
||||||
title={t('datasetCreation.stepOne.dataSourceType.web')}
|
title={t('datasetCreation.stepOne.dataSourceType.web')!}
|
||||||
className='truncate'
|
className='truncate'
|
||||||
>
|
>
|
||||||
{t('datasetCreation.stepOne.dataSourceType.web')}
|
{t('datasetCreation.stepOne.dataSourceType.web')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -15,7 +15,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
|||||||
### 鉴权
|
### 鉴权
|
||||||
|
|
||||||
|
|
||||||
Dify Service API 使用 `API-Key` 进行鉴权。
|
Service API 使用 `API-Key` 进行鉴权。
|
||||||
<i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i>
|
<i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i>
|
||||||
所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示:
|
所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示:
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Dify Service API 使用 `API-Key` 进行鉴权。
|
Service API 使用 `API-Key` 进行鉴权。
|
||||||
<i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i>
|
<i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i>
|
||||||
所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示:
|
所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示:
|
||||||
|
|
||||||
|
@ -2,12 +2,13 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import ExploreContext from '@/context/explore-context'
|
import ExploreContext from '@/context/explore-context'
|
||||||
import Sidebar from '@/app/components/explore/sidebar'
|
import Sidebar from '@/app/components/explore/sidebar'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import { fetchMembers } from '@/service/common'
|
import { fetchMembers } from '@/service/common'
|
||||||
import type { InstalledApp } from '@/models/explore'
|
import type { InstalledApp } from '@/models/explore'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
export type IExploreProps = {
|
export type IExploreProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -16,15 +17,16 @@ export type IExploreProps = {
|
|||||||
const Explore: FC<IExploreProps> = ({
|
const Explore: FC<IExploreProps> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
|
const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
|
||||||
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||||
const [hasEditPermission, setHasEditPermission] = useState(false)
|
const [hasEditPermission, setHasEditPermission] = useState(false)
|
||||||
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
|
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useDocumentTitle(t('common.menus.explore'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t('explore.title')} - Dify`;
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
|
const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
|
||||||
if (!accounts)
|
if (!accounts)
|
||||||
|
@ -26,15 +26,15 @@ const InstalledApp: FC<IInstalledAppProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-full py-2 pl-0 pr-2 sm:p-2'>
|
<div className='h-full bg-background-default py-2 pl-0 pr-2 sm:p-2'>
|
||||||
{installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && (
|
{installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && (
|
||||||
<ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' />
|
<ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' />
|
||||||
)}
|
)}
|
||||||
{installedApp.app.mode === 'completion' && (
|
{installedApp.app.mode === 'completion' && (
|
||||||
<TextGenerationApp isInstalledApp installedAppInfo={installedApp}/>
|
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
|
||||||
)}
|
)}
|
||||||
{installedApp.app.mode === 'workflow' && (
|
{installedApp.app.mode === 'workflow' && (
|
||||||
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp}/>
|
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Fragment, useState } from 'react'
|
import { Fragment, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useContextSelector } from 'use-context-selector'
|
|
||||||
import {
|
import {
|
||||||
RiAccountCircleLine,
|
RiAccountCircleLine,
|
||||||
RiArrowRightUpLine,
|
RiArrowRightUpLine,
|
||||||
@ -28,12 +27,12 @@ import { useGetDocLanguage } from '@/context/i18n'
|
|||||||
import Avatar from '@/app/components/base/avatar'
|
import Avatar from '@/app/components/base/avatar'
|
||||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||||
import { logout } from '@/service/common'
|
import { logout } from '@/service/common'
|
||||||
import AppContext, { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import { useModalContext } from '@/context/modal-context'
|
import { useModalContext } from '@/context/modal-context'
|
||||||
import { LicenseStatus } from '@/types/feature'
|
|
||||||
import { IS_CLOUD_EDITION } from '@/config'
|
import { IS_CLOUD_EDITION } from '@/config'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
export default function AppSelector() {
|
export default function AppSelector() {
|
||||||
const itemClassName = `
|
const itemClassName = `
|
||||||
@ -42,7 +41,7 @@ export default function AppSelector() {
|
|||||||
`
|
`
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [aboutVisible, setAboutVisible] = useState(false)
|
const [aboutVisible, setAboutVisible] = useState(false)
|
||||||
const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures)
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
|
const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
|
||||||
@ -127,73 +126,75 @@ export default function AppSelector() {
|
|||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
<div className='p-1'>
|
{!systemFeatures.branding.enabled && <>
|
||||||
<MenuItem>
|
<div className='p-1'>
|
||||||
<Link
|
<MenuItem>
|
||||||
className={cn(itemClassName, 'group justify-between',
|
<Link
|
||||||
'data-[active]:bg-state-base-hover',
|
className={cn(itemClassName, 'group justify-between',
|
||||||
)}
|
|
||||||
href={`https://docs.dify.ai/${docLanguage}/introduction`}
|
|
||||||
target='_blank' rel='noopener noreferrer'>
|
|
||||||
<RiBookOpenLine className='size-4 shrink-0 text-text-tertiary' />
|
|
||||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.helpCenter')}</div>
|
|
||||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
|
||||||
</Link>
|
|
||||||
</MenuItem>
|
|
||||||
<Support />
|
|
||||||
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
|
||||||
</div>
|
|
||||||
<div className='p-1'>
|
|
||||||
<MenuItem>
|
|
||||||
<Link
|
|
||||||
className={cn(itemClassName, 'group justify-between',
|
|
||||||
'data-[active]:bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
href='https://roadmap.dify.ai'
|
|
||||||
target='_blank' rel='noopener noreferrer'>
|
|
||||||
<RiMap2Line className='size-4 shrink-0 text-text-tertiary' />
|
|
||||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.roadmap')}</div>
|
|
||||||
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
|
||||||
</Link>
|
|
||||||
</MenuItem>
|
|
||||||
{systemFeatures.license.status === LicenseStatus.NONE && <MenuItem>
|
|
||||||
<Link
|
|
||||||
className={cn(itemClassName, 'group justify-between',
|
|
||||||
'data-[active]:bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
href='https://github.com/langgenius/dify'
|
|
||||||
target='_blank' rel='noopener noreferrer'>
|
|
||||||
<RiGithubLine className='size-4 shrink-0 text-text-tertiary' />
|
|
||||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.github')}</div>
|
|
||||||
<div className='flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]'>
|
|
||||||
<RiStarLine className='size-3 shrink-0 text-text-tertiary' />
|
|
||||||
<GithubStar className='system-2xs-medium-uppercase text-text-tertiary' />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</MenuItem>}
|
|
||||||
{
|
|
||||||
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
|
||||||
<MenuItem>
|
|
||||||
<div className={cn(itemClassName, 'justify-between',
|
|
||||||
'data-[active]:bg-state-base-hover',
|
'data-[active]:bg-state-base-hover',
|
||||||
)} onClick={() => setAboutVisible(true)}>
|
)}
|
||||||
<RiInformation2Line className='size-4 shrink-0 text-text-tertiary' />
|
href={`https://docs.dify.ai/${docLanguage}/introduction`}
|
||||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.about')}</div>
|
target='_blank' rel='noopener noreferrer'>
|
||||||
<div className='flex shrink-0 items-center'>
|
<RiBookOpenLine className='size-4 shrink-0 text-text-tertiary' />
|
||||||
<div className='system-xs-regular mr-2 text-text-tertiary'>{langeniusVersionInfo.current_version}</div>
|
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.helpCenter')}</div>
|
||||||
<Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||||
</div>
|
</Link>
|
||||||
|
</MenuItem>
|
||||||
|
<Support />
|
||||||
|
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
||||||
|
</div>
|
||||||
|
<div className='p-1'>
|
||||||
|
<MenuItem>
|
||||||
|
<Link
|
||||||
|
className={cn(itemClassName, 'group justify-between',
|
||||||
|
'data-[active]:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
href='https://roadmap.dify.ai'
|
||||||
|
target='_blank' rel='noopener noreferrer'>
|
||||||
|
<RiMap2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||||
|
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.roadmap')}</div>
|
||||||
|
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
|
||||||
|
</Link>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
<Link
|
||||||
|
className={cn(itemClassName, 'group justify-between',
|
||||||
|
'data-[active]:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
href='https://github.com/langgenius/dify'
|
||||||
|
target='_blank' rel='noopener noreferrer'>
|
||||||
|
<RiGithubLine className='size-4 shrink-0 text-text-tertiary' />
|
||||||
|
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.github')}</div>
|
||||||
|
<div className='flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]'>
|
||||||
|
<RiStarLine className='size-3 shrink-0 text-text-tertiary' />
|
||||||
|
<GithubStar className='system-2xs-medium-uppercase text-text-tertiary' />
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</Link>
|
||||||
)
|
</MenuItem>
|
||||||
}
|
{
|
||||||
</div>
|
document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
|
||||||
|
<MenuItem>
|
||||||
|
<div className={cn(itemClassName, 'justify-between',
|
||||||
|
'data-[active]:bg-state-base-hover',
|
||||||
|
)} onClick={() => setAboutVisible(true)}>
|
||||||
|
<RiInformation2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||||
|
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.about')}</div>
|
||||||
|
<div className='flex shrink-0 items-center'>
|
||||||
|
<div className='system-xs-regular mr-2 text-text-tertiary'>{langeniusVersionInfo.current_version}</div>
|
||||||
|
<Indicator color={langeniusVersionInfo.current_version === langeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
<MenuItem disabled>
|
<MenuItem disabled>
|
||||||
<div className='p-1'>
|
<div className='p-1'>
|
||||||
<div className={cn(itemClassName, 'hover:bg-transparent')}>
|
<div className={cn(itemClassName, 'hover:bg-transparent')}>
|
||||||
<RiTShirt2Line className='size-4 shrink-0 text-text-tertiary' />
|
<RiTShirt2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.theme.theme')}</div>
|
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.theme.theme')}</div>
|
||||||
<ThemeSwitcher/>
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
@ -25,6 +25,7 @@ import { LanguagesSupported } from '@/i18n/language'
|
|||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
import { RiPencilLine } from '@remixicon/react'
|
import { RiPencilLine } from '@remixicon/react'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
const MembersPage = () => {
|
const MembersPage = () => {
|
||||||
@ -38,7 +39,7 @@ const MembersPage = () => {
|
|||||||
}
|
}
|
||||||
const { locale } = useContext(I18n)
|
const { locale } = useContext(I18n)
|
||||||
|
|
||||||
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager, systemFeatures } = useAppContext()
|
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
|
||||||
const { data, mutate } = useSWR(
|
const { data, mutate } = useSWR(
|
||||||
{
|
{
|
||||||
url: '/workspaces/current/members',
|
url: '/workspaces/current/members',
|
||||||
@ -46,6 +47,7 @@ const MembersPage = () => {
|
|||||||
},
|
},
|
||||||
fetchMembers,
|
fetchMembers,
|
||||||
)
|
)
|
||||||
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
const [inviteModalVisible, setInviteModalVisible] = useState(false)
|
const [inviteModalVisible, setInviteModalVisible] = useState(false)
|
||||||
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
|
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
|
||||||
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
|
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useContext } from 'use-context-selector'
|
import { useContext } from 'use-context-selector'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -18,6 +18,7 @@ import I18n from '@/context/i18n'
|
|||||||
import 'react-multi-email/dist/style.css'
|
import 'react-multi-email/dist/style.css'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
|
||||||
|
import { useProviderContextSelector } from '@/context/provider-context'
|
||||||
type IInviteModalProps = {
|
type IInviteModalProps = {
|
||||||
isEmailSetup: boolean
|
isEmailSetup: boolean
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
@ -30,13 +31,27 @@ const InviteModal = ({
|
|||||||
onSend,
|
onSend,
|
||||||
}: IInviteModalProps) => {
|
}: IInviteModalProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
|
||||||
|
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
|
||||||
const [emails, setEmails] = useState<string[]>([])
|
const [emails, setEmails] = useState<string[]>([])
|
||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
|
const [isLimited, setIsLimited] = useState(false)
|
||||||
|
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
|
||||||
|
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
|
||||||
|
useEffect(() => {
|
||||||
|
const limited = licenseLimit.workspace_members.limit > 0
|
||||||
|
const used = emails.length + licenseLimit.workspace_members.size
|
||||||
|
setIsLimited(limited)
|
||||||
|
setUsedSize(used)
|
||||||
|
setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit))
|
||||||
|
}, [licenseLimit, emails])
|
||||||
|
|
||||||
const { locale } = useContext(I18n)
|
const { locale } = useContext(I18n)
|
||||||
const [role, setRole] = useState<string>('normal')
|
const [role, setRole] = useState<string>('normal')
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
|
if (isLimitExceeded)
|
||||||
|
return
|
||||||
if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
|
if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
|
||||||
try {
|
try {
|
||||||
const { result, invitation_results } = await inviteMember({
|
const { result, invitation_results } = await inviteMember({
|
||||||
@ -45,6 +60,7 @@ const InviteModal = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result === 'success') {
|
if (result === 'success') {
|
||||||
|
refreshLicenseLimit()
|
||||||
onCancel()
|
onCancel()
|
||||||
onSend(invitation_results)
|
onSend(invitation_results)
|
||||||
}
|
}
|
||||||
@ -54,7 +70,7 @@ const InviteModal = ({
|
|||||||
else {
|
else {
|
||||||
notify({ type: 'error', message: t('common.members.emailInvalid') })
|
notify({ type: 'error', message: t('common.members.emailInvalid') })
|
||||||
}
|
}
|
||||||
}, [role, emails, notify, onCancel, onSend, t])
|
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(s.wrap)}>
|
<div className={cn(s.wrap)}>
|
||||||
@ -82,7 +98,7 @@ const InviteModal = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-2 text-sm font-medium text-text-primary'>{t('common.members.email')}</div>
|
<div className='mb-2 text-sm font-medium text-text-primary'>{t('common.members.email')}</div>
|
||||||
<div className='mb-8 flex h-36 items-stretch'>
|
<div className='mb-8 flex h-36 flex-col items-stretch'>
|
||||||
<ReactMultiEmail
|
<ReactMultiEmail
|
||||||
className={cn('w-full border-components-input-border-active !bg-components-input-bg-normal px-3 pt-2 outline-none',
|
className={cn('w-full border-components-input-border-active !bg-components-input-bg-normal px-3 pt-2 outline-none',
|
||||||
'appearance-none overflow-y-auto rounded-lg text-sm !text-text-primary',
|
'appearance-none overflow-y-auto rounded-lg text-sm !text-text-primary',
|
||||||
@ -101,6 +117,14 @@ const InviteModal = ({
|
|||||||
}
|
}
|
||||||
placeholder={t('common.members.emailPlaceholder') || ''}
|
placeholder={t('common.members.emailPlaceholder') || ''}
|
||||||
/>
|
/>
|
||||||
|
<div className={
|
||||||
|
cn('system-xs-regular flex items-center justify-end text-text-tertiary',
|
||||||
|
(isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '')}
|
||||||
|
>
|
||||||
|
<span>{usedSize}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{isLimited ? licenseLimit.workspace_members.limit : t('common.license.unlimited')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='mb-6'>
|
<div className='mb-6'>
|
||||||
<RoleSelector value={role} onChange={setRole} />
|
<RoleSelector value={role} onChange={setRole} />
|
||||||
@ -109,7 +133,7 @@ const InviteModal = ({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!emails.length}
|
disabled={!emails.length || isLimitExceeded}
|
||||||
variant='primary'
|
variant='primary'
|
||||||
>
|
>
|
||||||
{t('common.members.sendInvite')}
|
{t('common.members.sendInvite')}
|
||||||
|
@ -23,7 +23,7 @@ import {
|
|||||||
import InstallFromMarketplace from './install-from-marketplace'
|
import InstallFromMarketplace from './install-from-marketplace'
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
searchText: string
|
searchText: string
|
||||||
@ -40,7 +40,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
|||||||
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
|
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||||
const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
|
const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
|
||||||
const { modelProviders: providers } = useProviderContext()
|
const { modelProviders: providers } = useProviderContext()
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
||||||
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
||||||
const configuredProviders: ModelProvider[] = []
|
const configuredProviders: ModelProvider[] = []
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import AppContext from '@/context/app-context'
|
|
||||||
import { LicenseStatus } from '@/types/feature'
|
import { LicenseStatus } from '@/types/feature'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useContextSelector } from 'use-context-selector'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import PremiumBadge from '../../base/premium-badge'
|
import PremiumBadge from '../../base/premium-badge'
|
||||||
import { RiHourglass2Fill } from '@remixicon/react'
|
import { RiHourglass2Fill } from '@remixicon/react'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const LicenseNav = () => {
|
const LicenseNav = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const systemFeatures = useContextSelector(AppContext, s => s.systemFeatures)
|
const { systemFeatures } = useGlobalPublicStore()
|
||||||
|
|
||||||
if (systemFeatures.license?.status === LicenseStatus.EXPIRING) {
|
if (systemFeatures.license?.status === LicenseStatus.EXPIRING) {
|
||||||
const expiredAt = systemFeatures.license?.expired_at
|
const expiredAt = systemFeatures.license?.expired_at
|
||||||
|
@ -10,11 +10,11 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContextSelector,
|
useContextSelector,
|
||||||
} from 'use-context-selector'
|
} from 'use-context-selector'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
|
||||||
import type { FilterState } from './filter-management'
|
import type { FilterState } from './filter-management'
|
||||||
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
|
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
export type PluginPageContextValue = {
|
export type PluginPageContextValue = {
|
||||||
containerRef: React.RefObject<HTMLDivElement>
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
@ -61,7 +61,7 @@ export const PluginPageContextProvider = ({
|
|||||||
})
|
})
|
||||||
const [currentPluginID, setCurrentPluginID] = useState<string | undefined>()
|
const [currentPluginID, setCurrentPluginID] = useState<string | undefined>()
|
||||||
|
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const tabs = usePluginPageTabs()
|
const tabs = usePluginPageTabs()
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
|
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
|
||||||
|
@ -6,19 +6,19 @@ import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-f
|
|||||||
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
|
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
|
||||||
import { usePluginPageContext } from '../context'
|
import { usePluginPageContext } from '../context'
|
||||||
import { Group } from '@/app/components/base/icons/src/vender/other'
|
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
|
||||||
import Line from '../../marketplace/empty/line'
|
import Line from '../../marketplace/empty/line'
|
||||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const Empty = () => {
|
const Empty = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [selectedAction, setSelectedAction] = useState<string | null>(null)
|
const [selectedAction, setSelectedAction] = useState<string | null>(null)
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
|
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
@ -25,7 +25,6 @@ import TabSlider from '@/app/components/base/tab-slider'
|
|||||||
import Tooltip from '@/app/components/base/tooltip'
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import PermissionSetModal from '@/app/components/plugins/permission-setting-modal/modal'
|
import PermissionSetModal from '@/app/components/plugins/permission-setting-modal/modal'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
|
||||||
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
|
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
|
||||||
import {
|
import {
|
||||||
useRouter,
|
useRouter,
|
||||||
@ -42,6 +41,8 @@ import I18n from '@/context/i18n'
|
|||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch'
|
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch'
|
||||||
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
|
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
const PACKAGE_IDS_KEY = 'package-ids'
|
const PACKAGE_IDS_KEY = 'package-ids'
|
||||||
const BUNDLE_INFO_KEY = 'bundle-info'
|
const BUNDLE_INFO_KEY = 'bundle-info'
|
||||||
@ -58,8 +59,7 @@ const PluginPage = ({
|
|||||||
const { locale } = useContext(I18n)
|
const { locale } = useContext(I18n)
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { replace } = useRouter()
|
const { replace } = useRouter()
|
||||||
|
useDocumentTitle(t('plugin.metadata.title'))
|
||||||
document.title = `${t('plugin.metadata.title')} - Dify`
|
|
||||||
|
|
||||||
// just support install one package now
|
// just support install one package now
|
||||||
const packageId = useMemo(() => {
|
const packageId = useMemo(() => {
|
||||||
@ -136,7 +136,7 @@ const PluginPage = ({
|
|||||||
const options = usePluginPageContext(v => v.options)
|
const options = usePluginPageContext(v => v.options)
|
||||||
const activeTab = usePluginPageContext(v => v.activeTab)
|
const activeTab = usePluginPageContext(v => v.activeTab)
|
||||||
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
|
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
|
||||||
const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab])
|
const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab])
|
||||||
const isExploringMarketplace = useMemo(() => {
|
const isExploringMarketplace = useMemo(() => {
|
||||||
|
@ -14,10 +14,10 @@ import {
|
|||||||
PortalToFollowElemContent,
|
PortalToFollowElemContent,
|
||||||
PortalToFollowElemTrigger,
|
PortalToFollowElemTrigger,
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||||
import { noop } from 'lodash-es'
|
import { noop } from 'lodash-es'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSwitchToMarketplaceTab: () => void
|
onSwitchToMarketplaceTab: () => void
|
||||||
@ -30,7 +30,7 @@ const InstallPluginDropdown = ({
|
|||||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
const [selectedAction, setSelectedAction] = useState<string | null>(null)
|
const [selectedAction, setSelectedAction] = useState<string | null>(null)
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
|
@ -3,8 +3,8 @@ import { useAppContext } from '@/context/app-context'
|
|||||||
import Toast from '../../base/toast'
|
import Toast from '../../base/toast'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useInvalidatePermissions, useMutationPermissions, usePermissions } from '@/service/use-plugins'
|
import { useInvalidatePermissions, useMutationPermissions, usePermissions } from '@/service/use-plugins'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean) => {
|
const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean) => {
|
||||||
if (!permission)
|
if (!permission)
|
||||||
@ -46,7 +46,7 @@ const usePermission = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useCanInstallPluginFromMarketplace = () => {
|
export const useCanInstallPluginFromMarketplace = () => {
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const { canManagement } = usePermission()
|
const { canManagement } = usePermission()
|
||||||
|
|
||||||
const canInstallPluginFromMarketplace = useMemo(() => {
|
const canInstallPluginFromMarketplace = useMemo(() => {
|
||||||
|
@ -13,6 +13,7 @@ import { checkOrSetAccessToken } from '../utils'
|
|||||||
import MenuDropdown from './menu-dropdown'
|
import MenuDropdown from './menu-dropdown'
|
||||||
import RunBatch from './run-batch'
|
import RunBatch from './run-batch'
|
||||||
import ResDownload from './run-batch/res-download'
|
import ResDownload from './run-batch/res-download'
|
||||||
|
import AppUnavailable from '../../base/app-unavailable'
|
||||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||||
import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share'
|
import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share'
|
||||||
@ -38,6 +39,10 @@ import { Resolution, TransferMethod } from '@/types/app'
|
|||||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
|
||||||
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
|
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
|
||||||
enum TaskStatus {
|
enum TaskStatus {
|
||||||
@ -98,14 +103,25 @@ const TextGeneration: FC<IMainProps> = ({
|
|||||||
doSetInputs(newInputs)
|
doSetInputs(newInputs)
|
||||||
inputsRef.current = newInputs
|
inputsRef.current = newInputs
|
||||||
}, [])
|
}, [])
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const [appId, setAppId] = useState<string>('')
|
const [appId, setAppId] = useState<string>('')
|
||||||
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
|
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
|
||||||
const [canReplaceLogo, setCanReplaceLogo] = useState<boolean>(false)
|
|
||||||
const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null)
|
const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null)
|
||||||
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
|
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
|
||||||
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
|
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
|
||||||
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
|
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
|
||||||
|
|
||||||
|
const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({
|
||||||
|
appId,
|
||||||
|
isInstalledApp,
|
||||||
|
enabled: systemFeatures.webapp_auth.enabled,
|
||||||
|
})
|
||||||
|
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||||
|
appId,
|
||||||
|
isInstalledApp,
|
||||||
|
enabled: systemFeatures.webapp_auth.enabled,
|
||||||
|
})
|
||||||
|
|
||||||
// save message
|
// save message
|
||||||
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
|
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
|
||||||
const fetchSavedMessage = async () => {
|
const fetchSavedMessage = async () => {
|
||||||
@ -395,10 +411,9 @@ const TextGeneration: FC<IMainProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const [appData, appParams]: any = await fetchInitData()
|
const [appData, appParams]: any = await fetchInitData()
|
||||||
const { app_id: appId, site: siteInfo, can_replace_logo, custom_config } = appData
|
const { app_id: appId, site: siteInfo, custom_config } = appData
|
||||||
setAppId(appId)
|
setAppId(appId)
|
||||||
setSiteInfo(siteInfo as SiteInfo)
|
setSiteInfo(siteInfo as SiteInfo)
|
||||||
setCanReplaceLogo(can_replace_logo)
|
|
||||||
setCustomConfig(custom_config)
|
setCustomConfig(custom_config)
|
||||||
changeLanguage(siteInfo.default_language)
|
changeLanguage(siteInfo.default_language)
|
||||||
|
|
||||||
@ -422,14 +437,7 @@ const TextGeneration: FC<IMainProps> = ({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
|
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
|
||||||
useEffect(() => {
|
useDocumentTitle(siteInfo?.title || t('share.generation.title'))
|
||||||
if (siteInfo?.title) {
|
|
||||||
if (canReplaceLogo)
|
|
||||||
document.title = `${siteInfo.title}`
|
|
||||||
else
|
|
||||||
document.title = `${siteInfo.title} - Powered by Dify`
|
|
||||||
}
|
|
||||||
}, [siteInfo?.title, canReplaceLogo])
|
|
||||||
|
|
||||||
useAppFavicon({
|
useAppFavicon({
|
||||||
enable: !isInstalledApp,
|
enable: !isInstalledApp,
|
||||||
@ -528,12 +536,14 @@ const TextGeneration: FC<IMainProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!appId || !siteInfo || !promptConfig) {
|
if (!appId || !siteInfo || !promptConfig || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission))) {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-screen items-center'>
|
<div className='flex h-screen items-center'>
|
||||||
<Loading type='app' />
|
<Loading type='app' />
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
if (systemFeatures.webapp_auth.enabled && !userCanAccessResult?.result)
|
||||||
|
return <AppUnavailable code={403} unknownReason='no permission.' />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@ -559,7 +569,7 @@ const TextGeneration: FC<IMainProps> = ({
|
|||||||
imageUrl={siteInfo.icon_url}
|
imageUrl={siteInfo.icon_url}
|
||||||
/>
|
/>
|
||||||
<div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div>
|
<div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div>
|
||||||
<MenuDropdown data={siteInfo} />
|
<MenuDropdown hideLogout={isInstalledApp || appAccessMode?.accessMode === AccessMode.PUBLIC} data={siteInfo} />
|
||||||
</div>
|
</div>
|
||||||
{siteInfo.description && (
|
{siteInfo.description && (
|
||||||
<div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div>
|
<div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div>
|
||||||
@ -631,10 +641,9 @@ const TextGeneration: FC<IMainProps> = ({
|
|||||||
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
|
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
|
||||||
)}>
|
)}>
|
||||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||||
{customConfig?.replace_webapp_logo && (
|
{systemFeatures.branding.enabled ? (
|
||||||
<img src={customConfig?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
|
<img src={systemFeatures.branding.login_page_logo} alt='logo' className='block h-5 w-auto' />
|
||||||
)}
|
) : (
|
||||||
{!customConfig?.replace_webapp_logo && (
|
|
||||||
<DifyLogo size='small' />
|
<DifyLogo size='small' />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import cn from 'classnames'
|
||||||
import Modal from '@/app/components/base/modal'
|
import Modal from '@/app/components/base/modal'
|
||||||
import AppIcon from '@/app/components/base/app-icon'
|
import AppIcon from '@/app/components/base/app-icon'
|
||||||
import type { SiteInfo } from '@/models/share'
|
import type { SiteInfo } from '@/models/share'
|
||||||
import { appDefaultIconBackground } from '@/config'
|
import { appDefaultIconBackground } from '@/config'
|
||||||
import cn from 'classnames'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: SiteInfo
|
data?: SiteInfo
|
||||||
|
@ -6,27 +6,32 @@ import type { Placement } from '@floating-ui/react'
|
|||||||
import {
|
import {
|
||||||
RiEqualizer2Line,
|
RiEqualizer2Line,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Divider from '../../base/divider'
|
||||||
|
import { removeAccessToken } from '../utils'
|
||||||
|
import InfoModal from './info-modal'
|
||||||
import ActionButton from '@/app/components/base/action-button'
|
import ActionButton from '@/app/components/base/action-button'
|
||||||
import {
|
import {
|
||||||
PortalToFollowElem,
|
PortalToFollowElem,
|
||||||
PortalToFollowElemContent,
|
PortalToFollowElemContent,
|
||||||
PortalToFollowElemTrigger,
|
PortalToFollowElemTrigger,
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
import Divider from '@/app/components/base/divider'
|
|
||||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||||
import InfoModal from './info-modal'
|
|
||||||
import type { SiteInfo } from '@/models/share'
|
import type { SiteInfo } from '@/models/share'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data?: SiteInfo
|
data?: SiteInfo
|
||||||
placement?: Placement
|
placement?: Placement
|
||||||
|
hideLogout?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const MenuDropdown: FC<Props> = ({
|
const MenuDropdown: FC<Props> = ({
|
||||||
data,
|
data,
|
||||||
placement,
|
placement,
|
||||||
|
hideLogout,
|
||||||
}) => {
|
}) => {
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, doSetOpen] = useState(false)
|
const [open, doSetOpen] = useState(false)
|
||||||
const openRef = useRef(open)
|
const openRef = useRef(open)
|
||||||
@ -39,6 +44,11 @@ const MenuDropdown: FC<Props> = ({
|
|||||||
setOpen(!openRef.current)
|
setOpen(!openRef.current)
|
||||||
}, [setOpen])
|
}, [setOpen])
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
removeAccessToken()
|
||||||
|
router.replace(`/webapp-signin?redirect_url=${window.location.href}`)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -64,7 +74,7 @@ const MenuDropdown: FC<Props> = ({
|
|||||||
<div className='p-1'>
|
<div className='p-1'>
|
||||||
<div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}>
|
<div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}>
|
||||||
<div className='grow'>{t('common.theme.theme')}</div>
|
<div className='grow'>{t('common.theme.theme')}</div>
|
||||||
<ThemeSwitcher/>
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider type='horizontal' className='my-0' />
|
<Divider type='horizontal' className='my-0' />
|
||||||
|
@ -15,14 +15,14 @@ import WorkflowToolEmpty from '@/app/components/tools/add-tool-modal/empty'
|
|||||||
import Card from '@/app/components/plugins/card'
|
import Card from '@/app/components/plugins/card'
|
||||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||||
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
|
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
|
||||||
import { useAllToolProviders } from '@/service/use-tools'
|
import { useAllToolProviders } from '@/service/use-tools'
|
||||||
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
const ProviderList = () => {
|
const ProviderList = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useTabSearchParams({
|
const [activeTab, setActiveTab] = useTabSearchParams({
|
||||||
defaultTab: 'builtin',
|
defaultTab: 'builtin',
|
||||||
@ -144,8 +144,8 @@ const ProviderList = () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div >
|
||||||
</div>
|
</div >
|
||||||
{currentProvider && !currentProvider.plugin_id && (
|
{currentProvider && !currentProvider.plugin_id && (
|
||||||
<ProviderDetail
|
<ProviderDetail
|
||||||
collection={currentProvider}
|
collection={currentProvider}
|
||||||
|
@ -26,9 +26,8 @@ import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
|||||||
import { useToastContext } from '@/app/components/base/toast'
|
import { useToastContext } from '@/app/components/base/toast'
|
||||||
import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
|
import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
|
||||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||||
import { fetchAppDetail, fetchAppSSO } from '@/service/apps'
|
import { fetchAppDetail } from '@/service/apps'
|
||||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
import { useSelector as useAppSelector } from '@/context/app-context'
|
|
||||||
|
|
||||||
const FeaturesTrigger = () => {
|
const FeaturesTrigger = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@ -36,7 +35,6 @@ const FeaturesTrigger = () => {
|
|||||||
const appDetail = useAppStore(s => s.appDetail)
|
const appDetail = useAppStore(s => s.appDetail)
|
||||||
const appID = appDetail?.id
|
const appID = appDetail?.id
|
||||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||||
const systemFeatures = useAppSelector(state => state.systemFeatures)
|
|
||||||
const {
|
const {
|
||||||
nodesReadOnly,
|
nodesReadOnly,
|
||||||
getNodesReadOnly,
|
getNodesReadOnly,
|
||||||
@ -85,18 +83,12 @@ const FeaturesTrigger = () => {
|
|||||||
const updateAppDetail = useCallback(async () => {
|
const updateAppDetail = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchAppDetail({ url: '/apps', id: appID! })
|
const res = await fetchAppDetail({ url: '/apps', id: appID! })
|
||||||
if (systemFeatures.enable_web_sso_switch_component) {
|
setAppDetail({ ...res })
|
||||||
const ssoRes = await fetchAppSSO({ appId: appID! })
|
|
||||||
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setAppDetail({ ...res })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}, [appID, setAppDetail, systemFeatures.enable_web_sso_switch_component])
|
}, [appID, setAppDetail])
|
||||||
const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!)
|
const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!)
|
||||||
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
|
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
|
||||||
if (await handleCheckBeforePublish()) {
|
if (await handleCheckBeforePublish()) {
|
||||||
|
@ -21,7 +21,7 @@ import ActionButton from '../../base/action-button'
|
|||||||
import { RiAddLine } from '@remixicon/react'
|
import { RiAddLine } from '@remixicon/react'
|
||||||
import { PluginType } from '../../plugins/types'
|
import { PluginType } from '../../plugins/types'
|
||||||
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
|
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
|
||||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
|
||||||
type AllToolsProps = {
|
type AllToolsProps = {
|
||||||
className?: string
|
className?: string
|
||||||
@ -87,7 +87,7 @@ const AllTools = ({
|
|||||||
plugins: notInstalledPlugins = [],
|
plugins: notInstalledPlugins = [],
|
||||||
} = useMarketplacePlugins()
|
} = useMarketplacePlugins()
|
||||||
|
|
||||||
const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures)
|
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enable_marketplace) return
|
if (enable_marketplace) return
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user