merge main
commit
55f4177b01
@ -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,120 @@
|
|||||||
|
from flask import request
|
||||||
|
from flask_restful import Resource, reqparse
|
||||||
|
from jwt import InvalidTokenError # type: ignore
|
||||||
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
|
import services
|
||||||
|
from controllers.console.auth.error import EmailCodeError, EmailOrPasswordMismatchError, InvalidEmailError
|
||||||
|
from controllers.console.error import AccountBannedError, AccountNotFound
|
||||||
|
from controllers.console.wraps import setup_required
|
||||||
|
from libs.helper import email
|
||||||
|
from libs.password import valid_password
|
||||||
|
from services.account_service import AccountService
|
||||||
|
from services.webapp_auth_service import WebAppAuthService
|
||||||
|
|
||||||
|
|
||||||
|
class LoginApi(Resource):
|
||||||
|
"""Resource for web app email/password login."""
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
"""Authenticate user and login."""
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("email", type=email, required=True, location="json")
|
||||||
|
parser.add_argument("password", type=valid_password, required=True, location="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app_code = request.headers.get("X-App-Code")
|
||||||
|
if app_code is None:
|
||||||
|
raise BadRequest("X-App-Code header is missing.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
account = WebAppAuthService.authenticate(args["email"], args["password"])
|
||||||
|
except services.errors.account.AccountLoginError:
|
||||||
|
raise AccountBannedError()
|
||||||
|
except services.errors.account.AccountPasswordError:
|
||||||
|
raise EmailOrPasswordMismatchError()
|
||||||
|
except services.errors.account.AccountNotFoundError:
|
||||||
|
raise AccountNotFound()
|
||||||
|
|
||||||
|
WebAppAuthService._validate_user_accessibility(account=account, app_code=app_code)
|
||||||
|
|
||||||
|
end_user = WebAppAuthService.create_end_user(email=args["email"], app_code=app_code)
|
||||||
|
|
||||||
|
token = WebAppAuthService.login(account=account, app_code=app_code, end_user_id=end_user.id)
|
||||||
|
return {"result": "success", "token": token}
|
||||||
|
|
||||||
|
|
||||||
|
# class LogoutApi(Resource):
|
||||||
|
# @setup_required
|
||||||
|
# def get(self):
|
||||||
|
# account = cast(Account, flask_login.current_user)
|
||||||
|
# if isinstance(account, flask_login.AnonymousUserMixin):
|
||||||
|
# return {"result": "success"}
|
||||||
|
# flask_login.logout_user()
|
||||||
|
# return {"result": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodeLoginSendEmailApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
def post(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("email", type=email, required=True, location="json")
|
||||||
|
parser.add_argument("language", type=str, required=False, location="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args["language"] is not None and args["language"] == "zh-Hans":
|
||||||
|
language = "zh-Hans"
|
||||||
|
else:
|
||||||
|
language = "en-US"
|
||||||
|
|
||||||
|
account = WebAppAuthService.get_user_through_email(args["email"])
|
||||||
|
if account is None:
|
||||||
|
raise AccountNotFound()
|
||||||
|
else:
|
||||||
|
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
|
||||||
|
|
||||||
|
return {"result": "success", "data": token}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodeLoginApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
def post(self):
|
||||||
|
parser = reqparse.RequestParser()
|
||||||
|
parser.add_argument("email", type=str, required=True, location="json")
|
||||||
|
parser.add_argument("code", type=str, required=True, location="json")
|
||||||
|
parser.add_argument("token", type=str, required=True, location="json")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
user_email = args["email"]
|
||||||
|
app_code = request.headers.get("X-App-Code")
|
||||||
|
if app_code is None:
|
||||||
|
raise BadRequest("X-App-Code header is missing.")
|
||||||
|
|
||||||
|
token_data = WebAppAuthService.get_email_code_login_data(args["token"])
|
||||||
|
if token_data is None:
|
||||||
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
if token_data["email"] != args["email"]:
|
||||||
|
raise InvalidEmailError()
|
||||||
|
|
||||||
|
if token_data["code"] != args["code"]:
|
||||||
|
raise EmailCodeError()
|
||||||
|
|
||||||
|
WebAppAuthService.revoke_email_code_login_token(args["token"])
|
||||||
|
account = WebAppAuthService.get_user_through_email(user_email)
|
||||||
|
if not account:
|
||||||
|
raise AccountNotFound()
|
||||||
|
|
||||||
|
WebAppAuthService._validate_user_accessibility(account=account, app_code=app_code)
|
||||||
|
|
||||||
|
end_user = WebAppAuthService.create_end_user(email=user_email, app_code=app_code)
|
||||||
|
|
||||||
|
token = WebAppAuthService.login(account=account, app_code=app_code, end_user_id=end_user.id)
|
||||||
|
AccountService.reset_login_error_rate_limit(args["email"])
|
||||||
|
return {"result": "success", "token": token}
|
||||||
|
|
||||||
|
|
||||||
|
# api.add_resource(LoginApi, "/login")
|
||||||
|
# api.add_resource(LogoutApi, "/logout")
|
||||||
|
# api.add_resource(EmailCodeLoginSendEmailApi, "/email-code-login")
|
||||||
|
# api.add_resource(EmailCodeLoginApi, "/email-code-login/validity")
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class WaterCrawlError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WaterCrawlBadRequestError(WaterCrawlError):
|
||||||
|
def __init__(self, response):
|
||||||
|
self.status_code = response.status_code
|
||||||
|
self.response = response
|
||||||
|
data = response.json()
|
||||||
|
self.message = data.get("message", "Unknown error occurred")
|
||||||
|
self.errors = data.get("errors", {})
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flat_errors(self):
|
||||||
|
return json.dumps(self.errors)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WaterCrawlBadRequestError: {self.message} \n {self.flat_errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class WaterCrawlPermissionError(WaterCrawlBadRequestError):
|
||||||
|
def __str__(self):
|
||||||
|
return f"You are exceeding your WaterCrawl API limits. {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
class WaterCrawlAuthenticationError(WaterCrawlBadRequestError):
|
||||||
|
def __str__(self):
|
||||||
|
return "WaterCrawl API key is invalid or expired. Please check your API key and try again."
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
# The minimal selector length for valid variables.
|
||||||
|
#
|
||||||
|
# The first element of the selector is the node id, and the second element is the variable name.
|
||||||
|
#
|
||||||
|
# If the selector length is more than 2, the remaining parts are the keys / indexes paths used
|
||||||
|
# to extract part of the variable value.
|
||||||
|
MIN_SELECTORS_LENGTH = 2
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def to_selector(node_id: str, name: str, paths: Iterable[str] = ()) -> Sequence[str]:
|
||||||
|
selectors = [node_id, name]
|
||||||
|
if paths:
|
||||||
|
selectors.extend(paths)
|
||||||
|
return selectors
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
"""add WorkflowDraftVariable model
|
||||||
|
|
||||||
|
Revision ID: 2adcbe1f5dfb
|
||||||
|
Revises: d28f2004b072
|
||||||
|
Create Date: 2025-05-15 15:31:03.128680
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
import models as models
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2adcbe1f5dfb"
|
||||||
|
down_revision = "d28f2004b072"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"workflow_draft_variables",
|
||||||
|
sa.Column("id", models.types.StringUUID(), server_default=sa.text("uuid_generate_v4()"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||||
|
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column("last_edited_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("node_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("selector", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("value_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("visible", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("editable", sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("workflow_draft_variables_pkey")),
|
||||||
|
sa.UniqueConstraint("app_id", "node_id", "name", name=op.f("workflow_draft_variables_app_id_key")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
|
||||||
|
# Dropping `workflow_draft_variables` also drops any index associated with it.
|
||||||
|
op.drop_table("workflow_draft_variables")
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -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,187 @@
|
|||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any, NamedTuple, TypeVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import exc as sa_exc
|
||||||
|
from sqlalchemy import insert
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session
|
||||||
|
from sqlalchemy.sql.sqltypes import VARCHAR
|
||||||
|
|
||||||
|
from models.types import EnumText
|
||||||
|
|
||||||
|
_user_type_admin = "admin"
|
||||||
|
_user_type_normal = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class _Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _UserType(StrEnum):
|
||||||
|
admin = _user_type_admin
|
||||||
|
normal = _user_type_normal
|
||||||
|
|
||||||
|
|
||||||
|
class _EnumWithLongValue(StrEnum):
|
||||||
|
unknown = "unknown"
|
||||||
|
a_really_long_enum_values = "a_really_long_enum_values"
|
||||||
|
|
||||||
|
|
||||||
|
class _User(_Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = sa.Column(sa.String(length=255), nullable=False)
|
||||||
|
user_type: Mapped[_UserType] = sa.Column(EnumText(enum_class=_UserType), nullable=False, default=_UserType.normal)
|
||||||
|
user_type_nullable: Mapped[_UserType | None] = sa.Column(EnumText(enum_class=_UserType), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _ColumnTest(_Base):
|
||||||
|
__tablename__ = "column_test"
|
||||||
|
|
||||||
|
id: Mapped[int] = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
|
||||||
|
user_type: Mapped[_UserType] = sa.Column(EnumText(enum_class=_UserType), nullable=False, default=_UserType.normal)
|
||||||
|
explicit_length: Mapped[_UserType | None] = sa.Column(
|
||||||
|
EnumText(_UserType, length=50), nullable=True, default=_UserType.normal
|
||||||
|
)
|
||||||
|
long_value: Mapped[_EnumWithLongValue] = sa.Column(EnumText(enum_class=_EnumWithLongValue), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def _first(it: Iterable[_T]) -> _T:
|
||||||
|
ls = list(it)
|
||||||
|
if not ls:
|
||||||
|
raise ValueError("List is empty")
|
||||||
|
return ls[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumText:
|
||||||
|
def test_column_impl(self):
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
columns = inspector.get_columns(_ColumnTest.__tablename__)
|
||||||
|
|
||||||
|
user_type_column = _first(c for c in columns if c["name"] == "user_type")
|
||||||
|
sql_type = user_type_column["type"]
|
||||||
|
assert isinstance(user_type_column["type"], VARCHAR)
|
||||||
|
assert sql_type.length == 20
|
||||||
|
assert user_type_column["nullable"] is False
|
||||||
|
|
||||||
|
explicit_length_column = _first(c for c in columns if c["name"] == "explicit_length")
|
||||||
|
sql_type = explicit_length_column["type"]
|
||||||
|
assert isinstance(sql_type, VARCHAR)
|
||||||
|
assert sql_type.length == 50
|
||||||
|
assert explicit_length_column["nullable"] is True
|
||||||
|
|
||||||
|
long_value_column = _first(c for c in columns if c["name"] == "long_value")
|
||||||
|
sql_type = long_value_column["type"]
|
||||||
|
assert isinstance(sql_type, VARCHAR)
|
||||||
|
assert sql_type.length == len(_EnumWithLongValue.a_really_long_enum_values)
|
||||||
|
|
||||||
|
def test_insert_and_select(self):
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
admin_user = _User(
|
||||||
|
name="admin",
|
||||||
|
user_type=_UserType.admin,
|
||||||
|
user_type_nullable=None,
|
||||||
|
)
|
||||||
|
session.add(admin_user)
|
||||||
|
session.flush()
|
||||||
|
admin_user_id = admin_user.id
|
||||||
|
|
||||||
|
normal_user = _User(
|
||||||
|
name="normal",
|
||||||
|
user_type=_UserType.normal.value,
|
||||||
|
user_type_nullable=_UserType.normal.value,
|
||||||
|
)
|
||||||
|
session.add(normal_user)
|
||||||
|
session.flush()
|
||||||
|
normal_user_id = normal_user.id
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = session.query(_User).filter(_User.id == admin_user_id).first()
|
||||||
|
assert user.user_type == _UserType.admin
|
||||||
|
assert user.user_type_nullable is None
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = session.query(_User).filter(_User.id == normal_user_id).first()
|
||||||
|
assert user.user_type == _UserType.normal
|
||||||
|
assert user.user_type_nullable == _UserType.normal
|
||||||
|
|
||||||
|
def test_insert_invalid_values(self):
|
||||||
|
def _session_insert_with_value(sess: Session, user_type: Any):
|
||||||
|
user = _User(name="test_user", user_type=user_type)
|
||||||
|
sess.add(user)
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
def _insert_with_user(sess: Session, user_type: Any):
|
||||||
|
stmt = insert(_User).values(
|
||||||
|
{
|
||||||
|
"name": "test_user",
|
||||||
|
"user_type": user_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sess.execute(stmt)
|
||||||
|
|
||||||
|
class TestCase(NamedTuple):
|
||||||
|
name: str
|
||||||
|
action: Callable[[Session], None]
|
||||||
|
exc_type: type[Exception]
|
||||||
|
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
cases = [
|
||||||
|
TestCase(
|
||||||
|
name="session insert with invalid value",
|
||||||
|
action=lambda s: _session_insert_with_value(s, "invalid"),
|
||||||
|
exc_type=ValueError,
|
||||||
|
),
|
||||||
|
TestCase(
|
||||||
|
name="session insert with invalid type",
|
||||||
|
action=lambda s: _session_insert_with_value(s, 1),
|
||||||
|
exc_type=TypeError,
|
||||||
|
),
|
||||||
|
TestCase(
|
||||||
|
name="insert with invalid value",
|
||||||
|
action=lambda s: _insert_with_user(s, "invalid"),
|
||||||
|
exc_type=ValueError,
|
||||||
|
),
|
||||||
|
TestCase(
|
||||||
|
name="insert with invalid type",
|
||||||
|
action=lambda s: _insert_with_user(s, 1),
|
||||||
|
exc_type=TypeError,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for idx, c in enumerate(cases, 1):
|
||||||
|
with pytest.raises(sa_exc.StatementError) as exc:
|
||||||
|
with Session(engine) as session:
|
||||||
|
c.action(session)
|
||||||
|
|
||||||
|
assert isinstance(exc.value.orig, c.exc_type), f"test case {idx} failed, name={c.name}"
|
||||||
|
|
||||||
|
def test_select_invalid_values(self):
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
insertion_sql = """
|
||||||
|
INSERT INTO users (id, name, user_type) VALUES
|
||||||
|
(1, 'invalid_value', 'invalid');
|
||||||
|
"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(sa.text(insertion_sql))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
with Session(engine) as session:
|
||||||
|
_user = session.query(_User).filter(_User.id == 1).first()
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import useSWRInfinite from 'swr/infinite'
|
||||||
|
import { debounce } from 'lodash-es'
|
||||||
|
import NewDatasetCard from './NewDatasetCard'
|
||||||
|
import DatasetCard from '@/app/components/datasets/list/dataset-card'
|
||||||
|
import type { DataSetListResponse, FetchDatasetsParams } from '@/models/datasets'
|
||||||
|
import { fetchDatasets } from '@/service/datasets'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const getKey = (
|
||||||
|
pageIndex: number,
|
||||||
|
previousPageData: DataSetListResponse,
|
||||||
|
tags: string[],
|
||||||
|
keyword: string,
|
||||||
|
includeAll: boolean,
|
||||||
|
) => {
|
||||||
|
if (!pageIndex || previousPageData.has_more) {
|
||||||
|
const params: FetchDatasetsParams = {
|
||||||
|
url: 'datasets',
|
||||||
|
params: {
|
||||||
|
page: pageIndex + 1,
|
||||||
|
limit: 30,
|
||||||
|
include_all: includeAll,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (tags.length)
|
||||||
|
params.params.tag_ids = tags
|
||||||
|
if (keyword)
|
||||||
|
params.params.keyword = keyword
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
|
tags: string[]
|
||||||
|
keywords: string
|
||||||
|
includeAll: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Datasets = ({
|
||||||
|
containerRef,
|
||||||
|
tags,
|
||||||
|
keywords,
|
||||||
|
includeAll,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||||
|
const { data, isLoading, setSize, mutate } = useSWRInfinite(
|
||||||
|
(pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords, includeAll),
|
||||||
|
fetchDatasets,
|
||||||
|
{ revalidateFirstPage: false, revalidateAll: true },
|
||||||
|
)
|
||||||
|
const loadingStateRef = useRef(false)
|
||||||
|
const anchorRef = useRef<HTMLAnchorElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadingStateRef.current = isLoading
|
||||||
|
}, [isLoading, t])
|
||||||
|
|
||||||
|
const onScroll = useCallback(
|
||||||
|
debounce(() => {
|
||||||
|
if (!loadingStateRef.current && containerRef.current && anchorRef.current) {
|
||||||
|
const { scrollTop, clientHeight } = containerRef.current
|
||||||
|
const anchorOffset = anchorRef.current.offsetTop
|
||||||
|
if (anchorOffset - scrollTop - clientHeight < 100)
|
||||||
|
setSize(size => size + 1)
|
||||||
|
}
|
||||||
|
}, 50),
|
||||||
|
[setSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentContainer = containerRef.current
|
||||||
|
currentContainer?.addEventListener('scroll', onScroll)
|
||||||
|
return () => {
|
||||||
|
currentContainer?.removeEventListener('scroll', onScroll)
|
||||||
|
onScroll.cancel()
|
||||||
|
}
|
||||||
|
}, [onScroll])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className='grid shrink-0 grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'>
|
||||||
|
{isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} />}
|
||||||
|
{data?.map(({ data: datasets }) => datasets.map(dataset => (
|
||||||
|
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />),
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Datasets
|
||||||
@ -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