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:
NFish 2025-05-20 12:07:50 +08:00 committed by GitHub
parent 6a8ca8296b
commit d186daa131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
199 changed files with 3618 additions and 1000 deletions

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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,6 +89,11 @@ 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:
system_features = FeatureService.get_system_features()
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
raise WorkspacesLimitExceeded()
else:
return { return {
"result": "fail", "result": "fail",
"data": "workspace not found, please contact system admin to invite you to join in a workspace", "data": "workspace not found, please contact system admin to invite you to join in a workspace",
@ -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()}

View File

@ -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."

View File

@ -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

View File

@ -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"],

View File

@ -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,
]

View File

@ -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(

View File

@ -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

View 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")

View File

@ -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")

View File

@ -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

View 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")

View File

@ -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()

View File

@ -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") source = decoded.get("token_source")
if not source or source != "sso": if not source or source != "webapp":
raise WebSSOAuthRequiredError() raise WebAppAuthRequiredError()
# Check if SSO is not enforced for web, and if the token source is SSO, # Check if authentication is not enforced for web, and if the token source is webapp,
# raise an error and redirect to normal passport login # raise an error and redirect to normal passport login
if not system_features.sso_enforced_for_web or not app_web_sso_enabled: if not system_webapp_auth_enabled or not app_web_auth_enabled:
source = decoded.get("token_source") source = decoded.get("token_source")
if source and source == "sso": if source and source == "webapp":
raise Unauthorized("sso token expired.") 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):

View File

@ -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,
} }

View File

@ -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

View File

@ -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)

View File

@ -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)

View 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
)

View File

@ -7,3 +7,7 @@ class WorkSpaceNotAllowedCreateError(BaseServiceError):
class WorkSpaceNotFoundError(BaseServiceError): class WorkSpaceNotFoundError(BaseServiceError):
pass pass
class WorkspacesLimitExceededError(BaseServiceError):
pass

View File

@ -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"]

View 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

View File

@ -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()

View 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))

View File

@ -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,21 +34,43 @@ 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":
template = "invite_member_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/invite_member_mail_template_zh-CN.html"
html_content = render_template( html_content = render_template(
"invite_member_mail_template_zh-CN.html", template,
to=to, to=to,
inviter_name=inviter_name, inviter_name=inviter_name,
workspace_name=workspace_name, workspace_name=workspace_name,
url=url, 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) mail.send(to=to, subject="立即加入 Dify 工作空间", html=html_content)
else: else:
template = "invite_member_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/invite_member_mail_template_en-US.html"
html_content = render_template( html_content = render_template(
"invite_member_mail_template_en-US.html", template,
to=to, to=to,
inviter_name=inviter_name, inviter_name=inviter_name,
workspace_name=workspace_name, workspace_name=workspace_name,
url=url, 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) mail.send(to=to, subject="Join Dify Workspace Now", html=html_content)

View File

@ -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,26 @@ 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"
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) 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"
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) mail.send(to=to, subject="Set Your Dify Password", html=html_content)
end_at = time.perf_counter() end_at = time.perf_counter()

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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()

View File

@ -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,19 +30,12 @@ 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) {
const ssoRes = await fetchAppSSO({ appId })
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
}
else {
setAppDetail({ ...res }) 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)
} }

View File

@ -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 (
<> <>

View File

@ -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)} />
)}
</> </>
) )
} }

View File

@ -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()

View 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}
</>)
}

View File

@ -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'>

View File

@ -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)

View File

@ -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} />
)} )}

View File

@ -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(

View File

@ -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 />
} }

View File

@ -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.

View File

@ -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` 泄露,导致财产损失。

View File

@ -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)

View File

@ -30,9 +30,4 @@ const Layout = ({ children }: { children: ReactNode }) => {
</> </>
) )
} }
export const metadata = {
title: 'Dify',
}
export default Layout export default Layout

View File

@ -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)

View File

@ -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>
}
if (systemFeatures.webapp_auth.enabled) {
if (systemFeatures.webapp_auth.allow_sso) {
return ( return (
<div className="flex h-full items-center justify-center"> <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]')}> <div className={cn('flex w-full grow flex-col items-center justify-center', 'px-6', 'md:px-[108px]')}>
<Loading type='area' /> <Loading />
</div> </div>
</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>
}
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)

View File

@ -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)

View File

@ -32,9 +32,4 @@ const Layout = ({ children }: { children: ReactNode }) => {
</> </>
) )
} }
export const metadata = {
title: 'Dify',
}
export default Layout export default Layout

View File

@ -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>

View File

@ -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()

View File

@ -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>
) )

View File

@ -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'}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>
}

View 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>
}

View File

@ -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>
}

View File

@ -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()
@ -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) => {
@ -223,23 +263,54 @@ const AppPublisher = ({
) )
} }
</div> </div>
<div className='border-t-[0.5px] border-t-divider-regular p-4 pt-3'> {(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
? <div className='py-2'><Loading /></div>
: <>
<Divider className='my-0' />
{systemFeatures.webapp_auth.enabled && <div className='p-4 pt-3'>
<div className='flex h-6 items-center'>
<p className='system-xs-medium text-text-tertiary'>{t('app.publishApp.title')}</p>
</div>
<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'
onClick={() => {
setShowAppAccessControl(true)
}}>
<div className='flex grow items-center gap-x-1.5 pr-1'>
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
{appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>}
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>}
{appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>}
</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 <SuggestedAction
disabled={!publishedAt} className='flex-1'
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
link={appURL} link={appURL}
icon={<RiPlayCircleLine className='h-4 w-4' />} icon={<RiPlayCircleLine className='h-4 w-4' />}
> >
{t('workflow.common.runApp')} {t('workflow.common.runApp')}
</SuggestedAction> </SuggestedAction>
</Tooltip>
{appDetail?.mode === 'workflow' || appDetail?.mode === 'completion' {appDetail?.mode === 'workflow' || appDetail?.mode === 'completion'
? ( ? (
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
<SuggestedAction <SuggestedAction
disabled={!publishedAt} className='flex-1'
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<RiPlayList2Line className='h-4 w-4' />} icon={<RiPlayList2Line className='h-4 w-4' />}
> >
{t('workflow.common.batchRunApp')} {t('workflow.common.batchRunApp')}
</SuggestedAction> </SuggestedAction>
</Tooltip>
) )
: ( : (
<SuggestedAction <SuggestedAction
@ -253,15 +324,18 @@ const AppPublisher = ({
{t('workflow.common.embedIntoSite')} {t('workflow.common.embedIntoSite')}
</SuggestedAction> </SuggestedAction>
)} )}
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
<SuggestedAction <SuggestedAction
className='flex-1'
onClick={() => { onClick={() => {
publishedAt && handleOpenInExplore() publishedAt && handleOpenInExplore()
}} }}
disabled={!publishedAt} disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
icon={<RiPlanetLine className='h-4 w-4' />} icon={<RiPlanetLine className='h-4 w-4' />}
> >
{t('workflow.common.openInExplore')} {t('workflow.common.openInExplore')}
</SuggestedAction> </SuggestedAction>
</Tooltip>
<SuggestedAction <SuggestedAction
disabled={!publishedAt} disabled={!publishedAt}
link='./develop' link='./develop'
@ -287,6 +361,7 @@ const AppPublisher = ({
/> />
)} )}
</div> </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)

View File

@ -8,16 +8,23 @@ 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) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (disabled)
return
onClick?.(e)
}
return (
<a <a
href={disabled ? undefined : link} href={disabled ? undefined : link}
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className={classNames( 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', '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',
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'hover:bg-state-accent-hover hover:text-text-accent cursor-pointer', disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
className, className,
)} )}
onClick={handleClick}
{...props} {...props}
> >
<div className='relative h-4 w-4'>{icon}</div> <div className='relative h-4 w-4'>{icon}</div>
@ -25,5 +32,6 @@ const SuggestedAction = ({ icon, link, disabled, children, className, ...props }
<RiArrowRightUpLine className='h-3.5 w-3.5' /> <RiArrowRightUpLine className='h-3.5 w-3.5' />
</a> </a>
) )
}
export default SuggestedAction export default SuggestedAction

View File

@ -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}

View File

@ -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

View File

@ -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
} }

View File

@ -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: [],

View File

@ -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,

View File

@ -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,

View File

@ -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,16 +148,14 @@ 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>
</div>
{!!showConfirm && ( {!!showConfirm && (
<Confirm <Confirm
title={t('share.chat.deleteConversation.title')} title={t('share.chat.deleteConversation.title')}
@ -173,6 +175,7 @@ const Sidebar = ({ isPanel }: Props) => {
/> />
)} )}
</div> </div>
</div>
) )
} }

View File

@ -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: [],

View File

@ -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,

View File

@ -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,

View File

@ -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'
/> />

View File

@ -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)

View File

@ -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>

View File

@ -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
} }
} }

View File

@ -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')}
@ -203,7 +205,7 @@ const StepOne = ({
> >
<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')}

View File

@ -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`,如下所示:

View File

@ -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`,如下所示:

View File

@ -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)

View File

@ -26,7 +26,7 @@ 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' />
)} )}

View File

@ -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,6 +126,7 @@ export default function AppSelector() {
</div> </div>
</MenuItem> </MenuItem>
</div> </div>
{!systemFeatures.branding.enabled && <>
<div className='p-1'> <div className='p-1'>
<MenuItem> <MenuItem>
<Link <Link
@ -156,7 +156,7 @@ export default function AppSelector() {
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' /> <RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
</Link> </Link>
</MenuItem> </MenuItem>
{systemFeatures.license.status === LicenseStatus.NONE && <MenuItem> <MenuItem>
<Link <Link
className={cn(itemClassName, 'group justify-between', className={cn(itemClassName, 'group justify-between',
'data-[active]:bg-state-base-hover', 'data-[active]:bg-state-base-hover',
@ -170,7 +170,7 @@ export default function AppSelector() {
<GithubStar className='system-2xs-medium-uppercase text-text-tertiary' /> <GithubStar className='system-2xs-medium-uppercase text-text-tertiary' />
</div> </div>
</Link> </Link>
</MenuItem>} </MenuItem>
{ {
document?.body?.getAttribute('data-public-site-about') !== 'hide' && ( document?.body?.getAttribute('data-public-site-about') !== 'hide' && (
<MenuItem> <MenuItem>
@ -188,6 +188,7 @@ export default function AppSelector() {
) )
} }
</div> </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')}>

View File

@ -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)

View File

@ -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')}

View File

@ -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[] = []

View File

@ -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

View File

@ -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)

View File

@ -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>) => {

View File

@ -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(() => {

View File

@ -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]

View File

@ -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(() => {

View File

@ -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>

View File

@ -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

View File

@ -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 (

View File

@ -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',

View File

@ -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) {
const ssoRes = await fetchAppSSO({ appId: appID! })
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
}
else {
setAppDetail({ ...res }) 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()) {

View File

@ -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