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