Compare commits
59 Commits
main
...
fix/e-lega
| Author | SHA1 | Date |
|---|---|---|
|
|
56962a5920 | 9 months ago |
|
|
1598be6311 | 9 months ago |
|
|
b1bd2c1d5f | 9 months ago |
|
|
820476c57e | 9 months ago |
|
|
e7003902b7 | 9 months ago |
|
|
441929d84c | 9 months ago |
|
|
7e2e764e33 | 9 months ago |
|
|
b647fb8316 | 9 months ago |
|
|
3b554dbac7 | 9 months ago |
|
|
233e826778 | 9 months ago |
|
|
221be98e8c | 9 months ago |
|
|
73e4d2df65 | 9 months ago |
|
|
3e37ca05ba | 9 months ago |
|
|
85832702f6 | 9 months ago |
|
|
8bc1512fa1 | 9 months ago |
|
|
b38286f06a | 9 months ago |
|
|
0ecdf38cd3 | 9 months ago |
|
|
8fe0537d46 | 9 months ago |
|
|
ca5d0460bb | 9 months ago |
|
|
62de9ae5d9 | 9 months ago |
|
|
94c64b661e | 9 months ago |
|
|
74cc4c0ce8 | 9 months ago |
|
|
8c1b1f1b15 | 9 months ago |
|
|
39d7e64dcc | 9 months ago |
|
|
45c163d1fe | 9 months ago |
|
|
db92ba92b8 | 9 months ago |
|
|
fb10f50ed9 | 9 months ago |
|
|
0bd509cb75 | 9 months ago |
|
|
97c891e6a0 | 9 months ago |
|
|
51001544e4 | 9 months ago |
|
|
8de24736ac | 9 months ago |
|
|
49eb78177e | 9 months ago |
|
|
8d7c903def | 9 months ago |
|
|
7bea68fa84 | 9 months ago |
|
|
75a11ff68d | 9 months ago |
|
|
47604e769c | 9 months ago |
|
|
b5126fb944 | 9 months ago |
|
|
afe6dc84eb | 9 months ago |
|
|
d6652beb62 | 9 months ago |
|
|
2ec5007ba2 | 9 months ago |
|
|
a41b194d4d | 9 months ago |
|
|
99261c5010 | 9 months ago |
|
|
b30308e85b | 9 months ago |
|
|
17d6dd6193 | 9 months ago |
|
|
7237c467ce | 9 months ago |
|
|
ddb3e32c1a | 9 months ago |
|
|
0301bd3ac1 | 9 months ago |
|
|
27daeefe98 | 9 months ago |
|
|
087ccf2cc1 | 9 months ago |
|
|
e7161a9bb5 | 9 months ago |
|
|
6de5172a7d | 9 months ago |
|
|
36394a4374 | 9 months ago |
|
|
96ff86a627 | 9 months ago |
|
|
df21fe8555 | 9 months ago |
|
|
27716538e6 | 9 months ago |
|
|
5de139650d | 9 months ago |
|
|
8417d8a5f0 | 9 months ago |
|
|
2480abb792 | 9 months ago |
|
|
62347206c0 | 9 months ago |
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"MD024": false
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
from flask_restful import (
|
||||||
|
Resource, # type: ignore
|
||||||
|
reqparse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from controllers.console.wraps import setup_required
|
||||||
|
from controllers.inner_api import api
|
||||||
|
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||||
|
from services.enterprise.mail_service import DifyMail, EnterpriseMailService
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseMail(Resource):
|
||||||
|
@setup_required
|
||||||
|
@enterprise_inner_api_only
|
||||||
|
def post(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("to", type=str, action="append", required=True)
|
||||||
|
parser.add_argument("subject", type=str, required=True)
|
||||||
|
parser.add_argument("body", type=str, required=True)
|
||||||
|
parser.add_argument("substitutions", type=dict, required=False)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
EnterpriseMailService.send_mail(DifyMail(**args))
|
||||||
|
return {"message": "success"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
api.add_resource(EnterpriseMail, "/enterprise/mail")
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
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 controllers.web import api
|
||||||
|
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")
|
||||||
|
api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
|
||||||
|
api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")
|
||||||
@ -1,11 +1,90 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from services.enterprise.base import EnterpriseRequest
|
from services.enterprise.base import EnterpriseRequest
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppSettings(BaseModel):
|
||||||
|
access_mode: str = Field(
|
||||||
|
description="Access mode for the web app. Can be 'public' or 'private'",
|
||||||
|
default="private",
|
||||||
|
alias="accessMode",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EnterpriseService:
|
class EnterpriseService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_info(cls):
|
def get_info(cls):
|
||||||
return EnterpriseRequest.send_request("GET", "/info")
|
return EnterpriseRequest.send_request("GET", "/info")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_app_web_sso_enabled(cls, app_code):
|
def get_workspace_info(cls, tenant_id: str):
|
||||||
return EnterpriseRequest.send_request("GET", f"/app-sso-setting?appCode={app_code}")
|
return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
|
||||||
|
|
||||||
|
class WebAppAuth:
|
||||||
|
@classmethod
|
||||||
|
def is_user_allowed_to_access_webapp(cls, user_id: str, app_code: str):
|
||||||
|
params = {"userId": user_id, "appCode": app_code}
|
||||||
|
data = EnterpriseRequest.send_request("GET", "/webapp/permission", params=params)
|
||||||
|
|
||||||
|
return data.get("result", False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_app_access_mode_by_id(cls, app_id: str) -> WebAppSettings:
|
||||||
|
if not app_id:
|
||||||
|
raise ValueError("app_id must be provided.")
|
||||||
|
params = {"appId": app_id}
|
||||||
|
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/id", params=params)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No data found.")
|
||||||
|
return WebAppSettings(**data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def batch_get_app_access_mode_by_id(cls, app_ids: list[str]) -> dict[str, WebAppSettings]:
|
||||||
|
if not app_ids:
|
||||||
|
return {}
|
||||||
|
body = {"appIds": app_ids}
|
||||||
|
data: dict[str, str] = EnterpriseRequest.send_request("POST", "/webapp/access-mode/batch/id", json=body)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No data found.")
|
||||||
|
|
||||||
|
if not isinstance(data["accessModes"], dict):
|
||||||
|
raise ValueError("Invalid data format.")
|
||||||
|
|
||||||
|
ret = {}
|
||||||
|
for key, value in data["accessModes"].items():
|
||||||
|
curr = WebAppSettings()
|
||||||
|
curr.access_mode = value
|
||||||
|
ret[key] = curr
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_app_access_mode_by_code(cls, app_code: str) -> WebAppSettings:
|
||||||
|
if not app_code:
|
||||||
|
raise ValueError("app_code must be provided.")
|
||||||
|
params = {"appCode": app_code}
|
||||||
|
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/code", params=params)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("No data found.")
|
||||||
|
return WebAppSettings(**data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_app_access_mode(cls, app_id: str, access_mode: str):
|
||||||
|
if not app_id:
|
||||||
|
raise ValueError("app_id must be provided.")
|
||||||
|
if access_mode not in ["public", "private", "private_all"]:
|
||||||
|
raise ValueError("access_mode must be either 'public', 'private', or 'private_all'")
|
||||||
|
|
||||||
|
data = {"appId": app_id, "accessMode": access_mode}
|
||||||
|
|
||||||
|
response = EnterpriseRequest.send_request("POST", "/webapp/access-mode", json=data)
|
||||||
|
|
||||||
|
return response.get("result", False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cleanup_webapp(cls, app_id: str):
|
||||||
|
if not app_id:
|
||||||
|
raise ValueError("app_id must be provided.")
|
||||||
|
|
||||||
|
body = {"appId": app_id}
|
||||||
|
EnterpriseRequest.send_request("DELETE", "/webapp/clean", json=body)
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from tasks.mail_enterprise_task import send_enterprise_email_task
|
||||||
|
|
||||||
|
|
||||||
|
class DifyMail(BaseModel):
|
||||||
|
to: list[str]
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
substitutions: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseMailService:
|
||||||
|
@classmethod
|
||||||
|
def send_mail(cls, mail: DifyMail):
|
||||||
|
send_enterprise_email_task.delay(
|
||||||
|
to=mail.to, subject=mail.subject, body=mail.body, substitutions=mail.substitutions
|
||||||
|
)
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
import random
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
|
from werkzeug.exceptions import NotFound, Unauthorized
|
||||||
|
|
||||||
|
from configs import dify_config
|
||||||
|
from controllers.web.error import WebAppAuthAccessDeniedError
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from libs.helper import TokenManager
|
||||||
|
from libs.passport import PassportService
|
||||||
|
from libs.password import compare_password
|
||||||
|
from models.account import Account, AccountStatus
|
||||||
|
from models.model import App, EndUser, Site
|
||||||
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
|
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
|
||||||
|
from services.feature_service import FeatureService
|
||||||
|
from tasks.mail_email_code_login import send_email_code_login_mail_task
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppAuthService:
|
||||||
|
"""Service for web app authentication."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate(email: str, password: str) -> Account:
|
||||||
|
"""authenticate account with email and password"""
|
||||||
|
|
||||||
|
account = Account.query.filter_by(email=email).first()
|
||||||
|
if not account:
|
||||||
|
raise AccountNotFoundError()
|
||||||
|
|
||||||
|
if account.status == AccountStatus.BANNED.value:
|
||||||
|
raise AccountLoginError("Account is banned.")
|
||||||
|
|
||||||
|
if account.password is None or not compare_password(password, account.password, account.password_salt):
|
||||||
|
raise AccountPasswordError("Invalid email or password.")
|
||||||
|
|
||||||
|
return cast(Account, account)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def login(cls, account: Account, app_code: str, end_user_id: str) -> str:
|
||||||
|
site = db.session.query(Site).filter(Site.code == app_code).first()
|
||||||
|
if not site:
|
||||||
|
raise NotFound("Site not found.")
|
||||||
|
|
||||||
|
access_token = cls._get_account_jwt_token(account=account, site=site, end_user_id=end_user_id)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_user_through_email(cls, email: str):
|
||||||
|
account = db.session.query(Account).filter(Account.email == email).first()
|
||||||
|
if not account:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if account.status == AccountStatus.BANNED.value:
|
||||||
|
raise Unauthorized("Account is banned.")
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def send_email_code_login_email(
|
||||||
|
cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US"
|
||||||
|
):
|
||||||
|
email = account.email if account else email
|
||||||
|
if email is None:
|
||||||
|
raise ValueError("Email must be provided.")
|
||||||
|
|
||||||
|
code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||||
|
token = TokenManager.generate_token(
|
||||||
|
account=account, email=email, token_type="webapp_email_code_login", additional_data={"code": code}
|
||||||
|
)
|
||||||
|
send_email_code_login_mail_task.delay(
|
||||||
|
language=language,
|
||||||
|
to=account.email if account else email,
|
||||||
|
code=code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_email_code_login_data(cls, token: str) -> Optional[dict[str, Any]]:
|
||||||
|
return TokenManager.get_token_data(token, "webapp_email_code_login")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def revoke_email_code_login_token(cls, token: str):
|
||||||
|
TokenManager.revoke_token(token, "webapp_email_code_login")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_end_user(cls, app_code, email) -> EndUser:
|
||||||
|
site = db.session.query(Site).filter(Site.code == app_code).first()
|
||||||
|
if not site:
|
||||||
|
raise NotFound("Site not found.")
|
||||||
|
app_model = db.session.query(App).filter(App.id == site.app_id).first()
|
||||||
|
if not app_model:
|
||||||
|
raise NotFound("App not found.")
|
||||||
|
end_user = EndUser(
|
||||||
|
tenant_id=app_model.tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
type="browser",
|
||||||
|
is_anonymous=False,
|
||||||
|
session_id=email,
|
||||||
|
name="enterpriseuser",
|
||||||
|
external_user_id="enterpriseuser",
|
||||||
|
)
|
||||||
|
db.session.add(end_user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return end_user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_user_accessibility(cls, account: Account, app_code: str):
|
||||||
|
"""Check if the user is allowed to access the app."""
|
||||||
|
system_features = FeatureService.get_system_features()
|
||||||
|
if system_features.webapp_auth.enabled:
|
||||||
|
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_code(app_code=app_code)
|
||||||
|
|
||||||
|
if (
|
||||||
|
app_settings.access_mode != "public"
|
||||||
|
and not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(account.id, app_code=app_code)
|
||||||
|
):
|
||||||
|
raise WebAppAuthAccessDeniedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_account_jwt_token(cls, account: Account, site: Site, end_user_id: str) -> str:
|
||||||
|
exp_dt = datetime.now(UTC) + timedelta(hours=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 24)
|
||||||
|
exp = int(exp_dt.timestamp())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": site.id,
|
||||||
|
"sub": "Web API Passport",
|
||||||
|
"app_id": site.app_id,
|
||||||
|
"app_code": site.code,
|
||||||
|
"user_id": account.id,
|
||||||
|
"end_user_id": end_user_id,
|
||||||
|
"token_source": "webapp",
|
||||||
|
"exp": exp,
|
||||||
|
}
|
||||||
|
|
||||||
|
token: str = PassportService().issue(payload)
|
||||||
|
return token
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import click
|
||||||
|
from celery import shared_task # type: ignore
|
||||||
|
from flask import render_template_string
|
||||||
|
|
||||||
|
from extensions.ext_mail import mail
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(queue="mail")
|
||||||
|
def send_enterprise_email_task(to, subject, body, substitutions):
|
||||||
|
if not mail.is_inited():
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info(click.style("Start enterprise mail to {} with subject {}".format(to, subject), fg="green"))
|
||||||
|
start_at = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
html_content = render_template_string(body, **substitutions)
|
||||||
|
|
||||||
|
if isinstance(to, list):
|
||||||
|
for t in to:
|
||||||
|
mail.send(to=t, subject=subject, html=html_content)
|
||||||
|
else:
|
||||||
|
mail.send(to=to, subject=subject, html=html_content)
|
||||||
|
|
||||||
|
end_at = time.perf_counter()
|
||||||
|
logging.info(
|
||||||
|
click.style("Send enterprise mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Send enterprise mail to {} failed".format(to))
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">Your login code for {{application_title}}</p>
|
||||||
|
<p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">If you didn't request a login, don't worry. You can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">{{application_title}} 的登录验证码</p>
|
||||||
|
<p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">如果您没有请求登录,请不要担心。您可以安全地忽略此电子邮件。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #374151;
|
||||||
|
background-color: #E5E7EB;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #F3F4F6;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background-color: #2970FF;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background-color: #265DD4;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #777777;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear {{ to }},</p>
|
||||||
|
<p>{{ inviter_name }} is pleased to invite you to join our workspace on {{application_title}}, a platform specifically designed for LLM application development. On {{application_title}}, you can explore, create, and collaborate to build and operate AI applications.</p>
|
||||||
|
<p>Click the button below to log in to {{application_title}} and join the workspace.</p>
|
||||||
|
<p style="text-align: center;"><a style="color: #fff; text-decoration: none" class="button" href="{{ url }}">Login Here</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Best regards,</p>
|
||||||
|
<p>{{application_title}} Team</p>
|
||||||
|
<p>Please do not reply directly to this email; it is automatically sent by the system.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">Set your {{application_title}} password</p>
|
||||||
|
<p class="description">Copy and paste this code, this code will only be valid for the next 5 minutes.</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">If you didn't request, don't worry. You can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
line-height: 16pt;
|
||||||
|
color: #101828;
|
||||||
|
background-color: #e9ebf0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
height: 360px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 36px 48px;
|
||||||
|
background-color: #fcfcfd;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: 0 2px 4px -2px rgba(9, 9, 11, 0.08);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header img {
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28.8px;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.code-content {
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
margin: 16px auto;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
line-height: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
line-height: 16px;
|
||||||
|
color: #676f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<p class="title">设置您的 {{application_title}} 账户密码</p>
|
||||||
|
<p class="description">复制并粘贴此验证码,注意验证码仅在接下来的 5 分钟内有效。</p>
|
||||||
|
<div class="code-content">
|
||||||
|
<span class="code">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="tips">如果您没有请求,请不要担心。您可以安全地忽略此电子邮件。</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import useDocumentTitle from '@/hooks/use-document-title'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t('common.menus.apps'))
|
||||||
|
return (<>
|
||||||
|
{children}
|
||||||
|
</>)
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { Fragment, useCallback } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type DialogProps = {
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
show: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessControlDialog = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
}: DialogProps) => {
|
||||||
|
const close = useCallback(() => {
|
||||||
|
onClose?.()
|
||||||
|
}, [onClose])
|
||||||
|
return (
|
||||||
|
<Transition appear show={show} as={Fragment}>
|
||||||
|
<Dialog as="div" open={true} className="relative z-20" onClose={() => null}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-background-overlay bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
|
||||||
|
<div onClick={() => close()} className="absolute right-5 top-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
|
||||||
|
<RiCloseLine className='h-5 w-5' />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccessControlDialog
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC, PropsWithChildren } from 'react'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import type { AccessMode } from '@/models/access-control'
|
||||||
|
|
||||||
|
type AccessControlItemProps = PropsWithChildren<{
|
||||||
|
type: AccessMode
|
||||||
|
}>
|
||||||
|
|
||||||
|
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||||
|
const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu }))
|
||||||
|
if (currentMenu !== type) {
|
||||||
|
return <div
|
||||||
|
className="cursor-pointer rounded-[10px] border-[1px]
|
||||||
|
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||||
|
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||||
|
onClick={() => setCurrentMenu(type)} >
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="rounded-[10px] border-[1.5px]
|
||||||
|
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessControlItem.displayName = 'AccessControlItem'
|
||||||
|
|
||||||
|
export default AccessControlItem
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
'use client'
|
||||||
|
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useDebounce } from 'ahooks'
|
||||||
|
import { FloatingOverlay } from '@floating-ui/react'
|
||||||
|
import Avatar from '../../base/avatar'
|
||||||
|
import Button from '../../base/button'
|
||||||
|
import Checkbox from '../../base/checkbox'
|
||||||
|
import Input from '../../base/input'
|
||||||
|
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||||
|
import Loading from '../../base/loading'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import classNames from '@/utils/classnames'
|
||||||
|
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||||
|
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||||
|
import { SubjectType } from '@/models/access-control'
|
||||||
|
import { useSelector } from '@/context/app-context'
|
||||||
|
|
||||||
|
export default function AddMemberOrGroupDialog() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||||
|
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||||
|
|
||||||
|
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||||
|
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||||
|
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setKeyword(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const hasMore = data?.pages?.[0].hasMore ?? false
|
||||||
|
let observer: IntersectionObserver | undefined
|
||||||
|
if (anchorRef.current) {
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isLoading && hasMore)
|
||||||
|
fetchNextPage()
|
||||||
|
}, { rootMargin: '20px' })
|
||||||
|
observer.observe(anchorRef.current)
|
||||||
|
}
|
||||||
|
return () => observer?.disconnect()
|
||||||
|
}, [isLoading, fetchNextPage, anchorRef, data])
|
||||||
|
|
||||||
|
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
|
||||||
|
<PortalToFollowElemTrigger asChild>
|
||||||
|
<Button variant='ghost-accent' size='small' className='flex shrink-0 items-center gap-x-0.5' onClick={() => setOpen(!open)}>
|
||||||
|
<RiAddCircleFill className='h-4 w-4' />
|
||||||
|
<span>{t('common.operation.add')}</span>
|
||||||
|
</Button>
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
{open && <FloatingOverlay />}
|
||||||
|
<PortalToFollowElemContent className='z-[25]'>
|
||||||
|
<div className='relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||||
|
<div className='sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]'>
|
||||||
|
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
isLoading
|
||||||
|
? <div className='p-1'><Loading /></div>
|
||||||
|
: (data?.pages?.length ?? 0) > 0
|
||||||
|
? <>
|
||||||
|
<div className='flex h-7 items-center px-2 py-0.5'>
|
||||||
|
<SelectedGroupsBreadCrumb />
|
||||||
|
</div>
|
||||||
|
<div className='p-1'>
|
||||||
|
{renderGroupOrMember(data?.pages ?? [])}
|
||||||
|
{isFetchingNextPage && <Loading />}
|
||||||
|
</div>
|
||||||
|
<div ref={anchorRef} className='h-0'> </div>
|
||||||
|
</>
|
||||||
|
: <div className='flex h-7 items-center justify-center px-2 py-0.5'>
|
||||||
|
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.noResult')}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupOrMemberData = { subjects: Subject[]; currPage: number }[]
|
||||||
|
function renderGroupOrMember(data: GroupOrMemberData) {
|
||||||
|
return data?.map((page) => {
|
||||||
|
return <div key={`search_group_member_page_${page.currPage}`}>
|
||||||
|
{page.subjects?.map((item, index) => {
|
||||||
|
if (item.subjectType === SubjectType.GROUP)
|
||||||
|
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
|
||||||
|
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedGroupsBreadCrumb() {
|
||||||
|
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||||
|
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||||
|
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||||
|
setSelectedGroupsForBreadcrumb(newGroups)
|
||||||
|
}, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb])
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setSelectedGroupsForBreadcrumb([])
|
||||||
|
}, [setSelectedGroupsForBreadcrumb])
|
||||||
|
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
|
||||||
|
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'text-text-accent cursor-pointer')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||||
|
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||||
|
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
|
||||||
|
<span>/</span>
|
||||||
|
<span className={index === selectedGroupsForBreadcrumb.length - 1 ? '' : 'cursor-pointer text-text-accent'} onClick={() => handleBreadCrumbClick(index)}>{group.name}</span>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupItemProps = {
|
||||||
|
group: AccessControlGroup
|
||||||
|
}
|
||||||
|
function GroupItem({ group }: GroupItemProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||||
|
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||||
|
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||||
|
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||||
|
const handleCheckChange = useCallback(() => {
|
||||||
|
if (!isChecked) {
|
||||||
|
const newGroups = [...specificGroups, group]
|
||||||
|
setSpecificGroups(newGroups)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const newGroups = specificGroups.filter(g => g.id !== group.id)
|
||||||
|
setSpecificGroups(newGroups)
|
||||||
|
}
|
||||||
|
}, [specificGroups, setSpecificGroups, group, isChecked])
|
||||||
|
|
||||||
|
const handleExpandClick = useCallback(() => {
|
||||||
|
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||||
|
}, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group])
|
||||||
|
return <BaseItem>
|
||||||
|
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||||
|
<div className='item-center flex grow'>
|
||||||
|
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||||
|
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||||
|
<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className='system-sm-medium mr-1 text-text-secondary'>{group.name}</p>
|
||||||
|
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||||
|
</div>
|
||||||
|
<Button size="small" disabled={isChecked} variant='ghost-accent'
|
||||||
|
className='flex shrink-0 items-center justify-between px-1.5 py-1' onClick={handleExpandClick}>
|
||||||
|
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
|
||||||
|
<RiArrowRightSLine className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberItemProps = {
|
||||||
|
member: AccessControlAccount
|
||||||
|
}
|
||||||
|
function MemberItem({ member }: MemberItemProps) {
|
||||||
|
const currentUser = useSelector(s => s.userProfile)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||||
|
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||||
|
const handleCheckChange = useCallback(() => {
|
||||||
|
if (!isChecked) {
|
||||||
|
const newMembers = [...specificMembers, member]
|
||||||
|
setSpecificMembers(newMembers)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const newMembers = specificMembers.filter(m => m.id !== member.id)
|
||||||
|
setSpecificMembers(newMembers)
|
||||||
|
}
|
||||||
|
}, [specificMembers, setSpecificMembers, member, isChecked])
|
||||||
|
return <BaseItem className='pr-3'>
|
||||||
|
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||||
|
<div className='flex grow items-center'>
|
||||||
|
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||||
|
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||||
|
<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className='system-sm-medium mr-1 text-text-secondary'>{member.name}</p>
|
||||||
|
{currentUser.email === member.email && <p className='system-xs-regular text-text-tertiary'>({t('common.you')})</p>}
|
||||||
|
</div>
|
||||||
|
<p className='system-xs-regular text-text-quaternary'>{member.email}</p>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseItemProps = {
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
function BaseItem({ children, className }: BaseItemProps) {
|
||||||
|
return <div className={classNames('p-1 pl-2 flex items-center space-x-2 hover:rounded-lg hover:bg-state-base-hover cursor-pointer', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
import { Dialog } from '@headlessui/react'
|
||||||
|
import { RiBuildingLine, RiGlobalLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import Button from '../../base/button'
|
||||||
|
import Toast from '../../base/toast'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import AccessControlDialog from './access-control-dialog'
|
||||||
|
import AccessControlItem from './access-control-item'
|
||||||
|
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import type { App } from '@/types/app'
|
||||||
|
import type { Subject } from '@/models/access-control'
|
||||||
|
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||||
|
import { useUpdateAccessMode } from '@/service/access-control'
|
||||||
|
|
||||||
|
type AccessControlProps = {
|
||||||
|
app: App
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccessControl(props: AccessControlProps) {
|
||||||
|
const { app, onClose, onConfirm } = props
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||||
|
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||||
|
const hideTip = systemFeatures.webapp_auth.enabled
|
||||||
|
&& (systemFeatures.webapp_auth.allow_sso
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAppId(app.id)
|
||||||
|
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||||
|
}, [app, setAppId, setCurrentMenu])
|
||||||
|
|
||||||
|
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
const submitData: {
|
||||||
|
appId: string
|
||||||
|
accessMode: AccessMode
|
||||||
|
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||||
|
} = { appId: app.id, accessMode: currentMenu }
|
||||||
|
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||||
|
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||||
|
specificGroups.forEach((group) => {
|
||||||
|
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||||
|
})
|
||||||
|
specificMembers.forEach((member) => {
|
||||||
|
subjects.push({
|
||||||
|
subjectId: member.id,
|
||||||
|
subjectType: SubjectType.ACCOUNT,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
submitData.subjects = subjects
|
||||||
|
}
|
||||||
|
await updateAccessMode(submitData)
|
||||||
|
Toast.notify({ type: 'success', message: t('app.accessControlDialog.updateSuccess') })
|
||||||
|
onConfirm?.()
|
||||||
|
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||||
|
return <AccessControlDialog show onClose={onClose}>
|
||||||
|
<div className='flex flex-col gap-y-3'>
|
||||||
|
<div className='pb-3 pl-6 pr-14 pt-6'>
|
||||||
|
<Dialog.Title className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</Dialog.Title>
|
||||||
|
<Dialog.Description className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-y-1 px-6 pb-3'>
|
||||||
|
<div className='leading-6'>
|
||||||
|
<p className='system-sm-medium'>{t('app.accessControlDialog.accessLabel')}</p>
|
||||||
|
</div>
|
||||||
|
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||||
|
<div className='flex items-center p-3'>
|
||||||
|
<div className='flex grow items-center gap-x-2'>
|
||||||
|
<RiBuildingLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p>
|
||||||
|
</div>
|
||||||
|
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||||
|
</div>
|
||||||
|
</AccessControlItem>
|
||||||
|
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||||
|
<SpecificGroupsOrMembers />
|
||||||
|
</AccessControlItem>
|
||||||
|
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||||
|
<div className='flex items-center gap-x-2 p-3'>
|
||||||
|
<RiGlobalLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
|
||||||
|
</div>
|
||||||
|
</AccessControlItem>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
|
||||||
|
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||||
|
<Button disabled={isPending} loading={isPending} variant='primary' onClick={handleConfirm}>{t('common.operation.confirm')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccessControlDialog>
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
'use client'
|
||||||
|
import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import Avatar from '../../base/avatar'
|
||||||
|
import Divider from '../../base/divider'
|
||||||
|
import Tooltip from '../../base/tooltip'
|
||||||
|
import Loading from '../../base/loading'
|
||||||
|
import useAccessControlStore from '../../../../context/access-control-store'
|
||||||
|
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||||
|
import { AccessMode } from '@/models/access-control'
|
||||||
|
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||||
|
|
||||||
|
export default function SpecificGroupsOrMembers() {
|
||||||
|
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||||
|
const appId = useAccessControlStore(s => s.appId)
|
||||||
|
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||||
|
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||||
|
const hideTip = systemFeatures.webapp_auth.enabled
|
||||||
|
&& (systemFeatures.webapp_auth.allow_sso
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||||
|
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||||
|
|
||||||
|
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||||
|
useEffect(() => {
|
||||||
|
setSpecificGroups(data?.groups ?? [])
|
||||||
|
setSpecificMembers(data?.members ?? [])
|
||||||
|
}, [data, setSpecificGroups, setSpecificMembers])
|
||||||
|
|
||||||
|
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||||
|
return <div className='flex items-center p-3'>
|
||||||
|
<div className='flex grow items-center gap-x-2'>
|
||||||
|
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||||
|
</div>
|
||||||
|
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<div className='flex items-center gap-x-1 p-3'>
|
||||||
|
<div className='flex grow items-center gap-x-1'>
|
||||||
|
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||||
|
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-x-1'>
|
||||||
|
{!hideTip && <>
|
||||||
|
<WebAppSSONotEnabledTip />
|
||||||
|
<Divider className='ml-2 mr-0 h-[14px]' type="vertical" />
|
||||||
|
</>}
|
||||||
|
<AddMemberOrGroupDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='px-1 pb-1'>
|
||||||
|
<div className='flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2'>
|
||||||
|
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderGroupsAndMembers() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||||
|
return <div className='px-2 pb-1.5 pt-5'><p className='system-xs-regular text-center text-text-tertiary'>{t('app.accessControlDialog.noGroupsOrMembers')}</p></div>
|
||||||
|
return <>
|
||||||
|
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.groups', { count: specificGroups.length ?? 0 })}</p>
|
||||||
|
<div className='flex flex-row flex-wrap gap-1'>
|
||||||
|
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||||
|
</div>
|
||||||
|
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.members', { count: specificMembers.length ?? 0 })}</p>
|
||||||
|
<div className='flex flex-row flex-wrap gap-1'>
|
||||||
|
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupItemProps = {
|
||||||
|
group: AccessControlGroup
|
||||||
|
}
|
||||||
|
function GroupItem({ group }: GroupItemProps) {
|
||||||
|
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||||
|
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||||
|
const handleRemoveGroup = useCallback(() => {
|
||||||
|
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||||
|
}, [group, setSpecificGroups, specificGroups])
|
||||||
|
return <BaseItem icon={<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />}
|
||||||
|
onRemove={handleRemoveGroup}>
|
||||||
|
<p className='system-xs-regular text-text-primary'>{group.name}</p>
|
||||||
|
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberItemProps = {
|
||||||
|
member: AccessControlAccount
|
||||||
|
}
|
||||||
|
function MemberItem({ member }: MemberItemProps) {
|
||||||
|
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||||
|
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||||
|
const handleRemoveMember = useCallback(() => {
|
||||||
|
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||||
|
}, [member, setSpecificMembers, specificMembers])
|
||||||
|
return <BaseItem icon={<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />}
|
||||||
|
onRemove={handleRemoveMember}>
|
||||||
|
<p className='system-xs-regular text-text-primary'>{member.name}</p>
|
||||||
|
</BaseItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseItemProps = {
|
||||||
|
icon: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||||
|
return <div className='group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs'>
|
||||||
|
<div className='h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||||
|
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className='flex h-4 w-4 cursor-pointer items-center justify-center' onClick={onRemove}>
|
||||||
|
<RiCloseCircleFill className='h-[14px] w-[14px] text-text-quaternary' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebAppSSONotEnabledTip() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return <Tooltip asChild={false} popupContent={t('app.accessControlDialog.webAppSSONotEnabledTip')}>
|
||||||
|
<RiAlertFill className='h-4 w-4 shrink-0 text-text-warning-secondary' />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue