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
| - name: Checkout code | - name: Checkout code | ||||
| uses: actions/checkout@v4 | uses: actions/checkout@v4 | ||||
| with: | with: | ||||
| fetch-depth: 0 | |||||
| persist-credentials: false | persist-credentials: false | ||||
| - name: Check changed files | - name: Check changed files |
| ) | ) | ||||
| from core.ops.ops_trace_manager import OpsTraceManager | from core.ops.ops_trace_manager import OpsTraceManager | ||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from fields.app_fields import ( | |||||
| 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 libs.login import login_required | ||||
| from models import Account, App | from models import Account, App | ||||
| from services.app_dsl_service import AppDslService, ImportMode | from services.app_dsl_service import AppDslService, ImportMode | ||||
| from services.app_service import AppService | from services.app_service import AppService | ||||
| from services.enterprise.enterprise_service import EnterpriseService | |||||
| from services.feature_service import FeatureService | |||||
| ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] | ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] | ||||
| if not app_pagination: | if not app_pagination: | ||||
| return {"data": [], "total": 0, "page": 1, "limit": 20, "has_more": False} | return {"data": [], "total": 0, "page": 1, "limit": 20, "has_more": False} | ||||
| return marshal(app_pagination, app_pagination_fields) | |||||
| if FeatureService.get_system_features().webapp_auth.enabled: | |||||
| app_ids = [str(app.id) for app in app_pagination.items] | |||||
| res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids) | |||||
| if len(res) != len(app_ids): | |||||
| raise BadRequest("Invalid app id in webapp auth") | |||||
| for app in app_pagination.items: | |||||
| if str(app.id) in res: | |||||
| app.access_mode = res[str(app.id)].access_mode | |||||
| return marshal(app_pagination, app_pagination_fields), 200 | |||||
| @setup_required | @setup_required | ||||
| @login_required | @login_required | ||||
| app_model = app_service.get_app(app_model) | app_model = app_service.get_app(app_model) | ||||
| if FeatureService.get_system_features().webapp_auth.enabled: | |||||
| app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) | |||||
| app_model.access_mode = app_setting.access_mode | |||||
| return app_model | return app_model | ||||
| @setup_required | @setup_required |
| from models.account import Account | from models.account import Account | ||||
| from services.account_service import AccountService, TenantService | from services.account_service import AccountService, TenantService | ||||
| from services.errors.account import AccountRegisterError | from services.errors.account import AccountRegisterError | ||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError | |||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError | |||||
| from services.feature_service import FeatureService | from services.feature_service import FeatureService | ||||
| if not reset_data: | if not reset_data: | ||||
| raise InvalidTokenError() | raise InvalidTokenError() | ||||
| # Must use token in reset phase | # Must use token in reset phase | ||||
| if reset_data.get("phase", "") != "reset": | |||||
| raise InvalidTokenError() | |||||
| # Must use token in reset phase | |||||
| if reset_data.get("phase", "") != "reset": | if reset_data.get("phase", "") != "reset": | ||||
| raise InvalidTokenError() | raise InvalidTokenError() | ||||
| ) | ) | ||||
| except WorkSpaceNotAllowedCreateError: | except WorkSpaceNotAllowedCreateError: | ||||
| pass | pass | ||||
| except WorkspacesLimitExceededError: | |||||
| pass | |||||
| except AccountRegisterError: | except AccountRegisterError: | ||||
| raise AccountInFreezeError() | raise AccountInFreezeError() | ||||
| AccountNotFound, | AccountNotFound, | ||||
| EmailSendIpLimitError, | EmailSendIpLimitError, | ||||
| NotAllowedCreateWorkspace, | NotAllowedCreateWorkspace, | ||||
| WorkspacesLimitExceeded, | |||||
| ) | ) | ||||
| from controllers.console.wraps import email_password_login_enabled, setup_required | from controllers.console.wraps import email_password_login_enabled, setup_required | ||||
| from events.tenant_event import tenant_was_created | from events.tenant_event import tenant_was_created | ||||
| from services.account_service import AccountService, RegisterService, TenantService | from services.account_service import AccountService, RegisterService, TenantService | ||||
| from services.billing_service import BillingService | from services.billing_service import BillingService | ||||
| from services.errors.account import AccountRegisterError | from services.errors.account import AccountRegisterError | ||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError | |||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError | |||||
| from services.feature_service import FeatureService | from services.feature_service import FeatureService | ||||
| # SELF_HOSTED only have one workspace | # SELF_HOSTED only have one workspace | ||||
| tenants = TenantService.get_join_tenants(account) | tenants = TenantService.get_join_tenants(account) | ||||
| if len(tenants) == 0: | if len(tenants) == 0: | ||||
| return { | |||||
| "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)) | token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) | ||||
| AccountService.reset_login_error_rate_limit(args["email"]) | AccountService.reset_login_error_rate_limit(args["email"]) | ||||
| if account: | if account: | ||||
| tenant = TenantService.get_join_tenants(account) | tenant = TenantService.get_join_tenants(account) | ||||
| if not tenant: | if not tenant: | ||||
| workspaces = FeatureService.get_system_features().license.workspaces | |||||
| if not workspaces.is_available(): | |||||
| raise WorkspacesLimitExceeded() | |||||
| if not FeatureService.get_system_features().is_allow_create_workspace: | if not FeatureService.get_system_features().is_allow_create_workspace: | ||||
| raise NotAllowedCreateWorkspace() | raise NotAllowedCreateWorkspace() | ||||
| else: | else: | ||||
| return NotAllowedCreateWorkspace() | return NotAllowedCreateWorkspace() | ||||
| except AccountRegisterError as are: | except AccountRegisterError as are: | ||||
| raise AccountInFreezeError() | raise AccountInFreezeError() | ||||
| except WorkspacesLimitExceededError: | |||||
| raise WorkspacesLimitExceeded() | |||||
| token_pair = AccountService.login(account, ip_address=extract_remote_ip(request)) | token_pair = AccountService.login(account, ip_address=extract_remote_ip(request)) | ||||
| AccountService.reset_login_error_rate_limit(args["email"]) | AccountService.reset_login_error_rate_limit(args["email"]) | ||||
| return {"result": "success", "data": token_pair.model_dump()} | return {"result": "success", "data": token_pair.model_dump()} |
| code = 400 | code = 400 | ||||
| class WorkspaceMembersLimitExceeded(BaseHTTPException): | |||||
| error_code = "limit_exceeded" | |||||
| description = "Unable to add member because the maximum workspace's member limit was exceeded" | |||||
| code = 400 | |||||
| class WorkspacesLimitExceeded(BaseHTTPException): | |||||
| error_code = "limit_exceeded" | |||||
| description = "Unable to create workspace because the maximum workspace limit was exceeded" | |||||
| code = 400 | |||||
| class AccountBannedError(BaseHTTPException): | class AccountBannedError(BaseHTTPException): | ||||
| error_code = "account_banned" | error_code = "account_banned" | ||||
| description = "Account is banned." | description = "Account is banned." |
| error_code = "app_suggested_questions_after_answer_disabled" | error_code = "app_suggested_questions_after_answer_disabled" | ||||
| description = "Function Suggested questions after answer disabled." | description = "Function Suggested questions after answer disabled." | ||||
| code = 403 | code = 403 | ||||
| class AppAccessDeniedError(BaseHTTPException): | |||||
| error_code = "access_denied" | |||||
| description = "App access denied." | |||||
| code = 403 |
| import logging | |||||
| from datetime import UTC, datetime | from datetime import UTC, datetime | ||||
| from typing import Any | from typing import Any | ||||
| from libs.login import login_required | from libs.login import login_required | ||||
| from models import App, InstalledApp, RecommendedApp | from models import App, InstalledApp, RecommendedApp | ||||
| from services.account_service import TenantService | from services.account_service import TenantService | ||||
| from services.app_service import AppService | |||||
| from services.enterprise.enterprise_service import EnterpriseService | |||||
| from services.feature_service import FeatureService | |||||
| logger = logging.getLogger(__name__) | |||||
| class InstalledAppsListApi(Resource): | class InstalledAppsListApi(Resource): | ||||
| for installed_app in installed_apps | for installed_app in installed_apps | ||||
| if installed_app.app is not None | if installed_app.app is not None | ||||
| ] | ] | ||||
| # filter out apps that user doesn't have access to | |||||
| if FeatureService.get_system_features().webapp_auth.enabled: | |||||
| user_id = current_user.id | |||||
| res = [] | |||||
| for installed_app in installed_app_list: | |||||
| app_code = AppService.get_app_code_by_id(str(installed_app["app"].id)) | |||||
| if EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp( | |||||
| user_id=user_id, | |||||
| app_code=app_code, | |||||
| ): | |||||
| res.append(installed_app) | |||||
| installed_app_list = res | |||||
| logger.debug(f"installed_app_list: {installed_app_list}, user_id: {user_id}") | |||||
| installed_app_list.sort( | installed_app_list.sort( | ||||
| key=lambda app: ( | key=lambda app: ( | ||||
| -app["is_pinned"], | -app["is_pinned"], |
| from flask_restful import Resource | from flask_restful import Resource | ||||
| from werkzeug.exceptions import NotFound | from werkzeug.exceptions import NotFound | ||||
| from controllers.console.explore.error import AppAccessDeniedError | |||||
| from controllers.console.wraps import account_initialization_required | from controllers.console.wraps import account_initialization_required | ||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from libs.login import login_required | from libs.login import login_required | ||||
| from models import InstalledApp | from models import InstalledApp | ||||
| from services.app_service import AppService | |||||
| from services.enterprise.enterprise_service import EnterpriseService | |||||
| from services.feature_service import FeatureService | |||||
| def installed_app_required(view=None): | def installed_app_required(view=None): | ||||
| return decorator | return decorator | ||||
| def user_allowed_to_access_app(view=None): | |||||
| def decorator(view): | |||||
| @wraps(view) | |||||
| def decorated(installed_app: InstalledApp, *args, **kwargs): | |||||
| feature = FeatureService.get_system_features() | |||||
| if feature.webapp_auth.enabled: | |||||
| app_id = installed_app.app_id | |||||
| app_code = AppService.get_app_code_by_id(app_id) | |||||
| res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp( | |||||
| user_id=str(current_user.id), | |||||
| app_code=app_code, | |||||
| ) | |||||
| if not res: | |||||
| raise AppAccessDeniedError() | |||||
| return view(installed_app, *args, **kwargs) | |||||
| return decorated | |||||
| if view: | |||||
| return decorator(view) | |||||
| return decorator | |||||
| class InstalledAppResource(Resource): | class InstalledAppResource(Resource): | ||||
| # must be reversed if there are multiple decorators | # must be reversed if there are multiple decorators | ||||
| method_decorators = [installed_app_required, account_initialization_required, login_required] | |||||
| method_decorators = [ | |||||
| user_allowed_to_access_app, | |||||
| installed_app_required, | |||||
| account_initialization_required, | |||||
| login_required, | |||||
| ] |
| import services | import services | ||||
| from configs import dify_config | from configs import dify_config | ||||
| from controllers.console import api | from controllers.console import api | ||||
| from controllers.console.error import WorkspaceMembersLimitExceeded | |||||
| from controllers.console.wraps import ( | from controllers.console.wraps import ( | ||||
| account_initialization_required, | account_initialization_required, | ||||
| cloud_edition_billing_resource_check, | cloud_edition_billing_resource_check, | ||||
| from models.account import Account, TenantAccountRole | from models.account import Account, TenantAccountRole | ||||
| from services.account_service import RegisterService, TenantService | from services.account_service import RegisterService, TenantService | ||||
| from services.errors.account import AccountAlreadyInTenantError | from services.errors.account import AccountAlreadyInTenantError | ||||
| from services.feature_service import FeatureService | |||||
| class MemberListApi(Resource): | class MemberListApi(Resource): | ||||
| inviter = current_user | inviter = current_user | ||||
| invitation_results = [] | invitation_results = [] | ||||
| console_web_url = dify_config.CONSOLE_WEB_URL | console_web_url = dify_config.CONSOLE_WEB_URL | ||||
| workspace_members = FeatureService.get_features(tenant_id=inviter.current_tenant.id).workspace_members | |||||
| if not workspace_members.is_available(len(invitee_emails)): | |||||
| raise WorkspaceMembersLimitExceeded() | |||||
| for invitee_email in invitee_emails: | for invitee_email in invitee_emails: | ||||
| try: | try: | ||||
| token = RegisterService.invite_new_member( | token = RegisterService.invite_new_member( |
| bp = Blueprint("inner_api", __name__, url_prefix="/inner/api") | bp = Blueprint("inner_api", __name__, url_prefix="/inner/api") | ||||
| api = ExternalApi(bp) | api = ExternalApi(bp) | ||||
| from . import mail | |||||
| from .plugin import plugin | from .plugin import plugin | ||||
| from .workspace import workspace | from .workspace import workspace |
| 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") |
| from flask_restful import marshal_with | |||||
| from flask import request | |||||
| from flask_restful import Resource, marshal_with, reqparse | |||||
| from controllers.common import fields | from controllers.common import fields | ||||
| from controllers.web import api | from controllers.web import api | ||||
| from controllers.web.error import AppUnavailableError | from controllers.web.error import AppUnavailableError | ||||
| from controllers.web.wraps import WebApiResource | from controllers.web.wraps import WebApiResource | ||||
| from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict | from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict | ||||
| from libs.passport import PassportService | |||||
| from models.model import App, AppMode | from models.model import App, AppMode | ||||
| from services.app_service import AppService | from services.app_service import AppService | ||||
| from services.enterprise.enterprise_service import EnterpriseService | |||||
| class AppParameterApi(WebApiResource): | class AppParameterApi(WebApiResource): | ||||
| return AppService().get_app_meta(app_model) | return AppService().get_app_meta(app_model) | ||||
| class AppAccessMode(Resource): | |||||
| def get(self): | |||||
| parser = reqparse.RequestParser() | |||||
| parser.add_argument("appId", type=str, required=True, location="args") | |||||
| args = parser.parse_args() | |||||
| app_id = args["appId"] | |||||
| res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id) | |||||
| return {"accessMode": res.access_mode} | |||||
| class AppWebAuthPermission(Resource): | |||||
| def get(self): | |||||
| user_id = "visitor" | |||||
| try: | |||||
| auth_header = request.headers.get("Authorization") | |||||
| if auth_header is None: | |||||
| raise | |||||
| if " " not in auth_header: | |||||
| raise | |||||
| auth_scheme, tk = auth_header.split(None, 1) | |||||
| auth_scheme = auth_scheme.lower() | |||||
| if auth_scheme != "bearer": | |||||
| raise | |||||
| decoded = PassportService().verify(tk) | |||||
| user_id = decoded.get("user_id", "visitor") | |||||
| except Exception as e: | |||||
| pass | |||||
| parser = reqparse.RequestParser() | |||||
| parser.add_argument("appId", type=str, required=True, location="args") | |||||
| args = parser.parse_args() | |||||
| app_id = args["appId"] | |||||
| app_code = AppService.get_app_code_by_id(app_id) | |||||
| res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_code) | |||||
| return {"result": res} | |||||
| api.add_resource(AppParameterApi, "/parameters") | api.add_resource(AppParameterApi, "/parameters") | ||||
| api.add_resource(AppMeta, "/meta") | api.add_resource(AppMeta, "/meta") | ||||
| # webapp auth apis | |||||
| api.add_resource(AppAccessMode, "/webapp/access-mode") | |||||
| api.add_resource(AppWebAuthPermission, "/webapp/permission") |
| code = 415 | code = 415 | ||||
| class WebSSOAuthRequiredError(BaseHTTPException): | |||||
| class WebAppAuthRequiredError(BaseHTTPException): | |||||
| error_code = "web_sso_auth_required" | error_code = "web_sso_auth_required" | ||||
| description = "Web SSO authentication required." | |||||
| description = "Web app authentication required." | |||||
| code = 401 | |||||
| class WebAppAuthAccessDeniedError(BaseHTTPException): | |||||
| error_code = "web_app_access_denied" | |||||
| description = "You do not have permission to access this web app." | |||||
| code = 401 | code = 401 | ||||
| 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") |
| from werkzeug.exceptions import NotFound, Unauthorized | from werkzeug.exceptions import NotFound, Unauthorized | ||||
| from controllers.web import api | from controllers.web import api | ||||
| from controllers.web.error import WebSSOAuthRequiredError | |||||
| from controllers.web.error import WebAppAuthRequiredError | |||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from libs.passport import PassportService | from libs.passport import PassportService | ||||
| from models.model import App, EndUser, Site | from models.model import App, EndUser, Site | ||||
| if app_code is None: | if app_code is None: | ||||
| raise Unauthorized("X-App-Code header is missing.") | raise Unauthorized("X-App-Code header is missing.") | ||||
| if system_features.sso_enforced_for_web: | |||||
| 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 | # get site from db and check if it is normal | ||||
| site = db.session.query(Site).filter(Site.code == app_code, Site.status == "normal").first() | site = db.session.query(Site).filter(Site.code == app_code, Site.status == "normal").first() |
| from flask_restful import Resource | from flask_restful import Resource | ||||
| from werkzeug.exceptions import BadRequest, NotFound, Unauthorized | from werkzeug.exceptions import BadRequest, NotFound, Unauthorized | ||||
| from controllers.web.error import WebSSOAuthRequiredError | |||||
| from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError | |||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from libs.passport import PassportService | from libs.passport import PassportService | ||||
| from models.model import App, EndUser, Site | from models.model import App, EndUser, Site | ||||
| def decode_jwt_token(): | def decode_jwt_token(): | ||||
| system_features = FeatureService.get_system_features() | system_features = FeatureService.get_system_features() | ||||
| app_code = request.headers.get("X-App-Code") | |||||
| app_code = str(request.headers.get("X-App-Code")) | |||||
| try: | try: | ||||
| auth_header = request.headers.get("Authorization") | auth_header = request.headers.get("Authorization") | ||||
| if auth_header is None: | if auth_header is None: | ||||
| if not end_user: | if not end_user: | ||||
| raise NotFound() | raise NotFound() | ||||
| _validate_web_sso_token(decoded, system_features, app_code) | |||||
| # for enterprise webapp auth | |||||
| app_web_auth_enabled = False | |||||
| if system_features.webapp_auth.enabled: | |||||
| app_web_auth_enabled = ( | |||||
| EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code).access_mode != "public" | |||||
| ) | |||||
| _validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled) | |||||
| _validate_user_accessibility(decoded, app_code, app_web_auth_enabled, system_features.webapp_auth.enabled) | |||||
| return app_model, end_user | return app_model, end_user | ||||
| except Unauthorized as e: | except Unauthorized as e: | ||||
| if system_features.sso_enforced_for_web: | |||||
| 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) | 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 | # raise an error and redirect to normal passport login | ||||
| if not system_features.sso_enforced_for_web or not app_web_sso_enabled: | |||||
| if not system_webapp_auth_enabled or not app_web_auth_enabled: | |||||
| source = decoded.get("token_source") | source = decoded.get("token_source") | ||||
| if source and source == "sso": | |||||
| 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): | class WebApiResource(Resource): |
| "created_at": TimestampField, | "created_at": TimestampField, | ||||
| "updated_by": fields.String, | "updated_by": fields.String, | ||||
| "updated_at": TimestampField, | "updated_at": TimestampField, | ||||
| "access_mode": fields.String, | |||||
| } | } | ||||
| prompt_config_fields = { | prompt_config_fields = { | ||||
| "updated_by": fields.String, | "updated_by": fields.String, | ||||
| "updated_at": TimestampField, | "updated_at": TimestampField, | ||||
| "tags": fields.List(fields.Nested(tag_fields)), | "tags": fields.List(fields.Nested(tag_fields)), | ||||
| "access_mode": fields.String, | |||||
| } | } | ||||
| "updated_by": fields.String, | "updated_by": fields.String, | ||||
| "updated_at": TimestampField, | "updated_at": TimestampField, | ||||
| "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), | "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), | ||||
| "access_mode": fields.String, | |||||
| } | } | ||||
| RoleAlreadyAssignedError, | RoleAlreadyAssignedError, | ||||
| TenantNotFoundError, | TenantNotFoundError, | ||||
| ) | ) | ||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError | |||||
| from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError | |||||
| from services.feature_service import FeatureService | from services.feature_service import FeatureService | ||||
| from tasks.delete_account_task import delete_account_task | from tasks.delete_account_task import delete_account_task | ||||
| from tasks.mail_account_deletion_task import send_account_deletion_verification_code | from tasks.mail_account_deletion_task import send_account_deletion_verification_code | ||||
| if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup: | if not FeatureService.get_system_features().is_allow_create_workspace and not is_setup: | ||||
| raise WorkSpaceNotAllowedCreateError() | raise WorkSpaceNotAllowedCreateError() | ||||
| workspaces = FeatureService.get_system_features().license.workspaces | |||||
| if not workspaces.is_available(): | |||||
| raise WorkspacesLimitExceededError() | |||||
| if name: | if name: | ||||
| tenant = TenantService.create_tenant(name=name, is_setup=is_setup) | tenant = TenantService.create_tenant(name=name, is_setup=is_setup) | ||||
| else: | else: | ||||
| if open_id is not None and provider is not None: | if open_id is not None and provider is not None: | ||||
| AccountService.link_account_integrate(provider, open_id, account) | AccountService.link_account_integrate(provider, open_id, account) | ||||
| if FeatureService.get_system_features().is_allow_create_workspace and create_workspace_required: | |||||
| if ( | |||||
| FeatureService.get_system_features().is_allow_create_workspace | |||||
| and create_workspace_required | |||||
| and FeatureService.get_system_features().license.workspaces.is_available() | |||||
| ): | |||||
| tenant = TenantService.create_tenant(f"{account.name}'s Workspace") | tenant = TenantService.create_tenant(f"{account.name}'s Workspace") | ||||
| TenantService.create_tenant_member(tenant, account, role="owner") | TenantService.create_tenant_member(tenant, account, role="owner") | ||||
| account.current_tenant = tenant | account.current_tenant = tenant |
| from events.app_event import app_was_created | from events.app_event import app_was_created | ||||
| from extensions.ext_database import db | from extensions.ext_database import db | ||||
| from models.account import Account | from models.account import Account | ||||
| from models.model import App, AppMode, AppModelConfig | |||||
| from models.model import App, AppMode, AppModelConfig, Site | |||||
| from models.tools import ApiToolProvider | from models.tools import ApiToolProvider | ||||
| from services.enterprise.enterprise_service import EnterpriseService | |||||
| from services.feature_service import FeatureService | |||||
| from services.tag_service import TagService | from services.tag_service import TagService | ||||
| from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task | from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task | ||||
| app_was_created.send(app, account=account) | app_was_created.send(app, account=account) | ||||
| if FeatureService.get_system_features().webapp_auth.enabled: | |||||
| # update web app setting as private | |||||
| EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private") | |||||
| return app | return app | ||||
| def get_app(self, app: App) -> App: | def get_app(self, app: App) -> App: | ||||
| db.session.delete(app) | db.session.delete(app) | ||||
| db.session.commit() | db.session.commit() | ||||
| # clean up web app settings | |||||
| if FeatureService.get_system_features().webapp_auth.enabled: | |||||
| EnterpriseService.WebAppAuth.cleanup_webapp(app.id) | |||||
| # Trigger asynchronous deletion of app and related data | # Trigger asynchronous deletion of app and related data | ||||
| remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id) | remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id) | ||||
| meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"} | meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"} | ||||
| return meta | return meta | ||||
| @staticmethod | |||||
| def get_app_code_by_id(app_id: str) -> str: | |||||
| """ | |||||
| Get app code by app id | |||||
| :param app_id: app id | |||||
| :return: app code | |||||
| """ | |||||
| site = db.session.query(Site).filter(Site.app_id == app_id).first() | |||||
| if not site: | |||||
| raise ValueError(f"App with id {app_id} not found") | |||||
| return str(site.code) |
| from pydantic import BaseModel, Field | |||||
| from services.enterprise.base import EnterpriseRequest | from services.enterprise.base import EnterpriseRequest | ||||
| class WebAppSettings(BaseModel): | |||||
| access_mode: str = Field( | |||||
| description="Access mode for the web app. Can be 'public' or 'private'", | |||||
| default="private", | |||||
| alias="accessMode", | |||||
| ) | |||||
| class EnterpriseService: | class EnterpriseService: | ||||
| @classmethod | @classmethod | ||||
| def get_info(cls): | def get_info(cls): | ||||
| return EnterpriseRequest.send_request("GET", "/info") | return EnterpriseRequest.send_request("GET", "/info") | ||||
| @classmethod | @classmethod | ||||
| def get_app_web_sso_enabled(cls, app_code): | |||||
| 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) |
| 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 | |||||
| ) |
| class WorkSpaceNotFoundError(BaseServiceError): | class WorkSpaceNotFoundError(BaseServiceError): | ||||
| pass | pass | ||||
| class WorkspacesLimitExceededError(BaseServiceError): | |||||
| pass |
| from enum import StrEnum | from enum import StrEnum | ||||
| from pydantic import BaseModel, ConfigDict | |||||
| from pydantic import BaseModel, ConfigDict, Field | |||||
| from configs import dify_config | from configs import dify_config | ||||
| from services.billing_service import BillingService | from services.billing_service import BillingService | ||||
| limit: int = 0 | limit: int = 0 | ||||
| class LicenseLimitationModel(BaseModel): | |||||
| """ | |||||
| - enabled: whether this limit is enforced | |||||
| - size: current usage count | |||||
| - limit: maximum allowed count; 0 means unlimited | |||||
| """ | |||||
| enabled: bool = Field(False, description="Whether this limit is currently active") | |||||
| size: int = Field(0, description="Number of resources already consumed") | |||||
| limit: int = Field(0, description="Maximum number of resources allowed; 0 means no limit") | |||||
| def is_available(self, required: int = 1) -> bool: | |||||
| """ | |||||
| Determine whether the requested amount can be allocated. | |||||
| Returns True if: | |||||
| - this limit is not active, or | |||||
| - the limit is zero (unlimited), or | |||||
| - there is enough remaining quota. | |||||
| """ | |||||
| if not self.enabled or self.limit == 0: | |||||
| return True | |||||
| return (self.limit - self.size) >= required | |||||
| class LicenseStatus(StrEnum): | class LicenseStatus(StrEnum): | ||||
| NONE = "none" | NONE = "none" | ||||
| INACTIVE = "inactive" | INACTIVE = "inactive" | ||||
| class LicenseModel(BaseModel): | class LicenseModel(BaseModel): | ||||
| status: LicenseStatus = LicenseStatus.NONE | status: LicenseStatus = LicenseStatus.NONE | ||||
| expired_at: str = "" | expired_at: str = "" | ||||
| workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) | |||||
| class BrandingModel(BaseModel): | |||||
| enabled: bool = False | |||||
| application_title: str = "" | |||||
| login_page_logo: str = "" | |||||
| workspace_logo: str = "" | |||||
| favicon: str = "" | |||||
| class WebAppAuthSSOModel(BaseModel): | |||||
| protocol: str = "" | |||||
| class WebAppAuthModel(BaseModel): | |||||
| enabled: bool = False | |||||
| allow_sso: bool = False | |||||
| sso_config: WebAppAuthSSOModel = WebAppAuthSSOModel() | |||||
| allow_email_code_login: bool = False | |||||
| allow_email_password_login: bool = False | |||||
| class FeatureModel(BaseModel): | class FeatureModel(BaseModel): | ||||
| can_replace_logo: bool = False | can_replace_logo: bool = False | ||||
| model_load_balancing_enabled: bool = False | model_load_balancing_enabled: bool = False | ||||
| dataset_operator_enabled: bool = False | dataset_operator_enabled: bool = False | ||||
| webapp_copyright_enabled: bool = False | |||||
| workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) | |||||
| # pydantic configs | # pydantic configs | ||||
| model_config = ConfigDict(protected_namespaces=()) | model_config = ConfigDict(protected_namespaces=()) | ||||
| class SystemFeatureModel(BaseModel): | class SystemFeatureModel(BaseModel): | ||||
| sso_enforced_for_signin: bool = False | sso_enforced_for_signin: bool = False | ||||
| sso_enforced_for_signin_protocol: str = "" | sso_enforced_for_signin_protocol: str = "" | ||||
| sso_enforced_for_web: bool = False | |||||
| sso_enforced_for_web_protocol: str = "" | |||||
| enable_web_sso_switch_component: bool = False | |||||
| enable_marketplace: bool = False | enable_marketplace: bool = False | ||||
| max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE | max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE | ||||
| enable_email_code_login: bool = False | enable_email_code_login: bool = False | ||||
| is_allow_create_workspace: bool = False | is_allow_create_workspace: bool = False | ||||
| is_email_setup: bool = False | is_email_setup: bool = False | ||||
| license: LicenseModel = LicenseModel() | license: LicenseModel = LicenseModel() | ||||
| branding: BrandingModel = BrandingModel() | |||||
| webapp_auth: WebAppAuthModel = WebAppAuthModel() | |||||
| class FeatureService: | class FeatureService: | ||||
| if dify_config.BILLING_ENABLED and tenant_id: | if dify_config.BILLING_ENABLED and tenant_id: | ||||
| cls._fulfill_params_from_billing_api(features, tenant_id) | cls._fulfill_params_from_billing_api(features, tenant_id) | ||||
| if dify_config.ENTERPRISE_ENABLED: | |||||
| features.webapp_copyright_enabled = True | |||||
| cls._fulfill_params_from_workspace_info(features, tenant_id) | |||||
| return features | return features | ||||
| @classmethod | @classmethod | ||||
| cls._fulfill_system_params_from_env(system_features) | cls._fulfill_system_params_from_env(system_features) | ||||
| if dify_config.ENTERPRISE_ENABLED: | if dify_config.ENTERPRISE_ENABLED: | ||||
| system_features.enable_web_sso_switch_component = True | |||||
| system_features.branding.enabled = True | |||||
| system_features.webapp_auth.enabled = True | |||||
| cls._fulfill_params_from_enterprise(system_features) | cls._fulfill_params_from_enterprise(system_features) | ||||
| if dify_config.MARKETPLACE_ENABLED: | if dify_config.MARKETPLACE_ENABLED: | ||||
| features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED | features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED | ||||
| features.education.enabled = dify_config.EDUCATION_ENABLED | features.education.enabled = dify_config.EDUCATION_ENABLED | ||||
| @classmethod | |||||
| def _fulfill_params_from_workspace_info(cls, features: FeatureModel, tenant_id: str): | |||||
| workspace_info = EnterpriseService.get_workspace_info(tenant_id) | |||||
| if "WorkspaceMembers" in workspace_info: | |||||
| features.workspace_members.size = workspace_info["WorkspaceMembers"]["used"] | |||||
| features.workspace_members.limit = workspace_info["WorkspaceMembers"]["limit"] | |||||
| features.workspace_members.enabled = workspace_info["WorkspaceMembers"]["enabled"] | |||||
| @classmethod | @classmethod | ||||
| def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str): | def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str): | ||||
| billing_info = BillingService.get_info(tenant_id) | billing_info = BillingService.get_info(tenant_id) | ||||
| features.billing.subscription.interval = billing_info["subscription"]["interval"] | features.billing.subscription.interval = billing_info["subscription"]["interval"] | ||||
| features.education.activated = billing_info["subscription"].get("education", False) | features.education.activated = billing_info["subscription"].get("education", False) | ||||
| if features.billing.subscription.plan != "sandbox": | |||||
| features.webapp_copyright_enabled = True | |||||
| if "members" in billing_info: | if "members" in billing_info: | ||||
| features.members.size = billing_info["members"]["size"] | features.members.size = billing_info["members"]["size"] | ||||
| features.members.limit = billing_info["members"]["limit"] | features.members.limit = billing_info["members"]["limit"] | ||||
| features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"] | features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"] | ||||
| @classmethod | @classmethod | ||||
| def _fulfill_params_from_enterprise(cls, features): | |||||
| def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel): | |||||
| enterprise_info = EnterpriseService.get_info() | enterprise_info = EnterpriseService.get_info() | ||||
| if "sso_enforced_for_signin" in enterprise_info: | |||||
| 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: | if "status" in license_info: | ||||
| features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) | features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) | ||||
| if "expired_at" in license_info: | |||||
| 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"] |
| 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 |
| from flask import render_template | from flask import render_template | ||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| # send email code login mail using different languages | # send email code login mail using different languages | ||||
| try: | try: | ||||
| if language == "zh-Hans": | if language == "zh-Hans": | ||||
| html_content = render_template("email_code_login_mail_template_zh-CN.html", to=to, code=code) | |||||
| template = "email_code_login_mail_template_zh-CN.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/email_code_login_mail_template_zh-CN.html" | |||||
| html_content = render_template(template, to=to, code=code, application_title=application_title) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject="邮箱验证码", html=html_content) | mail.send(to=to, subject="邮箱验证码", html=html_content) | ||||
| else: | else: | ||||
| html_content = render_template("email_code_login_mail_template_en-US.html", to=to, code=code) | |||||
| template = "email_code_login_mail_template_en-US.html" | |||||
| system_features = FeatureService.get_system_features() | |||||
| if system_features.branding.enabled: | |||||
| application_title = system_features.branding.application_title | |||||
| template = "without-brand/email_code_login_mail_template_en-US.html" | |||||
| html_content = render_template(template, to=to, code=code, application_title=application_title) | |||||
| else: | |||||
| html_content = render_template(template, to=to, code=code) | |||||
| mail.send(to=to, subject="Email Code", html=html_content) | mail.send(to=to, subject="Email Code", html=html_content) | ||||
| end_at = time.perf_counter() | end_at = time.perf_counter() |
| 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)) |
| from configs import dify_config | from configs import dify_config | ||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| try: | try: | ||||
| url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}" | url = f"{dify_config.CONSOLE_WEB_URL}/activate?token={token}" | ||||
| if language == "zh-Hans": | if language == "zh-Hans": | ||||
| html_content = render_template( | |||||
| "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: | 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() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| from flask import render_template | from flask import render_template | ||||
| from extensions.ext_mail import mail | from extensions.ext_mail import mail | ||||
| from services.feature_service import FeatureService | |||||
| @shared_task(queue="mail") | @shared_task(queue="mail") | ||||
| # send reset password mail using different languages | # send reset password mail using different languages | ||||
| try: | try: | ||||
| if language == "zh-Hans": | if language == "zh-Hans": | ||||
| html_content = render_template("reset_password_mail_template_zh-CN.html", to=to, code=code) | |||||
| 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: | 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() | end_at = time.perf_counter() | ||||
| logging.info( | logging.info( |
| <!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> |
| <!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> |
| <!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> |
| <!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> |
| <!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> |
| <!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> |
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useShallow } from 'zustand/react/shallow' | import { useShallow } from 'zustand/react/shallow' | ||||
| import { useContextSelector } from 'use-context-selector' | |||||
| import s from './style.module.css' | import s from './style.module.css' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { useStore } from '@/app/components/app/store' | import { useStore } from '@/app/components/app/store' | ||||
| import AppSideBar from '@/app/components/app-sidebar' | import AppSideBar from '@/app/components/app-sidebar' | ||||
| import type { NavIcon } from '@/app/components/app-sidebar/navLink' | import type { NavIcon } from '@/app/components/app-sidebar/navLink' | ||||
| import { fetchAppDetail, fetchAppSSO } from '@/service/apps' | |||||
| import 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 Loading from '@/app/components/base/loading' | ||||
| import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | ||||
| import type { App } from '@/types/app' | import type { App } from '@/types/app' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| export type IAppDetailLayoutProps = { | export type IAppDetailLayoutProps = { | ||||
| children: React.ReactNode | children: React.ReactNode | ||||
| icon: NavIcon | icon: NavIcon | ||||
| selectedIcon: NavIcon | selectedIcon: NavIcon | ||||
| }>>([]) | }>>([]) | ||||
| const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures) | |||||
| const getNavigations = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => { | const getNavigations = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: string) => { | ||||
| const navs = [ | const navs = [ | ||||
| return navs | return navs | ||||
| }, []) | }, []) | ||||
| useDocumentTitle(appDetail?.name || t('common.menus.appDetail')) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (appDetail) { | if (appDetail) { | ||||
| document.title = `${(appDetail.name || 'App')} - Dify` | |||||
| const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' | const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand' | ||||
| const mode = isMobile ? 'collapse' : 'expand' | const mode = isMobile ? 'collapse' : 'expand' | ||||
| setAppSiderbarExpand(isMobile ? mode : localeMode) | setAppSiderbarExpand(isMobile ? mode : localeMode) | ||||
| else { | else { | ||||
| setAppDetail({ ...res, enable_sso: false }) | setAppDetail({ ...res, enable_sso: false }) | ||||
| setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode)) | setNavigation(getNavigations(appId, isCurrentWorkspaceEditor, res.mode)) | ||||
| if (systemFeatures.enable_web_sso_switch_component && canIEditApp) { | |||||
| fetchAppSSO({ appId }).then((ssoRes) => { | |||||
| setAppDetail({ ...res, enable_sso: ssoRes.enabled }) | |||||
| }) | |||||
| } | |||||
| } | } | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
| }, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace, systemFeatures.enable_web_sso_switch_component]) | |||||
| }, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace]) | |||||
| useUnmount(() => { | useUnmount(() => { | ||||
| setAppDetail() | setAppDetail() |
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React from 'react' | import React from 'react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useContext, useContextSelector } from 'use-context-selector' | |||||
| import { useContext } from 'use-context-selector' | |||||
| import AppCard from '@/app/components/app/overview/appCard' | import AppCard from '@/app/components/app/overview/appCard' | ||||
| import Loading from '@/app/components/base/loading' | import Loading from '@/app/components/base/loading' | ||||
| import { ToastContext } from '@/app/components/base/toast' | import { ToastContext } from '@/app/components/base/toast' | ||||
| import { | import { | ||||
| fetchAppDetail, | fetchAppDetail, | ||||
| fetchAppSSO, | |||||
| updateAppSSO, | |||||
| updateAppSiteAccessToken, | updateAppSiteAccessToken, | ||||
| updateAppSiteConfig, | updateAppSiteConfig, | ||||
| updateAppSiteStatus, | updateAppSiteStatus, | ||||
| } from '@/service/apps' | } from '@/service/apps' | ||||
| import type { App, AppSSO } from '@/types/app' | |||||
| import type { App } from '@/types/app' | |||||
| import type { UpdateAppSiteCodeResponse } from '@/models/app' | import type { UpdateAppSiteCodeResponse } from '@/models/app' | ||||
| import { asyncRunSafe } from '@/utils' | import { asyncRunSafe } from '@/utils' | ||||
| import { NEED_REFRESH_APP_LIST_KEY } from '@/config' | import { NEED_REFRESH_APP_LIST_KEY } from '@/config' | ||||
| import type { IAppCardProps } from '@/app/components/app/overview/appCard' | import type { IAppCardProps } from '@/app/components/app/overview/appCard' | ||||
| import { useStore as useAppStore } from '@/app/components/app/store' | import { useStore as useAppStore } from '@/app/components/app/store' | ||||
| import AppContext from '@/context/app-context' | |||||
| export type ICardViewProps = { | export type ICardViewProps = { | ||||
| appId: string | appId: string | ||||
| const { notify } = useContext(ToastContext) | const { notify } = useContext(ToastContext) | ||||
| const appDetail = useAppStore(state => state.appDetail) | const appDetail = useAppStore(state => state.appDetail) | ||||
| const setAppDetail = useAppStore(state => state.setAppDetail) | const setAppDetail = useAppStore(state => state.setAppDetail) | ||||
| const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures) | |||||
| const updateAppDetail = async () => { | const updateAppDetail = async () => { | ||||
| try { | try { | ||||
| const res = await fetchAppDetail({ url: '/apps', id: appId }) | const res = await fetchAppDetail({ url: '/apps', id: appId }) | ||||
| if (systemFeatures.enable_web_sso_switch_component) { | |||||
| const ssoRes = await fetchAppSSO({ appId }) | |||||
| setAppDetail({ ...res, enable_sso: ssoRes.enabled }) | |||||
| } | |||||
| else { | |||||
| setAppDetail({ ...res }) | |||||
| } | |||||
| setAppDetail({ ...res }) | |||||
| } | } | ||||
| catch (error) { console.error(error) } | catch (error) { console.error(error) } | ||||
| } | } | ||||
| if (!err) | if (!err) | ||||
| localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') | localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') | ||||
| if (systemFeatures.enable_web_sso_switch_component) { | |||||
| const [sso_err] = await asyncRunSafe<AppSSO>( | |||||
| updateAppSSO({ id: appId, enabled: Boolean(params.enable_sso) }) as Promise<AppSSO>, | |||||
| ) | |||||
| if (sso_err) { | |||||
| handleCallbackResult(sso_err) | |||||
| return | |||||
| } | |||||
| } | |||||
| handleCallbackResult(err) | handleCallbackResult(err) | ||||
| } | } | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React, { useEffect } from 'react' | import React, { useEffect } from 'react' | ||||
| import { useRouter } from 'next/navigation' | import { useRouter } from 'next/navigation' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| export type IAppDetail = { | export type IAppDetail = { | ||||
| children: React.ReactNode | children: React.ReactNode | ||||
| const AppDetail: FC<IAppDetail> = ({ children }) => { | const AppDetail: FC<IAppDetail> = ({ children }) => { | ||||
| const router = useRouter() | const router = useRouter() | ||||
| const { isCurrentWorkspaceDatasetOperator } = useAppContext() | const { isCurrentWorkspaceDatasetOperator } = useAppContext() | ||||
| const { t } = useTranslation() | |||||
| useDocumentTitle(t('common.menus.appDetail')) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (isCurrentWorkspaceDatasetOperator) | if (isCurrentWorkspaceDatasetOperator) | ||||
| return router.replace('/datasets') | return router.replace('/datasets') | ||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [isCurrentWorkspaceDatasetOperator]) | |||||
| }, [isCurrentWorkspaceDatasetOperator, router]) | |||||
| return ( | return ( | ||||
| <> | <> |
| import { useRouter } from 'next/navigation' | import { useRouter } from 'next/navigation' | ||||
| import { useCallback, useEffect, useState } from 'react' | import { useCallback, useEffect, useState } from 'react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { RiMoreFill } from '@remixicon/react' | |||||
| import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill } from '@remixicon/react' | |||||
| import cn from '@/utils/classnames' | |||||
| import type { App } from '@/types/app' | import type { App } from '@/types/app' | ||||
| import Confirm from '@/app/components/base/confirm' | import Confirm from '@/app/components/base/confirm' | ||||
| import Toast, { ToastContext } from '@/app/components/base/toast' | import Toast, { ToastContext } from '@/app/components/base/toast' | ||||
| import { fetchWorkflowDraft } from '@/service/workflow' | import { fetchWorkflowDraft } from '@/service/workflow' | ||||
| import { fetchInstalledAppList } from '@/service/explore' | import { fetchInstalledAppList } from '@/service/explore' | ||||
| import { AppTypeIcon } from '@/app/components/app/type-selector' | import { AppTypeIcon } from '@/app/components/app/type-selector' | ||||
| import cn from '@/utils/classnames' | |||||
| import Tooltip from '@/app/components/base/tooltip' | |||||
| import AccessControl from '@/app/components/app/app-access-control' | |||||
| import { AccessMode } from '@/models/access-control' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| export type AppCardProps = { | export type AppCardProps = { | ||||
| app: App | app: App | ||||
| const AppCard = ({ app, onRefresh }: AppCardProps) => { | const AppCard = ({ app, onRefresh }: AppCardProps) => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { notify } = useContext(ToastContext) | const { notify } = useContext(ToastContext) | ||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const { isCurrentWorkspaceEditor } = useAppContext() | const { isCurrentWorkspaceEditor } = useAppContext() | ||||
| const { onPlanInfoChanged } = useProviderContext() | const { onPlanInfoChanged } = useProviderContext() | ||||
| const { push } = useRouter() | const { push } = useRouter() | ||||
| const [showDuplicateModal, setShowDuplicateModal] = useState(false) | const [showDuplicateModal, setShowDuplicateModal] = useState(false) | ||||
| const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false) | const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false) | ||||
| const [showConfirmDelete, setShowConfirmDelete] = useState(false) | const [showConfirmDelete, setShowConfirmDelete] = useState(false) | ||||
| const [showAccessControl, setShowAccessControl] = useState(false) | |||||
| const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) | const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([]) | ||||
| const onConfirmDelete = useCallback(async () => { | const onConfirmDelete = useCallback(async () => { | ||||
| }) | }) | ||||
| } | } | ||||
| setShowConfirmDelete(false) | 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 ({ | const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ | ||||
| name, | name, | ||||
| setShowSwitchModal(false) | setShowSwitchModal(false) | ||||
| } | } | ||||
| const onUpdateAccessControl = useCallback(() => { | |||||
| if (onRefresh) | |||||
| onRefresh() | |||||
| mutateApps() | |||||
| setShowAccessControl(false) | |||||
| }, [onRefresh, mutateApps, setShowAccessControl]) | |||||
| const Operations = (props: HtmlContentProps) => { | const Operations = (props: HtmlContentProps) => { | ||||
| const onMouseLeave = async () => { | const onMouseLeave = async () => { | ||||
| props.onClose?.() | props.onClose?.() | ||||
| e.preventDefault() | e.preventDefault() | ||||
| exportCheck() | exportCheck() | ||||
| } | } | ||||
| const onClickSwitch = async (e: React.MouseEvent<HTMLDivElement>) => { | |||||
| const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => { | |||||
| e.stopPropagation() | e.stopPropagation() | ||||
| props.onClick?.() | props.onClick?.() | ||||
| e.preventDefault() | e.preventDefault() | ||||
| setShowSwitchModal(true) | setShowSwitchModal(true) | ||||
| } | } | ||||
| const onClickDelete = async (e: React.MouseEvent<HTMLDivElement>) => { | |||||
| const onClickDelete = async (e: React.MouseEvent<HTMLButtonElement>) => { | |||||
| e.stopPropagation() | e.stopPropagation() | ||||
| props.onClick?.() | props.onClick?.() | ||||
| e.preventDefault() | e.preventDefault() | ||||
| setShowConfirmDelete(true) | setShowConfirmDelete(true) | ||||
| } | } | ||||
| const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => { | |||||
| e.stopPropagation() | |||||
| props.onClick?.() | |||||
| e.preventDefault() | |||||
| setShowAccessControl(true) | |||||
| } | |||||
| const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => { | const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => { | ||||
| e.stopPropagation() | e.stopPropagation() | ||||
| props.onClick?.() | props.onClick?.() | ||||
| } | } | ||||
| } | } | ||||
| return ( | 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> | <span className='system-sm-regular text-text-secondary'>{t('app.editApp')}</span> | ||||
| </button> | </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> | <span className='system-sm-regular text-text-secondary'>{t('app.duplicate')}</span> | ||||
| </button> | </button> | ||||
| <button className='mx-1 flex h-8 w-[calc(100%_-_8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover' onClick={onClickExport}> | |||||
| <button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickExport}> | |||||
| <span className='system-sm-regular text-text-secondary'>{t('app.export')}</span> | <span className='system-sm-regular text-text-secondary'>{t('app.export')}</span> | ||||
| </button> | </button> | ||||
| {(app.mode === 'completion' || app.mode === 'chat') && ( | {(app.mode === 'completion' || app.mode === 'chat') && ( | ||||
| <> | <> | ||||
| <Divider className="!my-1" /> | |||||
| <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} | onClick={onClickSwitch} | ||||
| > | > | ||||
| <span className='text-sm leading-5 text-text-secondary'>{t('app.switch')}</span> | <span className='text-sm leading-5 text-text-secondary'>{t('app.switch')}</span> | ||||
| </div> | |||||
| </button> | |||||
| </> | </> | ||||
| )} | )} | ||||
| <Divider className="!my-1" /> | |||||
| <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> | <span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span> | ||||
| </button> | </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} | onClick={onClickDelete} | ||||
| > | > | ||||
| <span className='system-sm-regular text-text-secondary group-hover:text-text-destructive'> | <span className='system-sm-regular text-text-secondary group-hover:text-text-destructive'> | ||||
| {t('common.operation.delete')} | {t('common.operation.delete')} | ||||
| </span> | </span> | ||||
| </div> | |||||
| </button> | |||||
| </div> | </div> | ||||
| ) | ) | ||||
| } | } | ||||
| {app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>} | {app.mode === 'completion' && <div className='truncate'>{t('app.types.completion').toUpperCase()}</div>} | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div className='flex h-5 w-5 shrink-0 items-center justify-center'> | |||||
| {app.access_mode === AccessMode.PUBLIC && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.anyone')}> | |||||
| <RiGlobalLine className='h-4 w-4 text-text-accent' /> | |||||
| </Tooltip>} | |||||
| {app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.specific')}> | |||||
| <RiLockLine className='h-4 w-4 text-text-quaternary' /> | |||||
| </Tooltip>} | |||||
| {app.access_mode === AccessMode.ORGANIZATION && <Tooltip asChild={false} popupContent={t('app.accessItemsDescription.organization')}> | |||||
| <RiBuildingLine className='h-4 w-4 text-text-quaternary' /> | |||||
| </Tooltip>} | |||||
| </div> | |||||
| </div> | </div> | ||||
| <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> | <div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'> | ||||
| <div | <div | ||||
| popupClassName={ | popupClassName={ | ||||
| (app.mode === 'completion' || app.mode === 'chat') | (app.mode === 'completion' || app.mode === 'chat') | ||||
| ? '!w-[256px] translate-x-[-224px]' | ? '!w-[256px] translate-x-[-224px]' | ||||
| : '!w-[160px] translate-x-[-128px]' | |||||
| : '!w-[216px] translate-x-[-128px]' | |||||
| } | } | ||||
| className={'!z-20 h-fit'} | className={'!z-20 h-fit'} | ||||
| /> | /> | ||||
| onClose={() => setSecretEnvList([])} | onClose={() => setSecretEnvList([])} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {showAccessControl && ( | |||||
| <AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} /> | |||||
| )} | |||||
| </> | </> | ||||
| ) | ) | ||||
| } | } |
| ] | ] | ||||
| useEffect(() => { | useEffect(() => { | ||||
| document.title = `${t('common.menus.apps')} - Dify` | |||||
| if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { | if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { | ||||
| localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) | localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) | ||||
| mutate() | mutate() |
| '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} | |||||
| </>) | |||||
| } |
| 'use client' | 'use client' | ||||
| import { useContextSelector } from 'use-context-selector' | |||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { RiDiscordFill, RiGithubFill } from '@remixicon/react' | import { RiDiscordFill, RiGithubFill } from '@remixicon/react' | ||||
| import Link from 'next/link' | import Link from 'next/link' | ||||
| import style from '../list.module.css' | import style from '../list.module.css' | ||||
| import Apps from './Apps' | import Apps from './Apps' | ||||
| import AppContext from '@/context/app-context' | |||||
| import { LicenseStatus } from '@/types/feature' | |||||
| import { useEducationInit } from '@/app/education-apply/hooks' | import { useEducationInit } from '@/app/education-apply/hooks' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const AppList = () => { | const AppList = () => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| useEducationInit() | useEducationInit() | ||||
| const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures) | |||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| return ( | return ( | ||||
| <div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> | <div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> | ||||
| <Apps /> | <Apps /> | ||||
| {systemFeatures.license.status === LicenseStatus.NONE && <footer className='shrink-0 grow-0 px-12 py-6'> | |||||
| {!systemFeatures.branding.enabled && <footer className='shrink-0 grow-0 px-12 py-6'> | |||||
| <h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3> | <h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3> | ||||
| <p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p> | <p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p> | ||||
| <div className='mt-3 flex items-center gap-2'> | <div className='mt-3 flex items-center gap-2'> |
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import Tooltip from '@/app/components/base/tooltip' | import Tooltip from '@/app/components/base/tooltip' | ||||
| import LinkedAppsPanel from '@/app/components/base/linked-apps-panel' | import LinkedAppsPanel from '@/app/components/base/linked-apps-panel' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| export type IAppDetailLayoutProps = { | export type IAppDetailLayoutProps = { | ||||
| children: React.ReactNode | children: React.ReactNode | ||||
| return baseNavigation | return baseNavigation | ||||
| }, [datasetRes?.provider, datasetId, t]) | }, [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) | const setAppSiderbarExpand = useStore(state => state.setAppSiderbarExpand) | ||||
| import { useStore as useTagStore } from '@/app/components/base/tag-management/store' | import { useStore as useTagStore } from '@/app/components/base/tag-management/store' | ||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import { useExternalApiPanel } from '@/context/external-api-panel-context' | import { useExternalApiPanel } from '@/context/external-api-panel-context' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const Container = () => { | const Container = () => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| const router = useRouter() | const router = useRouter() | ||||
| const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext() | const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext() | ||||
| const showTagManagementModal = useTagStore(s => s.showTagManagementModal) | const showTagManagementModal = useTagStore(s => s.showTagManagementModal) | ||||
| const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() | const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() | ||||
| const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false) | const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false) | ||||
| document.title = `${t('dataset.knowledge')} - Dify` | |||||
| useDocumentTitle(t('dataset.knowledge')) | |||||
| const options = useMemo(() => { | const options = useMemo(() => { | ||||
| return [ | return [ | ||||
| {activeTab === 'dataset' && ( | {activeTab === 'dataset' && ( | ||||
| <> | <> | ||||
| <Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} /> | <Datasets containerRef={containerRef} tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} /> | ||||
| <DatasetFooter /> | |||||
| {!systemFeatures.branding.enabled && <DatasetFooter />} | |||||
| {showTagManagementModal && ( | {showTagManagementModal && ( | ||||
| <TagManagementModal type='knowledge' show={showTagManagementModal} /> | <TagManagementModal type='knowledge' show={showTagManagementModal} /> | ||||
| )} | )} |
| import { useCallback, useEffect, useRef } from 'react' | import { useCallback, useEffect, useRef } from 'react' | ||||
| import useSWRInfinite from 'swr/infinite' | import useSWRInfinite from 'swr/infinite' | ||||
| import { debounce } from 'lodash-es' | import { debounce } from 'lodash-es' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import NewDatasetCard from './NewDatasetCard' | import NewDatasetCard from './NewDatasetCard' | ||||
| import DatasetCard from './DatasetCard' | import DatasetCard from './DatasetCard' | ||||
| import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets' | import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets' | ||||
| import { fetchDatasets } from '@/service/datasets' | import { fetchDatasets } from '@/service/datasets' | ||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| const getKey = ( | const getKey = ( | ||||
| pageIndex: number, | pageIndex: number, | ||||
| keywords, | keywords, | ||||
| includeAll, | includeAll, | ||||
| }: Props) => { | }: Props) => { | ||||
| const { t } = useTranslation() | |||||
| const { isCurrentWorkspaceEditor } = useAppContext() | const { isCurrentWorkspaceEditor } = useAppContext() | ||||
| const { data, isLoading, setSize, mutate } = useSWRInfinite( | const { data, isLoading, setSize, mutate } = useSWRInfinite( | ||||
| (pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll), | (pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll), | ||||
| const loadingStateRef = useRef(false) | const loadingStateRef = useRef(false) | ||||
| const anchorRef = useRef<HTMLAnchorElement>(null) | const anchorRef = useRef<HTMLAnchorElement>(null) | ||||
| const { t } = useTranslation() | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadingStateRef.current = isLoading | loadingStateRef.current = isLoading | ||||
| document.title = `${t('dataset.knowledge')} - Dify` | |||||
| }, [isLoading, t]) | }, [isLoading, t]) | ||||
| const onScroll = useCallback( | const onScroll = useCallback( | ||||
| return ( | return ( | ||||
| <nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'> | <nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'> | ||||
| { isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} /> } | |||||
| {isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} />} | |||||
| {data?.map(({ data: datasets }) => datasets.map(dataset => ( | {data?.map(({ data: datasets }) => datasets.map(dataset => ( | ||||
| <DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />), | <DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />), | ||||
| ))} | ))} |
| 'use client' | |||||
| import { useTranslation } from 'react-i18next' | |||||
| import Container from './Container' | import Container from './Container' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const AppList = async () => { | |||||
| const AppList = () => { | |||||
| const { t } = useTranslation() | |||||
| useDocumentTitle(t('common.menus.datasets')) | |||||
| return <Container /> | return <Container /> | ||||
| } | } | ||||
| <div> | <div> | ||||
| ### Authentication | ### Authentication | ||||
| Service API of Dify authenticates using an `API-Key`. | |||||
| Service API authenticates using an `API-Key`. | |||||
| It is suggested that developers store the `API-Key` in the backend instead of sharing or storing it in the client side to avoid the leakage of the `API-Key`, which may lead to property loss. | It is suggested that developers store the `API-Key` in the backend instead of sharing or storing it in the client side to avoid the leakage of the `API-Key`, which may lead to property loss. | ||||
| <div> | <div> | ||||
| ### 鉴权 | ### 鉴权 | ||||
| Dify Service API 使用 `API-Key` 进行鉴权。 | |||||
| Service API 使用 `API-Key` 进行鉴权。 | |||||
| 建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。 | 建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。 | ||||
| import type { FC } from 'react' | |||||
| 'use client' | |||||
| import type { FC, PropsWithChildren } from 'react' | |||||
| import React from 'react' | import React from 'react' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import ExploreClient from '@/app/components/explore' | import ExploreClient from '@/app/components/explore' | ||||
| export type IAppDetail = { | |||||
| 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 ( | return ( | ||||
| <ExploreClient> | <ExploreClient> | ||||
| {children} | {children} | ||||
| ) | ) | ||||
| } | } | ||||
| export default React.memo(AppDetail) | |||||
| export default React.memo(ExploreLayout) |
| </> | </> | ||||
| ) | ) | ||||
| } | } | ||||
| export const metadata = { | |||||
| title: 'Dify', | |||||
| } | |||||
| export default Layout | export default Layout |
| 'use client' | 'use client' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import { useRouter } from 'next/navigation' | import { useRouter } from 'next/navigation' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import React, { useEffect } from 'react' | import React, { useEffect } from 'react' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import ToolProviderList from '@/app/components/tools/provider-list' | import ToolProviderList from '@/app/components/tools/provider-list' | ||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| const Layout: FC = () => { | |||||
| const { t } = useTranslation() | |||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const ToolsList: FC = () => { | |||||
| const router = useRouter() | const router = useRouter() | ||||
| const { isCurrentWorkspaceDatasetOperator } = useAppContext() | 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(() => { | useEffect(() => { | ||||
| if (isCurrentWorkspaceDatasetOperator) | if (isCurrentWorkspaceDatasetOperator) | ||||
| return <ToolProviderList /> | return <ToolProviderList /> | ||||
| } | } | ||||
| export default React.memo(Layout) | |||||
| export default React.memo(ToolsList) |
| 'use client' | 'use client' | ||||
| import { useRouter, useSearchParams } from 'next/navigation' | import { useRouter, useSearchParams } from 'next/navigation' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React, { useEffect } from 'react' | |||||
| import React, { useCallback, useEffect } from 'react' | |||||
| import { useTranslation } from 'react-i18next' | |||||
| import { RiDoorLockLine } from '@remixicon/react' | |||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import Toast from '@/app/components/base/toast' | import Toast from '@/app/components/base/toast' | ||||
| import { fetchSystemFeatures, fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' | |||||
| import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' | |||||
| import { setAccessToken } from '@/app/components/share/utils' | import { setAccessToken } from '@/app/components/share/utils' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| import { SSOProtocol } from '@/types/feature' | |||||
| import Loading from '@/app/components/base/loading' | import Loading from '@/app/components/base/loading' | ||||
| import AppUnavailable from '@/app/components/base/app-unavailable' | |||||
| const WebSSOForm: FC = () => { | const WebSSOForm: FC = () => { | ||||
| const { t } = useTranslation() | |||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const searchParams = useSearchParams() | const searchParams = useSearchParams() | ||||
| const router = useRouter() | const router = useRouter() | ||||
| }) | }) | ||||
| } | } | ||||
| const getAppCodeFromRedirectUrl = () => { | |||||
| const getAppCodeFromRedirectUrl = useCallback(() => { | |||||
| const appCode = redirectUrl?.split('/').pop() | const appCode = redirectUrl?.split('/').pop() | ||||
| if (!appCode) | if (!appCode) | ||||
| return null | return null | ||||
| return appCode | return appCode | ||||
| } | |||||
| }, [redirectUrl]) | |||||
| const processTokenAndRedirect = async () => { | |||||
| const processTokenAndRedirect = useCallback(async () => { | |||||
| const appCode = getAppCodeFromRedirectUrl() | const appCode = getAppCodeFromRedirectUrl() | ||||
| if (!appCode || !tokenFromUrl || !redirectUrl) { | if (!appCode || !tokenFromUrl || !redirectUrl) { | ||||
| showErrorToast('redirect url or app code or token is invalid.') | showErrorToast('redirect url or app code or token is invalid.') | ||||
| await setAccessToken(appCode, tokenFromUrl) | await setAccessToken(appCode, tokenFromUrl) | ||||
| router.push(redirectUrl) | router.push(redirectUrl) | ||||
| } | |||||
| }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl]) | |||||
| const handleSSOLogin = async (protocol: string) => { | |||||
| const handleSSOLogin = useCallback(async () => { | |||||
| const appCode = getAppCodeFromRedirectUrl() | const appCode = getAppCodeFromRedirectUrl() | ||||
| if (!appCode || !redirectUrl) { | if (!appCode || !redirectUrl) { | ||||
| showErrorToast('redirect url or app code is invalid.') | showErrorToast('redirect url or app code is invalid.') | ||||
| return | return | ||||
| } | } | ||||
| switch (protocol) { | |||||
| case 'saml': { | |||||
| switch (systemFeatures.webapp_auth.sso_config.protocol) { | |||||
| case SSOProtocol.SAML: { | |||||
| const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) | const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl) | ||||
| router.push(samlRes.url) | router.push(samlRes.url) | ||||
| break | break | ||||
| } | } | ||||
| case 'oidc': { | |||||
| case SSOProtocol.OIDC: { | |||||
| const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) | const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl) | ||||
| router.push(oidcRes.url) | router.push(oidcRes.url) | ||||
| break | break | ||||
| } | } | ||||
| case 'oauth2': { | |||||
| case SSOProtocol.OAuth2: { | |||||
| const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) | const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl) | ||||
| router.push(oauth2Res.url) | router.push(oauth2Res.url) | ||||
| break | break | ||||
| } | } | ||||
| case '': | |||||
| break | |||||
| default: | default: | ||||
| showErrorToast('SSO protocol is not supported.') | showErrorToast('SSO protocol is not supported.') | ||||
| } | } | ||||
| } | |||||
| }, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol]) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const init = async () => { | const init = async () => { | ||||
| const res = await fetchSystemFeatures() | |||||
| const protocol = res.sso_enforced_for_web_protocol | |||||
| if (message) { | if (message) { | ||||
| showErrorToast(message) | showErrorToast(message) | ||||
| return | return | ||||
| } | } | ||||
| if (!tokenFromUrl) { | if (!tokenFromUrl) { | ||||
| await handleSSOLogin(protocol) | |||||
| await handleSSOLogin() | |||||
| return | return | ||||
| } | } | ||||
| } | } | ||||
| init() | init() | ||||
| }, [message, tokenFromUrl]) // Added dependencies to useEffect | |||||
| }, [message, processTokenAndRedirect, tokenFromUrl, handleSSOLogin]) | |||||
| if (tokenFromUrl) | |||||
| return <div className='flex h-full items-center justify-center'><Loading /></div> | |||||
| if (message) { | |||||
| return <div className='flex h-full items-center justify-center'> | |||||
| <AppUnavailable code={'App Unavailable'} unknownReason={message} /> | |||||
| </div> | |||||
| } | |||||
| return ( | |||||
| <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> | ||||
| </div> | </div> | ||||
| ) | |||||
| } | |||||
| else { | |||||
| return <div className="flex h-full items-center justify-center"> | |||||
| <p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p> | |||||
| </div> | |||||
| } | |||||
| } | } | ||||
| export default React.memo(WebSSOForm) | export default React.memo(WebSSOForm) |
| import { IS_CE_EDITION } from '@/config' | import { IS_CE_EDITION } from '@/config' | ||||
| import Input from '@/app/components/base/input' | import Input from '@/app/components/base/input' | ||||
| import PremiumBadge from '@/app/components/base/premium-badge' | import PremiumBadge from '@/app/components/base/premium-badge' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const titleClassName = ` | const titleClassName = ` | ||||
| system-sm-semibold text-text-secondary | system-sm-semibold text-text-secondary | ||||
| export default function AccountPage() { | export default function AccountPage() { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { systemFeatures } = useAppContext() | |||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| const { mutateUserProfile, userProfile, apps } = useAppContext() | const { mutateUserProfile, userProfile, apps } = useAppContext() | ||||
| const { isEducationAccount } = useProviderContext() | const { isEducationAccount } = useProviderContext() | ||||
| const { notify } = useContext(ToastContext) | const { notify } = useContext(ToastContext) | ||||
| <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> | <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> | ||||
| </div> | </div> | ||||
| <div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'> | <div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'> | ||||
| <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} /> | |||||
| <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} /> | |||||
| <div className='ml-4'> | <div className='ml-4'> | ||||
| <p className='system-xl-semibold text-text-primary'> | <p className='system-xl-semibold text-text-primary'> | ||||
| {userProfile.name} | {userProfile.name} |
| </> | </> | ||||
| ) | ) | ||||
| } | } | ||||
| export const metadata = { | |||||
| title: 'Dify', | |||||
| } | |||||
| export default Layout | export default Layout |
| 'use client' | |||||
| import { useTranslation } from 'react-i18next' | |||||
| import AccountPage from './account-page' | import AccountPage from './account-page' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| export default function Account() { | export default function Account() { | ||||
| const { t } = useTranslation() | |||||
| useDocumentTitle(t('common.menus.account')) | |||||
| return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'> | return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'> | ||||
| <AccountPage /> | <AccountPage /> | ||||
| </div> | </div> |
| import { invitationCheck } from '@/service/common' | import { invitationCheck } from '@/service/common' | ||||
| import Loading from '@/app/components/base/loading' | import Loading from '@/app/components/base/loading' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const ActivateForm = () => { | const ActivateForm = () => { | ||||
| useDocumentTitle('') | |||||
| const router = useRouter() | const router = useRouter() | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const searchParams = useSearchParams() | const searchParams = useSearchParams() |
| 'use client' | |||||
| import React from 'react' | import React from 'react' | ||||
| import Header from '../signin/_header' | import Header from '../signin/_header' | ||||
| import ActivateForm from './activateForm' | import ActivateForm from './activateForm' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const Activate = () => { | const Activate = () => { | ||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| return ( | return ( | ||||
| <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> | <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> | ||||
| <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> | <div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}> | ||||
| <Header /> | <Header /> | ||||
| <ActivateForm /> | <ActivateForm /> | ||||
| <div className='px-8 py-6 text-sm font-normal text-text-tertiary'> | |||||
| {!systemFeatures.branding.enabled && <div className='px-8 py-6 text-sm font-normal text-text-tertiary'> | |||||
| © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. | © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. | ||||
| </div> | |||||
| </div>} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| ) | ) |
| import ContentDialog from '@/app/components/base/content-dialog' | import ContentDialog from '@/app/components/base/content-dialog' | ||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView' | import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView' | ||||
| import Divider from '../base/divider' | |||||
| import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem' | import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem' | ||||
| export type IAppInfoProps = { | export type IAppInfoProps = { | ||||
| onClick={() => { | onClick={() => { | ||||
| setOpen(false) | setOpen(false) | ||||
| setShowDuplicateModal(true) | setShowDuplicateModal(true) | ||||
| }} | |||||
| > | |||||
| }}> | |||||
| <RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' /> | <RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' /> | ||||
| <span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span> | <span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span> | ||||
| </Button> | </Button> | ||||
| className='flex grow flex-col gap-2 overflow-auto px-2 py-1' | className='flex grow flex-col gap-2 overflow-auto px-2 py-1' | ||||
| /> | /> | ||||
| </div> | </div> | ||||
| <Divider /> | |||||
| <div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'> | <div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'> | ||||
| <Button | <Button | ||||
| size={'medium'} | size={'medium'} |
| desc: string | desc: string | ||||
| isExternal?: boolean | isExternal?: boolean | ||||
| icon: string | icon: string | ||||
| icon_background: string | |||||
| icon_background: string | null | |||||
| navigation: Array<{ | navigation: Array<{ | ||||
| name: string | name: string | ||||
| href: string | href: string |
| 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 |
| '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 |
| '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> | |||||
| } |
| '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> | |||||
| } |
| '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> | |||||
| } |
| import { | import { | ||||
| memo, | memo, | ||||
| useCallback, | useCallback, | ||||
| useEffect, | |||||
| useState, | useState, | ||||
| } from 'react' | } from 'react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import dayjs from 'dayjs' | import dayjs from 'dayjs' | ||||
| import { | import { | ||||
| RiArrowDownSLine, | RiArrowDownSLine, | ||||
| RiArrowRightSLine, | |||||
| RiLockLine, | |||||
| RiPlanetLine, | RiPlanetLine, | ||||
| RiPlayCircleLine, | RiPlayCircleLine, | ||||
| RiPlayList2Line, | RiPlayList2Line, | ||||
| RiTerminalBoxLine, | RiTerminalBoxLine, | ||||
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import { useKeyPress } from 'ahooks' | import { useKeyPress } from 'ahooks' | ||||
| import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' | |||||
| import Toast from '../../base/toast' | import Toast from '../../base/toast' | ||||
| import type { ModelAndParameter } from '../configuration/debug/types' | import type { ModelAndParameter } from '../configuration/debug/types' | ||||
| import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' | |||||
| import Divider from '../../base/divider' | |||||
| import AccessControl from '../app-access-control' | |||||
| import Loading from '../../base/loading' | |||||
| import Tooltip from '../../base/tooltip' | |||||
| import SuggestedAction from './suggested-action' | import SuggestedAction from './suggested-action' | ||||
| import PublishWithMultipleModel from './publish-with-multiple-model' | import PublishWithMultipleModel from './publish-with-multiple-model' | ||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import type { InputVar } from '@/app/components/workflow/types' | import type { InputVar } from '@/app/components/workflow/types' | ||||
| import { appDefaultIconBackground } from '@/config' | import { appDefaultIconBackground } from '@/config' | ||||
| import type { PublishWorkflowParams } from '@/types/workflow' | import type { PublishWorkflowParams } from '@/types/workflow' | ||||
| import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' | |||||
| import { AccessMode } from '@/models/access-control' | |||||
| import { fetchAppDetail } from '@/service/apps' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| export type AppPublisherProps = { | export type AppPublisherProps = { | ||||
| disabled?: boolean | disabled?: boolean | ||||
| const [published, setPublished] = useState(false) | const [published, setPublished] = useState(false) | ||||
| const [open, setOpen] = useState(false) | const [open, setOpen] = useState(false) | ||||
| const appDetail = useAppStore(state => state.appDetail) | const appDetail = useAppStore(state => state.appDetail) | ||||
| const setAppDetail = useAppStore(s => s.setAppDetail) | |||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} | const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} | ||||
| const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode | const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode | ||||
| const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}` | const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}` | ||||
| const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '') | const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '') | ||||
| const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) | |||||
| const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) | |||||
| useEffect(() => { | |||||
| if (systemFeatures.webapp_auth.enabled && open && appDetail) | |||||
| refetch() | |||||
| }, [open, appDetail, refetch, systemFeatures]) | |||||
| const [showAppAccessControl, setShowAppAccessControl] = useState(false) | |||||
| const [isAppAccessSet, setIsAppAccessSet] = useState(true) | |||||
| useEffect(() => { | |||||
| if (appDetail && appAccessSubjects) { | |||||
| if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) | |||||
| setIsAppAccessSet(false) | |||||
| else | |||||
| setIsAppAccessSet(true) | |||||
| } | |||||
| else { | |||||
| setIsAppAccessSet(true) | |||||
| } | |||||
| }, [appAccessSubjects, appDetail]) | |||||
| const language = useGetLanguage() | const language = useGetLanguage() | ||||
| const formatTimeFromNow = useCallback((time: number) => { | const formatTimeFromNow = useCallback((time: number) => { | ||||
| return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow() | return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow() | ||||
| await onRestore?.() | await onRestore?.() | ||||
| setOpen(false) | setOpen(false) | ||||
| } | } | ||||
| catch {} | |||||
| catch { } | |||||
| }, [onRestore]) | }, [onRestore]) | ||||
| const handleTrigger = useCallback(() => { | const handleTrigger = useCallback(() => { | ||||
| } | } | ||||
| }, [appDetail?.id]) | }, [appDetail?.id]) | ||||
| const handleAccessControlUpdate = useCallback(() => { | |||||
| fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => { | |||||
| setAppDetail(res) | |||||
| setShowAppAccessControl(false) | |||||
| }) | |||||
| }, [appDetail, setAppDetail]) | |||||
| const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) | const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) | ||||
| useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { | useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { | ||||
| return | return | ||||
| handlePublish() | handlePublish() | ||||
| }, | }, | ||||
| { exactMatch: true, useCapture: true }) | |||||
| { exactMatch: true, useCapture: true }) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| ) | ) | ||||
| } | } | ||||
| </div> | </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={() => { | 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} | 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> | ||||
| )} | |||||
| <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> | </div> | ||||
| </PortalToFollowElemContent> | </PortalToFollowElemContent> | ||||
| <EmbeddedModal | <EmbeddedModal | ||||
| appBaseUrl={appBaseURL} | appBaseUrl={appBaseURL} | ||||
| accessToken={accessToken} | accessToken={accessToken} | ||||
| /> | /> | ||||
| {showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />} | |||||
| </PortalToFollowElem > | </PortalToFollowElem > | ||||
| </> | |||||
| ) | |||||
| </>) | |||||
| } | } | ||||
| export default memo(AppPublisher) | export default memo(AppPublisher) |
| disabled?: boolean | 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 | export default SuggestedAction |
| 'use client' | 'use client' | ||||
| import React, { useMemo, useState } from 'react' | |||||
| import React, { useCallback, useEffect, useMemo, useState } from 'react' | |||||
| import { usePathname, useRouter } from 'next/navigation' | import { usePathname, useRouter } from 'next/navigation' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { | import { | ||||
| RiArrowRightSLine, | |||||
| RiBookOpenLine, | RiBookOpenLine, | ||||
| RiEqualizer2Line, | RiEqualizer2Line, | ||||
| RiExternalLinkLine, | RiExternalLinkLine, | ||||
| RiLockLine, | |||||
| RiPaintBrushLine, | RiPaintBrushLine, | ||||
| RiWindowLine, | RiWindowLine, | ||||
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import AppBasic from '@/app/components/app-sidebar/basic' | import AppBasic from '@/app/components/app-sidebar/basic' | ||||
| import { asyncRunSafe, randomString } from '@/utils' | import { asyncRunSafe, randomString } from '@/utils' | ||||
| import { basePath } from '@/utils/var' | import { basePath } from '@/utils/var' | ||||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import Switch from '@/app/components/base/switch' | import Switch from '@/app/components/base/switch' | ||||
| import Divider from '@/app/components/base/divider' | import Divider from '@/app/components/base/divider' | ||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import type { AppSSO } from '@/types/app' | import type { AppSSO } from '@/types/app' | ||||
| import Indicator from '@/app/components/header/indicator' | import Indicator from '@/app/components/header/indicator' | ||||
| import { fetchAppDetail } from '@/service/apps' | |||||
| import { AccessMode } from '@/models/access-control' | |||||
| import AccessControl from '../app-access-control' | |||||
| import { useAppWhiteListSubjects } from '@/service/access-control' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| export type IAppCardProps = { | export type IAppCardProps = { | ||||
| className?: string | className?: string | ||||
| const router = useRouter() | const router = useRouter() | ||||
| const pathname = usePathname() | const pathname = usePathname() | ||||
| const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() | const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() | ||||
| const appDetail = useAppStore(state => state.appDetail) | |||||
| const setAppDetail = useAppStore(state => state.setAppDetail) | |||||
| const [showSettingsModal, setShowSettingsModal] = useState(false) | const [showSettingsModal, setShowSettingsModal] = useState(false) | ||||
| const [showEmbedded, setShowEmbedded] = useState(false) | const [showEmbedded, setShowEmbedded] = useState(false) | ||||
| const [showCustomizeModal, setShowCustomizeModal] = useState(false) | const [showCustomizeModal, setShowCustomizeModal] = useState(false) | ||||
| const [genLoading, setGenLoading] = useState(false) | const [genLoading, setGenLoading] = useState(false) | ||||
| const [showConfirmDelete, setShowConfirmDelete] = useState(false) | const [showConfirmDelete, setShowConfirmDelete] = useState(false) | ||||
| const [showAccessControl, setShowAccessControl] = useState<boolean>(false) | |||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const { data: appAccessSubjects } = useAppWhiteListSubjects(appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) | |||||
| const OPERATIONS_MAP = useMemo(() => { | const OPERATIONS_MAP = useMemo(() => { | ||||
| const operationsMap = { | const operationsMap = { | ||||
| } | } | ||||
| } | } | ||||
| const [isAppAccessSet, setIsAppAccessSet] = useState(true) | |||||
| useEffect(() => { | |||||
| if (appDetail && appAccessSubjects) { | |||||
| if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) | |||||
| setIsAppAccessSet(false) | |||||
| else | |||||
| setIsAppAccessSet(true) | |||||
| } | |||||
| else { | |||||
| setIsAppAccessSet(true) | |||||
| } | |||||
| }, [appAccessSubjects, appDetail]) | |||||
| const handleClickAccessControl = useCallback(() => { | |||||
| if (!appDetail) | |||||
| return | |||||
| setShowAccessControl(true) | |||||
| }, [appDetail]) | |||||
| const handleAccessControlUpdate = useCallback(() => { | |||||
| fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => { | |||||
| setAppDetail(res) | |||||
| setShowAccessControl(false) | |||||
| }) | |||||
| }, [appDetail, setAppDetail]) | |||||
| return ( | return ( | ||||
| <div | <div | ||||
| className={ | className={ | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {isApp && systemFeatures.webapp_auth.enabled && appDetail && <div className='flex flex-col items-start justify-center self-stretch'> | |||||
| <div className="system-xs-medium pb-1 text-text-tertiary">{t('app.publishApp.title')}</div> | |||||
| <div className='flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2' | |||||
| onClick={handleClickAccessControl}> | |||||
| <div className='flex grow items-center gap-x-1.5 pr-1'> | |||||
| <RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' /> | |||||
| {appDetail?.access_mode === AccessMode.ORGANIZATION && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>} | |||||
| {appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>} | |||||
| {appDetail?.access_mode === AccessMode.PUBLIC && <p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>} | |||||
| </div> | |||||
| {!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>} | |||||
| <div className='flex h-4 w-4 shrink-0 items-center justify-center'> | |||||
| <RiArrowRightSLine className='h-4 w-4 text-text-quaternary' /> | |||||
| </div> | |||||
| </div> | |||||
| </div>} | |||||
| </div> | </div> | ||||
| <div className={'flex items-center gap-1 self-stretch p-3'}> | <div className={'flex items-center gap-1 self-stretch p-3'}> | ||||
| {!isApp && <SecretKeyButton appId={appInfo.id} />} | {!isApp && <SecretKeyButton appId={appInfo.id} />} | ||||
| api_base_url={appInfo.api_base_url} | api_base_url={appInfo.api_base_url} | ||||
| mode={appInfo.mode} | mode={appInfo.mode} | ||||
| /> | /> | ||||
| { | |||||
| showAccessControl && <AccessControl app={appDetail!} | |||||
| onConfirm={handleAccessControlUpdate} | |||||
| onClose={() => { setShowAccessControl(false) }} /> | |||||
| } | |||||
| </> | </> | ||||
| ) | ) | ||||
| : null} | : null} |
| import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react' | import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react' | ||||
| import Link from 'next/link' | import Link from 'next/link' | ||||
| import { Trans, useTranslation } from 'react-i18next' | import { Trans, useTranslation } from 'react-i18next' | ||||
| import { useContext, useContextSelector } from 'use-context-selector' | |||||
| import { useContext } from 'use-context-selector' | |||||
| import { SparklesSoft } from '@/app/components/base/icons/src/public/common' | import { SparklesSoft } from '@/app/components/base/icons/src/public/common' | ||||
| import Modal from '@/app/components/base/modal' | import Modal from '@/app/components/base/modal' | ||||
| import ActionButton from '@/app/components/base/action-button' | import ActionButton from '@/app/components/base/action-button' | ||||
| import { useToastContext } from '@/app/components/base/toast' | import { useToastContext } from '@/app/components/base/toast' | ||||
| import { LanguagesSupported, languages } from '@/i18n/language' | import { LanguagesSupported, languages } from '@/i18n/language' | ||||
| import Tooltip from '@/app/components/base/tooltip' | import Tooltip from '@/app/components/base/tooltip' | ||||
| import AppContext, { useAppContext } from '@/context/app-context' | |||||
| import { useProviderContext } from '@/context/provider-context' | import { useProviderContext } from '@/context/provider-context' | ||||
| import { useModalContext } from '@/context/modal-context' | import { useModalContext } from '@/context/modal-context' | ||||
| import type { AppIconSelection } from '@/app/components/base/app-icon-picker' | import type { AppIconSelection } from '@/app/components/base/app-icon-picker' | ||||
| onClose, | onClose, | ||||
| onSave, | onSave, | ||||
| }) => { | }) => { | ||||
| const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures) | |||||
| const { isCurrentWorkspaceEditor } = useAppContext() | |||||
| const { notify } = useToastContext() | const { notify } = useToastContext() | ||||
| const [isShowMore, setIsShowMore] = useState(false) | const [isShowMore, setIsShowMore] = useState(false) | ||||
| const { | const { | ||||
| : { type: 'emoji', icon, background: icon_background! }, | : { type: 'emoji', icon, background: icon_background! }, | ||||
| ) | ) | ||||
| const { enableBilling, plan } = useProviderContext() | |||||
| const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext() | |||||
| const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() | const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() | ||||
| const isFreePlan = plan.type === 'sandbox' | const isFreePlan = plan.type === 'sandbox' | ||||
| const handlePlanClick = useCallback(() => { | const handlePlanClick = useCallback(() => { | ||||
| setAppIcon(icon_type === 'image' | setAppIcon(icon_type === 'image' | ||||
| ? { type: 'image', url: icon_url!, fileId: icon } | ? { type: 'image', url: icon_url!, fileId: icon } | ||||
| : { type: 'emoji', icon, background: icon_background! }) | : { type: 'emoji', icon, background: icon_background! }) | ||||
| }, [appInfo]) | |||||
| }, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon]) | |||||
| const onHide = () => { | const onHide = () => { | ||||
| onClose() | onClose() | ||||
| chat_color_theme: inputInfo.chatColorTheme, | chat_color_theme: inputInfo.chatColorTheme, | ||||
| chat_color_theme_inverted: inputInfo.chatColorThemeInverted, | chat_color_theme_inverted: inputInfo.chatColorThemeInverted, | ||||
| prompt_public: false, | prompt_public: false, | ||||
| copyright: isFreePlan | |||||
| copyright: !webappCopyrightEnabled | |||||
| ? '' | ? '' | ||||
| : inputInfo.copyrightSwitchValue | : inputInfo.copyrightSwitchValue | ||||
| ? inputInfo.copyright | ? inputInfo.copyright | ||||
| </div> | </div> | ||||
| <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p> | <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p> | ||||
| </div> | </div> | ||||
| {/* SSO */} | |||||
| {systemFeatures.enable_web_sso_switch_component && ( | |||||
| <> | |||||
| <Divider className="my-0 h-px" /> | |||||
| <div className='w-full'> | |||||
| <p className='system-xs-medium-uppercase mb-1 text-text-tertiary'>{t(`${prefixSettings}.sso.label`)}</p> | |||||
| <div className='flex items-center justify-between'> | |||||
| <div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.sso.title`)}</div> | |||||
| <Tooltip | |||||
| disabled={systemFeatures.sso_enforced_for_web} | |||||
| popupContent={ | |||||
| <div className='w-[180px]'>{t(`${prefixSettings}.sso.tooltip`)}</div> | |||||
| } | |||||
| asChild={false} | |||||
| > | |||||
| <Switch disabled={!systemFeatures.sso_enforced_for_web || !isCurrentWorkspaceEditor} defaultValue={systemFeatures.sso_enforced_for_web && inputInfo.enable_sso} onChange={v => setInputInfo({ ...inputInfo, enable_sso: v })}></Switch> | |||||
| </Tooltip> | |||||
| </div> | |||||
| <p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.sso.description`)}</p> | |||||
| </div> | |||||
| </> | |||||
| )} | |||||
| {/* more settings switch */} | {/* more settings switch */} | ||||
| <Divider className="my-0 h-px" /> | <Divider className="my-0 h-px" /> | ||||
| {!isShowMore && ( | {!isShowMore && ( | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| <Tooltip | <Tooltip | ||||
| disabled={!isFreePlan} | |||||
| disabled={webappCopyrightEnabled} | |||||
| popupContent={ | popupContent={ | ||||
| <div className='w-[260px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div> | |||||
| <div className='w-[180px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div> | |||||
| } | } | ||||
| asChild={false} | asChild={false} | ||||
| > | > | ||||
| <Switch | <Switch | ||||
| disabled={isFreePlan} | |||||
| disabled={!webappCopyrightEnabled} | |||||
| defaultValue={inputInfo.copyrightSwitchValue} | defaultValue={inputInfo.copyrightSwitchValue} | ||||
| onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })} | onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })} | ||||
| /> | /> | ||||
| <Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button> | <Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button> | ||||
| <Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button> | <Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button> | ||||
| </div> | </div> | ||||
| {showAppIconPicker && ( | {showAppIconPicker && ( | ||||
| <div onClick={e => e.stopPropagation()}> | <div onClick={e => e.stopPropagation()}> | ||||
| <AppIconPicker | <AppIconPicker |
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| type IAppUnavailableProps = { | type IAppUnavailableProps = { | ||||
| code?: number | |||||
| code?: number | string | |||||
| isUnknownReason?: boolean | isUnknownReason?: boolean | ||||
| unknownReason?: string | unknownReason?: string | ||||
| } | } |
| ConversationItem, | ConversationItem, | ||||
| } from '@/models/share' | } from '@/models/share' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { AccessMode } from '@/models/access-control' | |||||
| export type ChatWithHistoryContextValue = { | export type ChatWithHistoryContextValue = { | ||||
| appInfoError?: any | appInfoError?: any | ||||
| appInfoLoading?: boolean | appInfoLoading?: boolean | ||||
| appMeta?: AppMeta | appMeta?: AppMeta | ||||
| appData?: AppData | appData?: AppData | ||||
| accessMode?: AccessMode | |||||
| userCanAccess?: boolean | |||||
| appParams?: ChatConfig | appParams?: ChatConfig | ||||
| appChatListDataLoading?: boolean | appChatListDataLoading?: boolean | ||||
| currentConversationId: string | currentConversationId: string | ||||
| } | } | ||||
| export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({ | export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({ | ||||
| accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, | |||||
| userCanAccess: false, | |||||
| currentConversationId: '', | currentConversationId: '', | ||||
| appPrevChatTree: [], | appPrevChatTree: [], | ||||
| pinnedConversationList: [], | pinnedConversationList: [], |
| import { InputVarType } from '@/app/components/workflow/types' | import { InputVarType } from '@/app/components/workflow/types' | ||||
| import { TransferMethod } from '@/types/app' | import { TransferMethod } from '@/types/app' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| import { AccessMode } from '@/models/access-control' | |||||
| function getFormattedChatList(messages: any[]) { | function getFormattedChatList(messages: any[]) { | ||||
| const newChatList: ChatItem[] = [] | const newChatList: ChatItem[] = [] | ||||
| export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { | export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { | ||||
| const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) | const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) | ||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) | const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) | ||||
| const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ | |||||
| appId: installedAppInfo?.app.id || appInfo?.app_id, | |||||
| isInstalledApp, | |||||
| enabled: systemFeatures.webapp_auth.enabled, | |||||
| }) | |||||
| const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ | |||||
| appId: installedAppInfo?.app.id || appInfo?.app_id, | |||||
| isInstalledApp, | |||||
| enabled: systemFeatures.webapp_auth.enabled, | |||||
| }) | |||||
| useAppFavicon({ | useAppFavicon({ | ||||
| enable: !installedAppInfo, | enable: !installedAppInfo, | ||||
| return { | return { | ||||
| appInfoError, | appInfoError, | ||||
| appInfoLoading, | |||||
| appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), | |||||
| accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, | |||||
| userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, | |||||
| isInstalledApp, | isInstalledApp, | ||||
| appId, | appId, | ||||
| currentConversationId, | currentConversationId, |
| import { checkOrSetAccessToken } from '@/app/components/share/utils' | import { checkOrSetAccessToken } from '@/app/components/share/utils' | ||||
| import AppUnavailable from '@/app/components/base/app-unavailable' | import AppUnavailable from '@/app/components/base/app-unavailable' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| type ChatWithHistoryProps = { | type ChatWithHistoryProps = { | ||||
| className?: string | className?: string | ||||
| className, | className, | ||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| userCanAccess, | |||||
| appInfoError, | appInfoError, | ||||
| appData, | appData, | ||||
| appInfoLoading, | appInfoLoading, | ||||
| useEffect(() => { | useEffect(() => { | ||||
| themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted) | themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted) | ||||
| if (site) { | |||||
| if (customConfig) | |||||
| document.title = `${site.title}` | |||||
| else | |||||
| document.title = `${site.title} - Powered by Dify` | |||||
| } | |||||
| }, [site, customConfig, themeBuilder]) | }, [site, customConfig, themeBuilder]) | ||||
| useDocumentTitle(site?.title || 'Chat') | |||||
| if (appInfoLoading) { | if (appInfoLoading) { | ||||
| return ( | return ( | ||||
| <Loading type='app' /> | <Loading type='app' /> | ||||
| ) | ) | ||||
| } | } | ||||
| if (!userCanAccess) | |||||
| return <AppUnavailable code={403} unknownReason='no permission.' /> | |||||
| if (appInfoError) { | if (appInfoError) { | ||||
| return ( | return ( | ||||
| const { | const { | ||||
| appInfoError, | appInfoError, | ||||
| appInfoLoading, | appInfoLoading, | ||||
| accessMode, | |||||
| userCanAccess, | |||||
| appData, | appData, | ||||
| appParams, | appParams, | ||||
| appMeta, | appMeta, | ||||
| appInfoError, | appInfoError, | ||||
| appInfoLoading, | appInfoLoading, | ||||
| appData, | appData, | ||||
| accessMode, | |||||
| userCanAccess, | |||||
| appParams, | appParams, | ||||
| appMeta, | appMeta, | ||||
| appChatListDataLoading, | appChatListDataLoading, |
| import DifyLogo from '@/app/components/base/logo/dify-logo' | import DifyLogo from '@/app/components/base/logo/dify-logo' | ||||
| import type { ConversationItem } from '@/models/share' | import type { ConversationItem } from '@/models/share' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { AccessMode } from '@/models/access-control' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| type Props = { | type Props = { | ||||
| isPanel?: boolean | isPanel?: boolean | ||||
| const Sidebar = ({ isPanel }: Props) => { | const Sidebar = ({ isPanel }: Props) => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { | const { | ||||
| isInstalledApp, | |||||
| accessMode, | |||||
| appData, | appData, | ||||
| handleNewConversation, | handleNewConversation, | ||||
| pinnedConversationList, | pinnedConversationList, | ||||
| isResponding, | isResponding, | ||||
| } = useChatWithHistoryContext() | } = useChatWithHistoryContext() | ||||
| const isSidebarCollapsed = sidebarCollapseState | const isSidebarCollapsed = sidebarCollapseState | ||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null) | const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null) | ||||
| const [showRename, setShowRename] = useState<ConversationItem | null>(null) | const [showRename, setShowRename] = useState<ConversationItem | null>(null) | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| <div className='flex shrink-0 items-center justify-between p-3'> | <div className='flex shrink-0 items-center justify-between p-3'> | ||||
| <MenuDropdown placement='top-start' data={appData?.site} /> | |||||
| <MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} placement='top-start' data={appData?.site} /> | |||||
| {/* powered by */} | {/* powered by */} | ||||
| <div className='shrink-0'> | <div className='shrink-0'> | ||||
| {!appData?.custom_config?.remove_webapp_brand && ( | {!appData?.custom_config?.remove_webapp_brand && ( | ||||
| 'flex shrink-0 items-center gap-1.5 px-1', | 'flex shrink-0 items-center gap-1.5 px-1', | ||||
| )}> | )}> | ||||
| <div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> | <div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> | ||||
| {appData?.custom_config?.replace_webapp_logo && ( | |||||
| <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> | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| {!!showConfirm && ( | |||||
| <Confirm | |||||
| title={t('share.chat.deleteConversation.title')} | |||||
| content={t('share.chat.deleteConversation.content') || ''} | |||||
| isShow | |||||
| onCancel={handleCancelConfirm} | |||||
| onConfirm={handleDelete} | |||||
| /> | |||||
| )} | |||||
| {showRename && ( | |||||
| <RenameModal | |||||
| isShow | |||||
| onClose={handleCancelRename} | |||||
| saveLoading={conversationRenaming} | |||||
| name={showRename?.name || ''} | |||||
| onSave={handleRename} | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| {!!showConfirm && ( | |||||
| <Confirm | |||||
| title={t('share.chat.deleteConversation.title')} | |||||
| content={t('share.chat.deleteConversation.content') || ''} | |||||
| isShow | |||||
| onCancel={handleCancelConfirm} | |||||
| onConfirm={handleDelete} | |||||
| /> | |||||
| )} | |||||
| {showRename && ( | |||||
| <RenameModal | |||||
| isShow | |||||
| onClose={handleCancelRename} | |||||
| saveLoading={conversationRenaming} | |||||
| name={showRename?.name || ''} | |||||
| onSave={handleRename} | |||||
| /> | |||||
| )} | |||||
| </div> | </div> | ||||
| ) | ) | ||||
| } | } |
| ConversationItem, | ConversationItem, | ||||
| } from '@/models/share' | } from '@/models/share' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { AccessMode } from '@/models/access-control' | |||||
| export type EmbeddedChatbotContextValue = { | export type EmbeddedChatbotContextValue = { | ||||
| accessMode?: AccessMode | |||||
| userCanAccess?: boolean | |||||
| appInfoError?: any | appInfoError?: any | ||||
| appInfoLoading?: boolean | appInfoLoading?: boolean | ||||
| appMeta?: AppMeta | appMeta?: AppMeta | ||||
| } | } | ||||
| export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({ | export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({ | ||||
| userCanAccess: false, | |||||
| accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, | |||||
| currentConversationId: '', | currentConversationId: '', | ||||
| appPrevChatList: [], | appPrevChatList: [], | ||||
| pinnedConversationList: [], | pinnedConversationList: [], |
| import { TransferMethod } from '@/types/app' | import { TransferMethod } from '@/types/app' | ||||
| import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' | import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| import { AccessMode } from '@/models/access-control' | |||||
| function getFormattedChatList(messages: any[]) { | function getFormattedChatList(messages: any[]) { | ||||
| const newChatList: ChatItem[] = [] | const newChatList: ChatItem[] = [] | ||||
| export const useEmbeddedChatbot = () => { | export const useEmbeddedChatbot = () => { | ||||
| const isInstalledApp = false | const isInstalledApp = false | ||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) | const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo) | ||||
| const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ | |||||
| appId: appInfo?.app_id, | |||||
| isInstalledApp, | |||||
| enabled: systemFeatures.webapp_auth.enabled, | |||||
| }) | |||||
| const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ | |||||
| appId: appInfo?.app_id, | |||||
| isInstalledApp, | |||||
| enabled: systemFeatures.webapp_auth.enabled, | |||||
| }) | |||||
| const appData = useMemo(() => { | const appData = useMemo(() => { | ||||
| return appInfo | return appInfo | ||||
| return { | return { | ||||
| appInfoError, | appInfoError, | ||||
| appInfoLoading, | |||||
| appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission)), | |||||
| accessMode: systemFeatures.webapp_auth.enabled ? appAccessMode?.accessMode : AccessMode.PUBLIC, | |||||
| userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, | |||||
| isInstalledApp, | isInstalledApp, | ||||
| allowResetChat, | allowResetChat, | ||||
| appId, | appId, |
| import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' | import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' | ||||
| import DifyLogo from '@/app/components/base/logo/dify-logo' | import DifyLogo from '@/app/components/base/logo/dify-logo' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const Chatbot = () => { | const Chatbot = () => { | ||||
| const { | const { | ||||
| userCanAccess, | |||||
| isMobile, | isMobile, | ||||
| allowResetChat, | allowResetChat, | ||||
| appInfoError, | appInfoError, | ||||
| useEffect(() => { | useEffect(() => { | ||||
| themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted) | themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted) | ||||
| if (site) { | |||||
| if (customConfig) | |||||
| document.title = `${site.title}` | |||||
| else | |||||
| document.title = `${site.title} - Powered by Dify` | |||||
| } | |||||
| }, [site, customConfig, themeBuilder]) | }, [site, customConfig, themeBuilder]) | ||||
| useDocumentTitle(site?.title || 'Chat') | |||||
| if (appInfoLoading) { | if (appInfoLoading) { | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| ) | ) | ||||
| } | } | ||||
| if (!userCanAccess) | |||||
| return <AppUnavailable code={403} unknownReason='no permission.' /> | |||||
| if (appInfoError) { | if (appInfoError) { | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| appInfoError, | appInfoError, | ||||
| appInfoLoading, | appInfoLoading, | ||||
| appData, | appData, | ||||
| accessMode, | |||||
| userCanAccess, | |||||
| appParams, | appParams, | ||||
| appMeta, | appMeta, | ||||
| appChatListDataLoading, | appChatListDataLoading, | ||||
| } = useEmbeddedChatbot() | } = useEmbeddedChatbot() | ||||
| return <EmbeddedChatbotContext.Provider value={{ | return <EmbeddedChatbotContext.Provider value={{ | ||||
| userCanAccess, | |||||
| accessMode, | |||||
| appInfoError, | appInfoError, | ||||
| appInfoLoading, | appInfoLoading, | ||||
| appData, | appData, |
| import classNames from '@/utils/classnames' | import classNames from '@/utils/classnames' | ||||
| import useTheme from '@/hooks/use-theme' | import useTheme from '@/hooks/use-theme' | ||||
| import { basePath } from '@/utils/var' | import { basePath } from '@/utils/var' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| export type LogoStyle = 'default' | 'monochromeWhite' | export type LogoStyle = 'default' | 'monochromeWhite' | ||||
| export const logoPathMap: Record<LogoStyle, string> = { | export const logoPathMap: Record<LogoStyle, string> = { | ||||
| }) => { | }) => { | ||||
| const { theme } = useTheme() | const { theme } = useTheme() | ||||
| const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style | const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style | ||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| let src = `${basePath}${logoPathMap[themedStyle]}` | |||||
| if (systemFeatures.branding.enabled) | |||||
| src = systemFeatures.branding.workspace_logo | |||||
| return ( | return ( | ||||
| <img | <img | ||||
| src={`${basePath}${logoPathMap[themedStyle]}`} | |||||
| src={src} | |||||
| className={classNames('block object-contain', logoSizeMap[size], className)} | className={classNames('block object-contain', logoSizeMap[size], className)} | ||||
| alt='Dify logo' | alt='Dify logo' | ||||
| /> | /> |
| import { useEffect, useRef, useState } from 'react' | import { useEffect, useRef, useState } from 'react' | ||||
| import { SVG } from '@svgdotjs/svg.js' | import { SVG } from '@svgdotjs/svg.js' | ||||
| import ImagePreview from '@/app/components/base/image-uploader/image-preview' | |||||
| import DOMPurify from 'dompurify' | import DOMPurify from 'dompurify' | ||||
| import ImagePreview from '@/app/components/base/image-uploader/image-preview' | |||||
| export const SVGRenderer = ({ content }: { content: string }) => { | export const SVGRenderer = ({ content }: { content: string }) => { | ||||
| const svgRef = useRef<HTMLDivElement>(null) | const svgRef = useRef<HTMLDivElement>(null) |
| }} | }} | ||||
| onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)} | onMouseLeave={() => triggerMethod === 'hover' && handleLeave(true)} | ||||
| asChild={asChild} | asChild={asChild} | ||||
| className={!asChild ? triggerClassName : ''} | |||||
| > | > | ||||
| {children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>} | {children || <div data-testid={triggerTestId} className={triggerClassName || 'h-3.5 w-3.5 shrink-0 p-[1px]'}><RiQuestionLine className='h-full w-full text-text-quaternary hover:text-text-tertiary' /></div>} | ||||
| </PortalToFollowElemTrigger> | </PortalToFollowElemTrigger> |
| education: { | education: { | ||||
| enabled: boolean | enabled: boolean | ||||
| activated: boolean | activated: boolean | ||||
| }, | |||||
| webapp_copyright_enabled: boolean | |||||
| workspace_members: { | |||||
| size: number | |||||
| limit: number | |||||
| } | } | ||||
| } | } | ||||
| import classNames from '@/utils/classnames' | import classNames from '@/utils/classnames' | ||||
| import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others' | import { Icon3Dots } from '@/app/components/base/icons/src/vender/line/others' | ||||
| import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' | import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config' | ||||
| type IStepOneProps = { | type IStepOneProps = { | ||||
| datasetId?: string | datasetId?: string | ||||
| dataSourceType?: DataSourceType | dataSourceType?: DataSourceType | ||||
| type NotionConnectorProps = { | type NotionConnectorProps = { | ||||
| onSetting: () => void | onSetting: () => void | ||||
| } | } | ||||
| export const NotionConnector = ({ onSetting }: NotionConnectorProps) => { | |||||
| export const NotionConnector = (props: NotionConnectorProps) => { | |||||
| const { onSetting } = props | |||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| return ( | return ( | ||||
| > | > | ||||
| <span className={cn(s.datasetIcon)} /> | <span className={cn(s.datasetIcon)} /> | ||||
| <span | <span | ||||
| title={t('datasetCreation.stepOne.dataSourceType.file')} | |||||
| title={t('datasetCreation.stepOne.dataSourceType.file')!} | |||||
| className='truncate' | className='truncate' | ||||
| > | > | ||||
| {t('datasetCreation.stepOne.dataSourceType.file')} | {t('datasetCreation.stepOne.dataSourceType.file')} | ||||
| > | > | ||||
| <span className={cn(s.datasetIcon, s.notion)} /> | <span className={cn(s.datasetIcon, s.notion)} /> | ||||
| <span | <span | ||||
| title={t('datasetCreation.stepOne.dataSourceType.notion')} | |||||
| title={t('datasetCreation.stepOne.dataSourceType.notion')!} | |||||
| className='truncate' | className='truncate' | ||||
| > | > | ||||
| {t('datasetCreation.stepOne.dataSourceType.notion')} | {t('datasetCreation.stepOne.dataSourceType.notion')} | ||||
| </div> | </div> | ||||
| {(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && ( | {(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && ( | ||||
| <div | <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> | ||||
| )} | )} | ||||
| </div> | </div> |
| ### 鉴权 | ### 鉴权 | ||||
| Dify Service API 使用 `API-Key` 进行鉴权。 | |||||
| Service API 使用 `API-Key` 进行鉴权。 | |||||
| <i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i> | <i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i> | ||||
| 所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示: | 所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示: | ||||
| ### Authentication | ### Authentication | ||||
| Dify Service API 使用 `API-Key` 进行鉴权。 | |||||
| Service API 使用 `API-Key` 进行鉴权。 | |||||
| <i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i> | <i>**强烈建议开发者把 `API-Key` 放在后端存储,而非分享或者放在客户端存储,以免 `API-Key` 泄露,导致财产损失。**</i> | ||||
| 所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示: | 所有 API 请求都应在 **`Authorization`** HTTP Header 中包含您的 `API-Key`,如下所示: | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import React, { useEffect, useState } from 'react' | import React, { useEffect, useState } from 'react' | ||||
| import { useRouter } from 'next/navigation' | import { useRouter } from 'next/navigation' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import ExploreContext from '@/context/explore-context' | import ExploreContext from '@/context/explore-context' | ||||
| import Sidebar from '@/app/components/explore/sidebar' | import Sidebar from '@/app/components/explore/sidebar' | ||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import { fetchMembers } from '@/service/common' | import { fetchMembers } from '@/service/common' | ||||
| import type { InstalledApp } from '@/models/explore' | import type { InstalledApp } from '@/models/explore' | ||||
| import { useTranslation } from 'react-i18next' | |||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| export type IExploreProps = { | export type IExploreProps = { | ||||
| children: React.ReactNode | children: React.ReactNode | ||||
| const Explore: FC<IExploreProps> = ({ | const Explore: FC<IExploreProps> = ({ | ||||
| children, | children, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation() | |||||
| const router = useRouter() | const router = useRouter() | ||||
| const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0) | const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0) | ||||
| const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext() | const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext() | ||||
| const [hasEditPermission, setHasEditPermission] = useState(false) | const [hasEditPermission, setHasEditPermission] = useState(false) | ||||
| const [installedApps, setInstalledApps] = useState<InstalledApp[]>([]) | const [installedApps, setInstalledApps] = useState<InstalledApp[]>([]) | ||||
| const { t } = useTranslation() | |||||
| useDocumentTitle(t('common.menus.explore')) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| document.title = `${t('explore.title')} - Dify`; | |||||
| (async () => { | (async () => { | ||||
| const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} }) | const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} }) | ||||
| if (!accounts) | if (!accounts) |
| } | } | ||||
| return ( | return ( | ||||
| <div className='h-full py-2 pl-0 pr-2 sm:p-2'> | |||||
| <div className='h-full bg-background-default py-2 pl-0 pr-2 sm:p-2'> | |||||
| {installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && ( | {installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && ( | ||||
| <ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' /> | <ChatWithHistory installedAppInfo={installedApp} className='overflow-hidden rounded-2xl shadow-md' /> | ||||
| )} | )} | ||||
| {installedApp.app.mode === 'completion' && ( | {installedApp.app.mode === 'completion' && ( | ||||
| <TextGenerationApp isInstalledApp installedAppInfo={installedApp}/> | |||||
| <TextGenerationApp isInstalledApp installedAppInfo={installedApp} /> | |||||
| )} | )} | ||||
| {installedApp.app.mode === 'workflow' && ( | {installedApp.app.mode === 'workflow' && ( | ||||
| <TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp}/> | |||||
| <TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} /> | |||||
| )} | )} | ||||
| </div> | </div> | ||||
| ) | ) |
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { Fragment, useState } from 'react' | import { Fragment, useState } from 'react' | ||||
| import { useRouter } from 'next/navigation' | import { useRouter } from 'next/navigation' | ||||
| import { useContextSelector } from 'use-context-selector' | |||||
| import { | import { | ||||
| RiAccountCircleLine, | RiAccountCircleLine, | ||||
| RiArrowRightUpLine, | RiArrowRightUpLine, | ||||
| import Avatar from '@/app/components/base/avatar' | import Avatar from '@/app/components/base/avatar' | ||||
| import ThemeSwitcher from '@/app/components/base/theme-switcher' | import ThemeSwitcher from '@/app/components/base/theme-switcher' | ||||
| import { logout } from '@/service/common' | import { logout } from '@/service/common' | ||||
| import AppContext, { useAppContext } from '@/context/app-context' | |||||
| import { useAppContext } from '@/context/app-context' | |||||
| import { useProviderContext } from '@/context/provider-context' | import { useProviderContext } from '@/context/provider-context' | ||||
| import { useModalContext } from '@/context/modal-context' | import { useModalContext } from '@/context/modal-context' | ||||
| import { LicenseStatus } from '@/types/feature' | |||||
| import { IS_CLOUD_EDITION } from '@/config' | import { IS_CLOUD_EDITION } from '@/config' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| export default function AppSelector() { | export default function AppSelector() { | ||||
| const itemClassName = ` | const itemClassName = ` | ||||
| ` | ` | ||||
| const router = useRouter() | const router = useRouter() | ||||
| const [aboutVisible, setAboutVisible] = useState(false) | const [aboutVisible, setAboutVisible] = useState(false) | ||||
| const systemFeatures = useContextSelector(AppContext, v => v.systemFeatures) | |||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext() | const { userProfile, langeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext() | ||||
| </div> | </div> | ||||
| </MenuItem> | </MenuItem> | ||||
| </div> | </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', | '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> | </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> | <MenuItem disabled> | ||||
| <div className='p-1'> | <div className='p-1'> | ||||
| <div className={cn(itemClassName, 'hover:bg-transparent')}> | <div className={cn(itemClassName, 'hover:bg-transparent')}> | ||||
| <RiTShirt2Line className='size-4 shrink-0 text-text-tertiary' /> | <RiTShirt2Line className='size-4 shrink-0 text-text-tertiary' /> | ||||
| <div className='system-md-regular grow px-1 text-text-secondary'>{t('common.theme.theme')}</div> | <div className='system-md-regular grow px-1 text-text-secondary'>{t('common.theme.theme')}</div> | ||||
| <ThemeSwitcher/> | |||||
| <ThemeSwitcher /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </MenuItem> | </MenuItem> |
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import Tooltip from '@/app/components/base/tooltip' | import Tooltip from '@/app/components/base/tooltip' | ||||
| import { RiPencilLine } from '@remixicon/react' | import { RiPencilLine } from '@remixicon/react' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| dayjs.extend(relativeTime) | dayjs.extend(relativeTime) | ||||
| const MembersPage = () => { | const MembersPage = () => { | ||||
| } | } | ||||
| const { locale } = useContext(I18n) | const { locale } = useContext(I18n) | ||||
| const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager, systemFeatures } = useAppContext() | |||||
| const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext() | |||||
| const { data, mutate } = useSWR( | const { data, mutate } = useSWR( | ||||
| { | { | ||||
| url: '/workspaces/current/members', | url: '/workspaces/current/members', | ||||
| }, | }, | ||||
| fetchMembers, | fetchMembers, | ||||
| ) | ) | ||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| const [inviteModalVisible, setInviteModalVisible] = useState(false) | const [inviteModalVisible, setInviteModalVisible] = useState(false) | ||||
| const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([]) | const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([]) | ||||
| const [invitedModalVisible, setInvitedModalVisible] = useState(false) | const [invitedModalVisible, setInvitedModalVisible] = useState(false) |
| 'use client' | 'use client' | ||||
| import { useCallback, useState } from 'react' | |||||
| import { useCallback, useEffect, useState } from 'react' | |||||
| import { useContext } from 'use-context-selector' | import { useContext } from 'use-context-selector' | ||||
| import { RiCloseLine } from '@remixicon/react' | import { RiCloseLine } from '@remixicon/react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import 'react-multi-email/dist/style.css' | import 'react-multi-email/dist/style.css' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { useProviderContextSelector } from '@/context/provider-context' | |||||
| type IInviteModalProps = { | type IInviteModalProps = { | ||||
| isEmailSetup: boolean | isEmailSetup: boolean | ||||
| onCancel: () => void | onCancel: () => void | ||||
| onSend, | onSend, | ||||
| }: IInviteModalProps) => { | }: IInviteModalProps) => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const licenseLimit = useProviderContextSelector(s => s.licenseLimit) | |||||
| const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit) | |||||
| const [emails, setEmails] = useState<string[]>([]) | const [emails, setEmails] = useState<string[]>([]) | ||||
| const { notify } = useContext(ToastContext) | const { notify } = useContext(ToastContext) | ||||
| const [isLimited, setIsLimited] = useState(false) | |||||
| const [isLimitExceeded, setIsLimitExceeded] = useState(false) | |||||
| const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0) | |||||
| useEffect(() => { | |||||
| const limited = licenseLimit.workspace_members.limit > 0 | |||||
| const used = emails.length + licenseLimit.workspace_members.size | |||||
| setIsLimited(limited) | |||||
| setUsedSize(used) | |||||
| setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit)) | |||||
| }, [licenseLimit, emails]) | |||||
| const { locale } = useContext(I18n) | const { locale } = useContext(I18n) | ||||
| const [role, setRole] = useState<string>('normal') | const [role, setRole] = useState<string>('normal') | ||||
| const handleSend = useCallback(async () => { | const handleSend = useCallback(async () => { | ||||
| if (isLimitExceeded) | |||||
| return | |||||
| if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) { | if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) { | ||||
| try { | try { | ||||
| const { result, invitation_results } = await inviteMember({ | const { result, invitation_results } = await inviteMember({ | ||||
| }) | }) | ||||
| if (result === 'success') { | if (result === 'success') { | ||||
| refreshLicenseLimit() | |||||
| onCancel() | onCancel() | ||||
| onSend(invitation_results) | onSend(invitation_results) | ||||
| } | } | ||||
| else { | else { | ||||
| notify({ type: 'error', message: t('common.members.emailInvalid') }) | notify({ type: 'error', message: t('common.members.emailInvalid') }) | ||||
| } | } | ||||
| }, [role, emails, notify, onCancel, onSend, t]) | |||||
| }, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t]) | |||||
| return ( | return ( | ||||
| <div className={cn(s.wrap)}> | <div className={cn(s.wrap)}> | ||||
| <div> | <div> | ||||
| <div className='mb-2 text-sm font-medium text-text-primary'>{t('common.members.email')}</div> | <div className='mb-2 text-sm font-medium text-text-primary'>{t('common.members.email')}</div> | ||||
| <div className='mb-8 flex h-36 items-stretch'> | |||||
| <div className='mb-8 flex h-36 flex-col items-stretch'> | |||||
| <ReactMultiEmail | <ReactMultiEmail | ||||
| className={cn('w-full border-components-input-border-active !bg-components-input-bg-normal px-3 pt-2 outline-none', | className={cn('w-full border-components-input-border-active !bg-components-input-bg-normal px-3 pt-2 outline-none', | ||||
| 'appearance-none overflow-y-auto rounded-lg text-sm !text-text-primary', | 'appearance-none overflow-y-auto rounded-lg text-sm !text-text-primary', | ||||
| } | } | ||||
| placeholder={t('common.members.emailPlaceholder') || ''} | placeholder={t('common.members.emailPlaceholder') || ''} | ||||
| /> | /> | ||||
| <div className={ | |||||
| cn('system-xs-regular flex items-center justify-end text-text-tertiary', | |||||
| (isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '')} | |||||
| > | |||||
| <span>{usedSize}</span> | |||||
| <span>/</span> | |||||
| <span>{isLimited ? licenseLimit.workspace_members.limit : t('common.license.unlimited')}</span> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <div className='mb-6'> | <div className='mb-6'> | ||||
| <RoleSelector value={role} onChange={setRole} /> | <RoleSelector value={role} onChange={setRole} /> | ||||
| tabIndex={0} | tabIndex={0} | ||||
| className='w-full' | className='w-full' | ||||
| onClick={handleSend} | onClick={handleSend} | ||||
| disabled={!emails.length} | |||||
| disabled={!emails.length || isLimitExceeded} | |||||
| variant='primary' | variant='primary' | ||||
| > | > | ||||
| {t('common.members.sendInvite')} | {t('common.members.sendInvite')} |
| import InstallFromMarketplace from './install-from-marketplace' | import InstallFromMarketplace from './install-from-marketplace' | ||||
| import { useProviderContext } from '@/context/provider-context' | import { useProviderContext } from '@/context/provider-context' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| type Props = { | type Props = { | ||||
| searchText: string | searchText: string | ||||
| const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text) | const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text) | ||||
| const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts) | const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts) | ||||
| const { modelProviders: providers } = useProviderContext() | const { modelProviders: providers } = useProviderContext() | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel | const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel | ||||
| const [configuredProviders, notConfiguredProviders] = useMemo(() => { | const [configuredProviders, notConfiguredProviders] = useMemo(() => { | ||||
| const configuredProviders: ModelProvider[] = [] | const configuredProviders: ModelProvider[] = [] |
| 'use client' | 'use client' | ||||
| import AppContext from '@/context/app-context' | |||||
| import { LicenseStatus } from '@/types/feature' | import { LicenseStatus } from '@/types/feature' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useContextSelector } from 'use-context-selector' | |||||
| import dayjs from 'dayjs' | import dayjs from 'dayjs' | ||||
| import PremiumBadge from '../../base/premium-badge' | import PremiumBadge from '../../base/premium-badge' | ||||
| import { RiHourglass2Fill } from '@remixicon/react' | import { RiHourglass2Fill } from '@remixicon/react' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const LicenseNav = () => { | const LicenseNav = () => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const systemFeatures = useContextSelector(AppContext, s => s.systemFeatures) | |||||
| const { systemFeatures } = useGlobalPublicStore() | |||||
| if (systemFeatures.license?.status === LicenseStatus.EXPIRING) { | if (systemFeatures.license?.status === LicenseStatus.EXPIRING) { | ||||
| const expiredAt = systemFeatures.license?.expired_at | const expiredAt = systemFeatures.license?.expired_at |
| createContext, | createContext, | ||||
| useContextSelector, | useContextSelector, | ||||
| } from 'use-context-selector' | } from 'use-context-selector' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import type { FilterState } from './filter-management' | import type { FilterState } from './filter-management' | ||||
| import { useTabSearchParams } from '@/hooks/use-tab-searchparams' | import { useTabSearchParams } from '@/hooks/use-tab-searchparams' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks' | import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| export type PluginPageContextValue = { | export type PluginPageContextValue = { | ||||
| containerRef: React.RefObject<HTMLDivElement> | containerRef: React.RefObject<HTMLDivElement> | ||||
| }) | }) | ||||
| const [currentPluginID, setCurrentPluginID] = useState<string | undefined>() | const [currentPluginID, setCurrentPluginID] = useState<string | undefined>() | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const tabs = usePluginPageTabs() | const tabs = usePluginPageTabs() | ||||
| const options = useMemo(() => { | const options = useMemo(() => { | ||||
| return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace) | return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace) |
| import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' | import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' | ||||
| import { usePluginPageContext } from '../context' | import { usePluginPageContext } from '../context' | ||||
| import { Group } from '@/app/components/base/icons/src/vender/other' | import { Group } from '@/app/components/base/icons/src/vender/other' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import Line from '../../marketplace/empty/line' | import Line from '../../marketplace/empty/line' | ||||
| import { useInstalledPluginList } from '@/service/use-plugins' | import { useInstalledPluginList } from '@/service/use-plugins' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' | import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const Empty = () => { | const Empty = () => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const fileInputRef = useRef<HTMLInputElement>(null) | const fileInputRef = useRef<HTMLInputElement>(null) | ||||
| const [selectedAction, setSelectedAction] = useState<string | null>(null) | const [selectedAction, setSelectedAction] = useState<string | null>(null) | ||||
| const [selectedFile, setSelectedFile] = useState<File | null>(null) | const [selectedFile, setSelectedFile] = useState<File | null>(null) | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const setActiveTab = usePluginPageContext(v => v.setActiveTab) | const setActiveTab = usePluginPageContext(v => v.setActiveTab) | ||||
| const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { |
| import Tooltip from '@/app/components/base/tooltip' | import Tooltip from '@/app/components/base/tooltip' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import PermissionSetModal from '@/app/components/plugins/permission-setting-modal/modal' | import PermissionSetModal from '@/app/components/plugins/permission-setting-modal/modal' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import InstallFromMarketplace from '../install-plugin/install-from-marketplace' | import InstallFromMarketplace from '../install-plugin/install-from-marketplace' | ||||
| import { | import { | ||||
| useRouter, | useRouter, | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch' | import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch' | ||||
| import { PLUGIN_PAGE_TABS_MAP } from '../hooks' | import { PLUGIN_PAGE_TABS_MAP } from '../hooks' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const PACKAGE_IDS_KEY = 'package-ids' | const PACKAGE_IDS_KEY = 'package-ids' | ||||
| const BUNDLE_INFO_KEY = 'bundle-info' | const BUNDLE_INFO_KEY = 'bundle-info' | ||||
| const { locale } = useContext(I18n) | const { locale } = useContext(I18n) | ||||
| const searchParams = useSearchParams() | const searchParams = useSearchParams() | ||||
| const { replace } = useRouter() | const { replace } = useRouter() | ||||
| document.title = `${t('plugin.metadata.title')} - Dify` | |||||
| useDocumentTitle(t('plugin.metadata.title')) | |||||
| // just support install one package now | // just support install one package now | ||||
| const packageId = useMemo(() => { | const packageId = useMemo(() => { | ||||
| const options = usePluginPageContext(v => v.options) | const options = usePluginPageContext(v => v.options) | ||||
| const activeTab = usePluginPageContext(v => v.activeTab) | const activeTab = usePluginPageContext(v => v.activeTab) | ||||
| const setActiveTab = usePluginPageContext(v => v.setActiveTab) | const setActiveTab = usePluginPageContext(v => v.setActiveTab) | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab]) | const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab]) | ||||
| const isExploringMarketplace = useMemo(() => { | const isExploringMarketplace = useMemo(() => { |
| PortalToFollowElemContent, | PortalToFollowElemContent, | ||||
| PortalToFollowElemTrigger, | PortalToFollowElemTrigger, | ||||
| } from '@/app/components/base/portal-to-follow-elem' | } from '@/app/components/base/portal-to-follow-elem' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' | import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' | ||||
| import { noop } from 'lodash-es' | import { noop } from 'lodash-es' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| type Props = { | type Props = { | ||||
| onSwitchToMarketplaceTab: () => void | onSwitchToMarketplaceTab: () => void | ||||
| const [isMenuOpen, setIsMenuOpen] = useState(false) | const [isMenuOpen, setIsMenuOpen] = useState(false) | ||||
| const [selectedAction, setSelectedAction] = useState<string | null>(null) | const [selectedAction, setSelectedAction] = useState<string | null>(null) | ||||
| const [selectedFile, setSelectedFile] = useState<File | null>(null) | const [selectedFile, setSelectedFile] = useState<File | null>(null) | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
| const file = event.target.files?.[0] | const file = event.target.files?.[0] |
| import Toast from '../../base/toast' | import Toast from '../../base/toast' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useInvalidatePermissions, useMutationPermissions, usePermissions } from '@/service/use-plugins' | import { useInvalidatePermissions, useMutationPermissions, usePermissions } from '@/service/use-plugins' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import { useMemo } from 'react' | import { useMemo } from 'react' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean) => { | const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean) => { | ||||
| if (!permission) | if (!permission) | ||||
| } | } | ||||
| export const useCanInstallPluginFromMarketplace = () => { | export const useCanInstallPluginFromMarketplace = () => { | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const { canManagement } = usePermission() | const { canManagement } = usePermission() | ||||
| const canInstallPluginFromMarketplace = useMemo(() => { | const canInstallPluginFromMarketplace = useMemo(() => { |
| import MenuDropdown from './menu-dropdown' | import MenuDropdown from './menu-dropdown' | ||||
| import RunBatch from './run-batch' | import RunBatch from './run-batch' | ||||
| import ResDownload from './run-batch/res-download' | import ResDownload from './run-batch/res-download' | ||||
| import AppUnavailable from '../../base/app-unavailable' | |||||
| import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' | ||||
| import RunOnce from '@/app/components/share/text-generation/run-once' | import RunOnce from '@/app/components/share/text-generation/run-once' | ||||
| import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share' | import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share' | ||||
| import { useAppFavicon } from '@/hooks/use-app-favicon' | import { useAppFavicon } from '@/hooks/use-app-favicon' | ||||
| import DifyLogo from '@/app/components/base/logo/dify-logo' | import DifyLogo from '@/app/components/base/logo/dify-logo' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' | |||||
| import { AccessMode } from '@/models/access-control' | |||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| import useDocumentTitle from '@/hooks/use-document-title' | |||||
| const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group. | const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group. | ||||
| enum TaskStatus { | enum TaskStatus { | ||||
| doSetInputs(newInputs) | doSetInputs(newInputs) | ||||
| inputsRef.current = newInputs | inputsRef.current = newInputs | ||||
| }, []) | }, []) | ||||
| const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const [appId, setAppId] = useState<string>('') | const [appId, setAppId] = useState<string>('') | ||||
| const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null) | const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null) | ||||
| const [canReplaceLogo, setCanReplaceLogo] = useState<boolean>(false) | |||||
| const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null) | const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null) | ||||
| const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null) | const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null) | ||||
| const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null) | const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null) | ||||
| const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null) | const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null) | ||||
| const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ | |||||
| appId, | |||||
| isInstalledApp, | |||||
| enabled: systemFeatures.webapp_auth.enabled, | |||||
| }) | |||||
| const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ | |||||
| appId, | |||||
| isInstalledApp, | |||||
| enabled: systemFeatures.webapp_auth.enabled, | |||||
| }) | |||||
| // save message | // save message | ||||
| const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([]) | const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([]) | ||||
| const fetchSavedMessage = async () => { | const fetchSavedMessage = async () => { | ||||
| useEffect(() => { | useEffect(() => { | ||||
| (async () => { | (async () => { | ||||
| const [appData, appParams]: any = await fetchInitData() | const [appData, appParams]: any = await fetchInitData() | ||||
| const { app_id: appId, site: siteInfo, can_replace_logo, custom_config } = appData | |||||
| const { app_id: appId, site: siteInfo, custom_config } = appData | |||||
| setAppId(appId) | setAppId(appId) | ||||
| setSiteInfo(siteInfo as SiteInfo) | setSiteInfo(siteInfo as SiteInfo) | ||||
| setCanReplaceLogo(can_replace_logo) | |||||
| setCustomConfig(custom_config) | setCustomConfig(custom_config) | ||||
| changeLanguage(siteInfo.default_language) | changeLanguage(siteInfo.default_language) | ||||
| }, []) | }, []) | ||||
| // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client. | // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client. | ||||
| useEffect(() => { | |||||
| 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({ | useAppFavicon({ | ||||
| enable: !isInstalledApp, | enable: !isInstalledApp, | ||||
| </div> | </div> | ||||
| ) | ) | ||||
| if (!appId || !siteInfo || !promptConfig) { | |||||
| if (!appId || !siteInfo || !promptConfig || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission))) { | |||||
| return ( | return ( | ||||
| <div className='flex h-screen items-center'> | <div className='flex h-screen items-center'> | ||||
| <Loading type='app' /> | <Loading type='app' /> | ||||
| </div>) | </div>) | ||||
| } | } | ||||
| if (systemFeatures.webapp_auth.enabled && !userCanAccessResult?.result) | |||||
| return <AppUnavailable code={403} unknownReason='no permission.' /> | |||||
| return ( | return ( | ||||
| <div className={cn( | <div className={cn( | ||||
| imageUrl={siteInfo.icon_url} | imageUrl={siteInfo.icon_url} | ||||
| /> | /> | ||||
| <div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div> | <div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div> | ||||
| <MenuDropdown data={siteInfo} /> | |||||
| <MenuDropdown hideLogout={isInstalledApp || appAccessMode?.accessMode === AccessMode.PUBLIC} data={siteInfo} /> | |||||
| </div> | </div> | ||||
| {siteInfo.description && ( | {siteInfo.description && ( | ||||
| <div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div> | <div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div> | ||||
| !isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', | !isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', | ||||
| )}> | )}> | ||||
| <div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> | <div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div> | ||||
| {customConfig?.replace_webapp_logo && ( | |||||
| <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' /> | <DifyLogo size='small' /> | ||||
| )} | )} | ||||
| </div> | </div> |
| import React from 'react' | import React from 'react' | ||||
| import cn from 'classnames' | |||||
| import Modal from '@/app/components/base/modal' | import Modal from '@/app/components/base/modal' | ||||
| import AppIcon from '@/app/components/base/app-icon' | import AppIcon from '@/app/components/base/app-icon' | ||||
| import type { SiteInfo } from '@/models/share' | import type { SiteInfo } from '@/models/share' | ||||
| import { appDefaultIconBackground } from '@/config' | import { appDefaultIconBackground } from '@/config' | ||||
| import cn from 'classnames' | |||||
| type Props = { | type Props = { | ||||
| data?: SiteInfo | data?: SiteInfo |
| import { | import { | ||||
| RiEqualizer2Line, | RiEqualizer2Line, | ||||
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import { useRouter } from 'next/navigation' | |||||
| import Divider from '../../base/divider' | |||||
| import { removeAccessToken } from '../utils' | |||||
| import InfoModal from './info-modal' | |||||
| import ActionButton from '@/app/components/base/action-button' | import ActionButton from '@/app/components/base/action-button' | ||||
| import { | import { | ||||
| PortalToFollowElem, | PortalToFollowElem, | ||||
| PortalToFollowElemContent, | PortalToFollowElemContent, | ||||
| PortalToFollowElemTrigger, | PortalToFollowElemTrigger, | ||||
| } from '@/app/components/base/portal-to-follow-elem' | } from '@/app/components/base/portal-to-follow-elem' | ||||
| import Divider from '@/app/components/base/divider' | |||||
| import ThemeSwitcher from '@/app/components/base/theme-switcher' | import ThemeSwitcher from '@/app/components/base/theme-switcher' | ||||
| import InfoModal from './info-modal' | |||||
| import type { SiteInfo } from '@/models/share' | import type { SiteInfo } from '@/models/share' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| type Props = { | type Props = { | ||||
| data?: SiteInfo | data?: SiteInfo | ||||
| placement?: Placement | placement?: Placement | ||||
| hideLogout?: boolean | |||||
| } | } | ||||
| const MenuDropdown: FC<Props> = ({ | const MenuDropdown: FC<Props> = ({ | ||||
| data, | data, | ||||
| placement, | placement, | ||||
| hideLogout, | |||||
| }) => { | }) => { | ||||
| const router = useRouter() | |||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const [open, doSetOpen] = useState(false) | const [open, doSetOpen] = useState(false) | ||||
| const openRef = useRef(open) | const openRef = useRef(open) | ||||
| setOpen(!openRef.current) | setOpen(!openRef.current) | ||||
| }, [setOpen]) | }, [setOpen]) | ||||
| const handleLogout = useCallback(() => { | |||||
| removeAccessToken() | |||||
| router.replace(`/webapp-signin?redirect_url=${window.location.href}`) | |||||
| }, [router]) | |||||
| const [show, setShow] = useState(false) | const [show, setShow] = useState(false) | ||||
| return ( | return ( | ||||
| <div className='p-1'> | <div className='p-1'> | ||||
| <div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}> | <div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}> | ||||
| <div className='grow'>{t('common.theme.theme')}</div> | <div className='grow'>{t('common.theme.theme')}</div> | ||||
| <ThemeSwitcher/> | |||||
| <ThemeSwitcher /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <Divider type='horizontal' className='my-0' /> | <Divider type='horizontal' className='my-0' /> |
| import Card from '@/app/components/plugins/card' | import Card from '@/app/components/plugins/card' | ||||
| import CardMoreInfo from '@/app/components/plugins/card/card-more-info' | import CardMoreInfo from '@/app/components/plugins/card/card-more-info' | ||||
| import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' | import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' | ||||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||||
| import { useAllToolProviders } from '@/service/use-tools' | import { useAllToolProviders } from '@/service/use-tools' | ||||
| import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' | import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' | ||||
| import { useGlobalPublicStore } from '@/context/global-public-context' | |||||
| const ProviderList = () => { | const ProviderList = () => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) | |||||
| const containerRef = useRef<HTMLDivElement>(null) | const containerRef = useRef<HTMLDivElement>(null) | ||||
| const { enable_marketplace } = useAppContextSelector(s => s.systemFeatures) | |||||
| const [activeTab, setActiveTab] = useTabSearchParams({ | const [activeTab, setActiveTab] = useTabSearchParams({ | ||||
| defaultTab: 'builtin', | defaultTab: 'builtin', | ||||
| /> | /> | ||||
| ) | ) | ||||
| } | } | ||||
| </div> | |||||
| </div> | |||||
| </div > | |||||
| </div > | |||||
| {currentProvider && !currentProvider.plugin_id && ( | {currentProvider && !currentProvider.plugin_id && ( | ||||
| <ProviderDetail | <ProviderDetail | ||||
| collection={currentProvider} | collection={currentProvider} |
| import { useToastContext } from '@/app/components/base/toast' | import { useToastContext } from '@/app/components/base/toast' | ||||
| import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' | import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' | ||||
| import type { PublishWorkflowParams } from '@/types/workflow' | import type { PublishWorkflowParams } from '@/types/workflow' | ||||
| import { fetchAppDetail, fetchAppSSO } from '@/service/apps' | |||||
| import { fetchAppDetail } from '@/service/apps' | |||||
| import { useStore as useAppStore } from '@/app/components/app/store' | import { useStore as useAppStore } from '@/app/components/app/store' | ||||
| import { useSelector as useAppSelector } from '@/context/app-context' | |||||
| const FeaturesTrigger = () => { | const FeaturesTrigger = () => { | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const appDetail = useAppStore(s => s.appDetail) | const appDetail = useAppStore(s => s.appDetail) | ||||
| const appID = appDetail?.id | const appID = appDetail?.id | ||||
| const setAppDetail = useAppStore(s => s.setAppDetail) | const setAppDetail = useAppStore(s => s.setAppDetail) | ||||
| const systemFeatures = useAppSelector(state => state.systemFeatures) | |||||
| const { | const { | ||||
| nodesReadOnly, | nodesReadOnly, | ||||
| getNodesReadOnly, | getNodesReadOnly, | ||||
| const updateAppDetail = useCallback(async () => { | const updateAppDetail = useCallback(async () => { | ||||
| try { | try { | ||||
| const res = await fetchAppDetail({ url: '/apps', id: appID! }) | const res = await fetchAppDetail({ url: '/apps', id: appID! }) | ||||
| if (systemFeatures.enable_web_sso_switch_component) { | |||||
| const ssoRes = await fetchAppSSO({ appId: appID! }) | |||||
| setAppDetail({ ...res, enable_sso: ssoRes.enabled }) | |||||
| } | |||||
| else { | |||||
| setAppDetail({ ...res }) | |||||
| } | |||||
| setAppDetail({ ...res }) | |||||
| } | } | ||||
| catch (error) { | catch (error) { | ||||
| console.error(error) | console.error(error) | ||||
| } | } | ||||
| }, [appID, setAppDetail, systemFeatures.enable_web_sso_switch_component]) | |||||
| }, [appID, setAppDetail]) | |||||
| const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!) | const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!) | ||||
| const onPublish = useCallback(async (params?: PublishWorkflowParams) => { | const onPublish = useCallback(async (params?: PublishWorkflowParams) => { | ||||
| if (await handleCheckBeforePublish()) { | if (await handleCheckBeforePublish()) { |