Merge branch 'main' into feat/r2
# Conflicts: # docker/docker-compose.middleware.yamlfeat/datasource
commit
1d8b390584
@ -0,0 +1,14 @@
|
||||
# Debugging with VS Code
|
||||
|
||||
This `launch.json.template` file provides various debug configurations for the Dify project within VS Code / Cursor. To use these configurations, you should copy the contents of this file into a new file named `launch.json` in the same `.vscode` directory.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create `launch.json`**: If you don't have one, create a file named `launch.json` inside the `.vscode` directory.
|
||||
2. **Copy Content**: Copy the entire content from `launch.json.template` into your newly created `launch.json` file.
|
||||
3. **Select Debug Configuration**: Go to the Run and Debug view in VS Code / Cursor (Ctrl+Shift+D or Cmd+Shift+D).
|
||||
4. **Start Debugging**: Select the desired configuration from the dropdown menu and click the green play button.
|
||||
|
||||
## Tips
|
||||
|
||||
- If you need to debug with Edge browser instead of Chrome, modify the `serverReadyAction` configuration in the "Next.js: debug full stack" section, change `"debugWithChrome"` to `"debugWithEdge"` to use Microsoft Edge for debugging.
|
||||
@ -0,0 +1,68 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Flask API",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"env": {
|
||||
"FLASK_APP": "app.py",
|
||||
"FLASK_ENV": "development",
|
||||
"GEVENT_SUPPORT": "True"
|
||||
},
|
||||
"args": [
|
||||
"run",
|
||||
"--host=0.0.0.0",
|
||||
"--port=5001",
|
||||
"--no-debugger",
|
||||
"--no-reload"
|
||||
],
|
||||
"jinja": true,
|
||||
"justMyCode": true,
|
||||
"cwd": "${workspaceFolder}/api",
|
||||
"python": "${workspaceFolder}/api/.venv/bin/python"
|
||||
},
|
||||
{
|
||||
"name": "Python: Celery Worker (Solo)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "celery",
|
||||
"env": {
|
||||
"GEVENT_SUPPORT": "True"
|
||||
},
|
||||
"args": [
|
||||
"-A",
|
||||
"app.celery",
|
||||
"worker",
|
||||
"-P",
|
||||
"solo",
|
||||
"-c",
|
||||
"1",
|
||||
"-Q",
|
||||
"dataset,generation,mail,ops_trace",
|
||||
"--loglevel",
|
||||
"INFO"
|
||||
],
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}/api",
|
||||
"python": "${workspaceFolder}/api/.venv/bin/python"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/web/node_modules/next/dist/bin/next",
|
||||
"runtimeArgs": ["--inspect"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithChrome",
|
||||
"killOnServerStop": true,
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}/web"
|
||||
},
|
||||
"cwd": "${workspaceFolder}/web"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
from flask import request
|
||||
from flask_restful import Resource, reqparse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
EmailPasswordResetLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.wraps import email_password_login_enabled, only_edition_enterprise, setup_required
|
||||
from controllers.web import api
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import email, extract_remote_ip
|
||||
from libs.password import hash_password, valid_password
|
||||
from models.account import Account
|
||||
from services.account_service import AccountService
|
||||
|
||||
|
||||
class ForgotPasswordSendEmailApi(Resource):
|
||||
@only_edition_enterprise
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
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()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
|
||||
if args["language"] is not None and args["language"] == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
with Session(db.engine) as session:
|
||||
account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none()
|
||||
token = None
|
||||
if account is None:
|
||||
raise AccountNotFound()
|
||||
else:
|
||||
token = AccountService.send_reset_password_email(account=account, email=args["email"], language=language)
|
||||
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
|
||||
class ForgotPasswordCheckApi(Resource):
|
||||
@only_edition_enterprise
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
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, nullable=False, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
user_email = args["email"]
|
||||
|
||||
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(args["email"])
|
||||
if is_forgot_password_error_rate_limit:
|
||||
raise EmailPasswordResetLimitError()
|
||||
|
||||
token_data = AccountService.get_reset_password_data(args["token"])
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if user_email != token_data.get("email"):
|
||||
raise InvalidEmailError()
|
||||
|
||||
if args["code"] != token_data.get("code"):
|
||||
AccountService.add_forgot_password_error_rate_limit(args["email"])
|
||||
raise EmailCodeError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_reset_password_token(args["token"])
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
_, new_token = AccountService.generate_reset_password_token(
|
||||
user_email, code=args["code"], additional_data={"phase": "reset"}
|
||||
)
|
||||
|
||||
AccountService.reset_forgot_password_error_rate_limit(args["email"])
|
||||
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
|
||||
|
||||
|
||||
class ForgotPasswordResetApi(Resource):
|
||||
@only_edition_enterprise
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("token", type=str, required=True, nullable=False, location="json")
|
||||
parser.add_argument("new_password", type=valid_password, required=True, nullable=False, location="json")
|
||||
parser.add_argument("password_confirm", type=valid_password, required=True, nullable=False, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate passwords match
|
||||
if args["new_password"] != args["password_confirm"]:
|
||||
raise PasswordMismatchError()
|
||||
|
||||
# Validate token and get reset data
|
||||
reset_data = AccountService.get_reset_password_data(args["token"])
|
||||
if not reset_data:
|
||||
raise InvalidTokenError()
|
||||
# Must use token in reset phase
|
||||
if reset_data.get("phase", "") != "reset":
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Revoke token to prevent reuse
|
||||
AccountService.revoke_reset_password_token(args["token"])
|
||||
|
||||
# Generate secure salt and hash password
|
||||
salt = secrets.token_bytes(16)
|
||||
password_hashed = hash_password(args["new_password"], salt)
|
||||
|
||||
email = reset_data.get("email", "")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
account = session.execute(select(Account).filter_by(email=email)).scalar_one_or_none()
|
||||
|
||||
if account:
|
||||
self._update_existing_account(account, password_hashed, salt, session)
|
||||
else:
|
||||
raise AccountNotFound()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
def _update_existing_account(self, account, password_hashed, salt, session):
|
||||
# Update existing account credentials
|
||||
account.password = base64.b64encode(password_hashed).decode()
|
||||
account.password_salt = base64.b64encode(salt).decode()
|
||||
session.commit()
|
||||
|
||||
|
||||
api.add_resource(ForgotPasswordSendEmailApi, "/forgot-password")
|
||||
api.add_resource(ForgotPasswordCheckApi, "/forgot-password/validity")
|
||||
api.add_resource(ForgotPasswordResetApi, "/forgot-password/resets")
|
||||
@ -1,3 +1,4 @@
|
||||
- audio
|
||||
- code
|
||||
- time
|
||||
- qrcode
|
||||
- webscraper
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional, cast
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
|
||||
from core.entities.provider_entities import QuotaUnit
|
||||
from core.file.models import File
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.model_runtime.entities.llm_entities import LLMUsage
|
||||
from core.model_runtime.entities.model_entities import ModelType
|
||||
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
|
||||
from core.plugin.entities.plugin import ModelProviderID
|
||||
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
||||
from core.variables.segments import ArrayAnySegment, ArrayFileSegment, FileSegment, NoneSegment, StringSegment
|
||||
from core.workflow.entities.variable_pool import VariablePool
|
||||
from core.workflow.enums import SystemVariableKey
|
||||
from core.workflow.nodes.llm.entities import ModelConfig
|
||||
from models import db
|
||||
from models.model import Conversation
|
||||
from models.provider import Provider, ProviderType
|
||||
|
||||
from .exc import InvalidVariableTypeError, LLMModeRequiredError, ModelNotExistError
|
||||
|
||||
|
||||
def fetch_model_config(
|
||||
tenant_id: str, node_data_model: ModelConfig
|
||||
) -> tuple[ModelInstance, ModelConfigWithCredentialsEntity]:
|
||||
if not node_data_model.mode:
|
||||
raise LLMModeRequiredError("LLM mode is required.")
|
||||
|
||||
model = ModelManager().get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=node_data_model.provider,
|
||||
model=node_data_model.name,
|
||||
)
|
||||
|
||||
model.model_type_instance = cast(LargeLanguageModel, model.model_type_instance)
|
||||
|
||||
# check model
|
||||
provider_model = model.provider_model_bundle.configuration.get_provider_model(
|
||||
model=node_data_model.name, model_type=ModelType.LLM
|
||||
)
|
||||
|
||||
if provider_model is None:
|
||||
raise ModelNotExistError(f"Model {node_data_model.name} not exist.")
|
||||
provider_model.raise_for_status()
|
||||
|
||||
# model config
|
||||
stop: list[str] = []
|
||||
if "stop" in node_data_model.completion_params:
|
||||
stop = node_data_model.completion_params.pop("stop")
|
||||
|
||||
model_schema = model.model_type_instance.get_model_schema(node_data_model.name, model.credentials)
|
||||
if not model_schema:
|
||||
raise ModelNotExistError(f"Model {node_data_model.name} not exist.")
|
||||
|
||||
return model, ModelConfigWithCredentialsEntity(
|
||||
provider=node_data_model.provider,
|
||||
model=node_data_model.name,
|
||||
model_schema=model_schema,
|
||||
mode=node_data_model.mode,
|
||||
provider_model_bundle=model.provider_model_bundle,
|
||||
credentials=model.credentials,
|
||||
parameters=node_data_model.completion_params,
|
||||
stop=stop,
|
||||
)
|
||||
|
||||
|
||||
def fetch_files(variable_pool: VariablePool, selector: Sequence[str]) -> Sequence["File"]:
|
||||
variable = variable_pool.get(selector)
|
||||
if variable is None:
|
||||
return []
|
||||
elif isinstance(variable, FileSegment):
|
||||
return [variable.value]
|
||||
elif isinstance(variable, ArrayFileSegment):
|
||||
return variable.value
|
||||
elif isinstance(variable, NoneSegment | ArrayAnySegment):
|
||||
return []
|
||||
raise InvalidVariableTypeError(f"Invalid variable type: {type(variable)}")
|
||||
|
||||
|
||||
def fetch_memory(
|
||||
variable_pool: VariablePool, app_id: str, node_data_memory: Optional[MemoryConfig], model_instance: ModelInstance
|
||||
) -> Optional[TokenBufferMemory]:
|
||||
if not node_data_memory:
|
||||
return None
|
||||
|
||||
# get conversation id
|
||||
conversation_id_variable = variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID.value])
|
||||
if not isinstance(conversation_id_variable, StringSegment):
|
||||
return None
|
||||
conversation_id = conversation_id_variable.value
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
stmt = select(Conversation).where(Conversation.app_id == app_id, Conversation.id == conversation_id)
|
||||
conversation = session.scalar(stmt)
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
|
||||
return memory
|
||||
|
||||
|
||||
def deduct_llm_quota(tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None:
|
||||
provider_model_bundle = model_instance.provider_model_bundle
|
||||
provider_configuration = provider_model_bundle.configuration
|
||||
|
||||
if provider_configuration.using_provider_type != ProviderType.SYSTEM:
|
||||
return
|
||||
|
||||
system_configuration = provider_configuration.system_configuration
|
||||
|
||||
quota_unit = None
|
||||
for quota_configuration in system_configuration.quota_configurations:
|
||||
if quota_configuration.quota_type == system_configuration.current_quota_type:
|
||||
quota_unit = quota_configuration.quota_unit
|
||||
|
||||
if quota_configuration.quota_limit == -1:
|
||||
return
|
||||
|
||||
break
|
||||
|
||||
used_quota = None
|
||||
if quota_unit:
|
||||
if quota_unit == QuotaUnit.TOKENS:
|
||||
used_quota = usage.total_tokens
|
||||
elif quota_unit == QuotaUnit.CREDITS:
|
||||
used_quota = dify_config.get_model_credits(model_instance.model)
|
||||
else:
|
||||
used_quota = 1
|
||||
|
||||
if used_quota is not None and system_configuration.current_quota_type is not None:
|
||||
with Session(db.engine) as session:
|
||||
stmt = (
|
||||
update(Provider)
|
||||
.where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
# TODO: Use provider name with prefix after the data migration.
|
||||
Provider.provider_name == ModelProviderID(model_instance.provider).provider_name,
|
||||
Provider.provider_type == ProviderType.SYSTEM.value,
|
||||
Provider.quota_type == system_configuration.current_quota_type.value,
|
||||
Provider.quota_limit > Provider.quota_used,
|
||||
)
|
||||
.values(
|
||||
quota_used=Provider.quota_used + used_quota,
|
||||
last_used=datetime.now(tz=UTC).replace(tzinfo=None),
|
||||
)
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
@ -0,0 +1,60 @@
|
||||
"""`workflow_draft_varaibles` add `node_execution_id` column, add an index for `workflow_node_executions`.
|
||||
|
||||
Revision ID: 4474872b0ee6
|
||||
Revises: 2adcbe1f5dfb
|
||||
Create Date: 2025-06-06 14:24:44.213018
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4474872b0ee6'
|
||||
down_revision = '2adcbe1f5dfb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# `CREATE INDEX CONCURRENTLY` cannot run within a transaction, so use the `autocommit_block`
|
||||
# context manager to wrap the index creation statement.
|
||||
# Reference:
|
||||
#
|
||||
# - https://www.postgresql.org/docs/current/sql-createindex.html#:~:text=Another%20difference%20is,CREATE%20INDEX%20CONCURRENTLY%20cannot.
|
||||
# - https://alembic.sqlalchemy.org/en/latest/api/runtime.html#alembic.runtime.migration.MigrationContext.autocommit_block
|
||||
with op.get_context().autocommit_block():
|
||||
op.create_index(
|
||||
op.f('workflow_node_executions_tenant_id_idx'),
|
||||
"workflow_node_executions",
|
||||
['tenant_id', 'workflow_id', 'node_id', sa.literal_column('created_at DESC')],
|
||||
unique=False,
|
||||
postgresql_concurrently=True,
|
||||
)
|
||||
|
||||
with op.batch_alter_table('workflow_draft_variables', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('node_execution_id', models.types.StringUUID(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
|
||||
# `DROP INDEX CONCURRENTLY` cannot run within a transaction, so use the `autocommit_block`
|
||||
# context manager to wrap the index creation statement.
|
||||
# Reference:
|
||||
#
|
||||
# - https://www.postgresql.org/docs/current/sql-createindex.html#:~:text=Another%20difference%20is,CREATE%20INDEX%20CONCURRENTLY%20cannot.
|
||||
# - https://alembic.sqlalchemy.org/en/latest/api/runtime.html#alembic.runtime.migration.MigrationContext.autocommit_block
|
||||
# `DROP INDEX CONCURRENTLY` cannot run within a transaction, so commit existing transactions first.
|
||||
# Reference:
|
||||
#
|
||||
# https://www.postgresql.org/docs/current/sql-createindex.html#:~:text=Another%20difference%20is,CREATE%20INDEX%20CONCURRENTLY%20cannot.
|
||||
with op.get_context().autocommit_block():
|
||||
op.drop_index(op.f('workflow_node_executions_tenant_id_idx'), postgresql_concurrently=True)
|
||||
|
||||
with op.batch_alter_table('workflow_draft_variables', schema=None) as batch_op:
|
||||
batch_op.drop_column('node_execution_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@ -0,0 +1,62 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import click
|
||||
from flask import render_template
|
||||
from redis import Redis
|
||||
|
||||
import app
|
||||
from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_mail import mail
|
||||
|
||||
# Create a dedicated Redis connection (using the same configuration as Celery)
|
||||
celery_broker_url = dify_config.CELERY_BROKER_URL
|
||||
|
||||
parsed = urlparse(celery_broker_url)
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or 6379
|
||||
password = parsed.password or None
|
||||
redis_db = parsed.path.strip("/") or "1" # type: ignore
|
||||
|
||||
celery_redis = Redis(host=host, port=port, password=password, db=redis_db)
|
||||
|
||||
|
||||
@app.celery.task(queue="monitor")
|
||||
def queue_monitor_task():
|
||||
queue_name = "dataset"
|
||||
threshold = dify_config.QUEUE_MONITOR_THRESHOLD
|
||||
|
||||
try:
|
||||
queue_length = celery_redis.llen(f"{queue_name}")
|
||||
logging.info(click.style(f"Start monitor {queue_name}", fg="green"))
|
||||
logging.info(click.style(f"Queue length: {queue_length}", fg="green"))
|
||||
|
||||
if queue_length >= threshold:
|
||||
warning_msg = f"Queue {queue_name} task count exceeded the limit.: {queue_length}/{threshold}"
|
||||
logging.warning(click.style(warning_msg, fg="red"))
|
||||
alter_emails = dify_config.QUEUE_MONITOR_ALERT_EMAILS
|
||||
if alter_emails:
|
||||
to_list = alter_emails.split(",")
|
||||
for to in to_list:
|
||||
try:
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
html_content = render_template(
|
||||
"queue_monitor_alert_email_template_en-US.html",
|
||||
queue_name=queue_name,
|
||||
queue_length=queue_length,
|
||||
threshold=threshold,
|
||||
alert_time=current_time,
|
||||
)
|
||||
mail.send(
|
||||
to=to, subject="Alert: Dataset Queue pending tasks exceeded the limit", html=html_content
|
||||
)
|
||||
except Exception as e:
|
||||
logging.exception(click.style("Exception occurred during sending email", fg="red"))
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(click.style("Exception occurred during queue monitoring", fg="red"))
|
||||
finally:
|
||||
if db.session.is_active:
|
||||
db.session.close()
|
||||
@ -0,0 +1,129 @@
|
||||
<!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;
|
||||
min-height: 605px;
|
||||
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;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
border-radius: 16px;
|
||||
background-color: #fef0f0;
|
||||
margin: 16px auto;
|
||||
border: 1px solid #fda29b;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.alert-detail {
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.typography {
|
||||
letter-spacing: -0.07px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #354052;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.typography p{
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.typography-title {
|
||||
color: #101828;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tip-list{
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
|
||||
</div>
|
||||
<p class="title">Queue Monitoring Alert</p>
|
||||
<p class="typography">Our system has detected an abnormal queue status that requires your attention:</p>
|
||||
|
||||
<div class="alert-content">
|
||||
<div class="alert-title">Queue Task Alert</div>
|
||||
<div class="alert-detail">
|
||||
Queue "{{queue_name}}" has {{queue_length}} pending tasks (Threshold: {{threshold}})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="typography">
|
||||
<p style="margin-bottom:4px">Recommended actions:</p>
|
||||
<p>1. Check the queue processing status in the system dashboard</p>
|
||||
<p>2. Verify if there are any processing bottlenecks</p>
|
||||
<p>3. Consider scaling up workers if needed</p>
|
||||
</div>
|
||||
|
||||
<p class="typography-title">Additional Information:</p>
|
||||
<ul class="typography tip-list">
|
||||
<li>Alert triggered at: {{alert_time}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
|
||||
import I18NContext from '@/context/i18n'
|
||||
|
||||
export default function CheckCode() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const email = decodeURIComponent(searchParams.get('email') as string)
|
||||
const token = decodeURIComponent(searchParams.get('token') as string)
|
||||
const [code, setVerifyCode] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const { locale } = useContext(I18NContext)
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
if (!code.trim()) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.checkCode.emptyCode'),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!/\d{6}/.test(code)) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.checkCode.invalidCode'),
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
const ret = await verifyWebAppResetPasswordCode({ email, code, token })
|
||||
if (ret.is_valid) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('token', encodeURIComponent(ret.token))
|
||||
router.push(`/webapp-reset-password/set-password?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resendCode = async () => {
|
||||
try {
|
||||
const res = await sendWebAppResetPasswordCode(email, locale)
|
||||
if (res.result === 'success') {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('token', encodeURIComponent(res.data))
|
||||
router.replace(`/webapp-reset-password/check-code?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}
|
||||
|
||||
return <div className='flex flex-col gap-3'>
|
||||
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg'>
|
||||
<RiMailSendFill className='h-6 w-6 text-2xl' />
|
||||
</div>
|
||||
<div className='pb-4 pt-2'>
|
||||
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
|
||||
<p className='body-md-regular mt-2 text-text-secondary'>
|
||||
<span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span>
|
||||
<br />
|
||||
{t('login.checkCode.validTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action="">
|
||||
<input type='text' className='hidden' />
|
||||
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
|
||||
<Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
|
||||
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
|
||||
<Countdown onResend={resendCode} />
|
||||
</form>
|
||||
<div className='py-2'>
|
||||
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||
</div>
|
||||
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
|
||||
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
|
||||
<RiArrowLeftLine size={12} />
|
||||
</div>
|
||||
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import Header from '@/app/signin/_header'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
return <>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={
|
||||
cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-[108px]',
|
||||
)
|
||||
}>
|
||||
<div className='flex w-[400px] flex-col'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
|
||||
import { emailRegex } from '@/config'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { sendResetPasswordCode } from '@/service/common'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { noop } from 'lodash-es'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
export default function CheckCode() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle('')
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const { locale } = useContext(I18NContext)
|
||||
|
||||
const handleGetEMailVerificationCode = async () => {
|
||||
try {
|
||||
if (!email) {
|
||||
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(email)) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.error.emailInValid'),
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
const res = await sendResetPasswordCode(email, locale)
|
||||
if (res.result === 'success') {
|
||||
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('token', encodeURIComponent(res.data))
|
||||
params.set('email', encodeURIComponent(email))
|
||||
router.push(`/webapp-reset-password/check-code?${params.toString()}`)
|
||||
}
|
||||
else if (res.code === 'account_not_found') {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.error.registrationNotAllowed'),
|
||||
})
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: res.data,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <div className='flex flex-col gap-3'>
|
||||
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
|
||||
<RiLockPasswordLine className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
|
||||
</div>
|
||||
<div className='pb-4 pt-2'>
|
||||
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.resetPassword')}</h2>
|
||||
<p className='body-md-regular mt-2 text-text-secondary'>
|
||||
{t('login.resetPasswordDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={noop}>
|
||||
<input type='text' className='hidden' />
|
||||
<div className='mb-2'>
|
||||
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
|
||||
<div className='mt-1'>
|
||||
<Input id='email' type="email" disabled={loading} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className='mt-3'>
|
||||
<Button loading={loading} disabled={loading} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.sendVerificationCode')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className='py-2'>
|
||||
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||
</div>
|
||||
<Link href={`/webapp-signin?${searchParams.toString()}`} className='flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary'>
|
||||
<div className='inline-block rounded-full bg-background-default-dimmed p-1'>
|
||||
<RiArrowLeftLine size={12} />
|
||||
</div>
|
||||
<span className='system-xs-regular ml-2'>{t('login.backToLogin')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import cn from 'classnames'
|
||||
import { RiCheckboxCircleFill } from '@remixicon/react'
|
||||
import { useCountDown } from 'ahooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { changeWebAppPasswordWithToken } from '@/service/common'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
|
||||
|
||||
const ChangePasswordForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = decodeURIComponent(searchParams.get('token') || '')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
const showErrorMessage = useCallback((message: string) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const getSignInUrl = () => {
|
||||
return `/webapp-signin?redirect_url=${searchParams.get('redirect_url') || ''}`
|
||||
}
|
||||
|
||||
const AUTO_REDIRECT_TIME = 5000
|
||||
const [leftTime, setLeftTime] = useState<number | undefined>(undefined)
|
||||
const [countdown] = useCountDown({
|
||||
leftTime,
|
||||
onEnd: () => {
|
||||
router.replace(getSignInUrl())
|
||||
},
|
||||
})
|
||||
|
||||
const valid = useCallback(() => {
|
||||
if (!password.trim()) {
|
||||
showErrorMessage(t('login.error.passwordEmpty'))
|
||||
return false
|
||||
}
|
||||
if (!validPassword.test(password)) {
|
||||
showErrorMessage(t('login.error.passwordInvalid'))
|
||||
return false
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
showErrorMessage(t('common.account.notEqual'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [password, confirmPassword, showErrorMessage, t])
|
||||
|
||||
const handleChangePassword = useCallback(async () => {
|
||||
if (!valid())
|
||||
return
|
||||
try {
|
||||
await changeWebAppPasswordWithToken({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token,
|
||||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
},
|
||||
})
|
||||
setShowSuccess(true)
|
||||
setLeftTime(AUTO_REDIRECT_TIME)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, [password, token, valid, confirmPassword])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
'flex w-full grow flex-col items-center justify-center',
|
||||
'px-6',
|
||||
'md:px-[108px]',
|
||||
)
|
||||
}>
|
||||
{!showSuccess && (
|
||||
<div className='flex flex-col md:w-[400px]'>
|
||||
<div className="mx-auto w-full">
|
||||
<h2 className="title-4xl-semi-bold text-text-primary">
|
||||
{t('login.changePassword')}
|
||||
</h2>
|
||||
<p className='body-md-regular mt-2 text-text-secondary'>
|
||||
{t('login.changePasswordTip')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 w-full">
|
||||
<div className="bg-white">
|
||||
{/* Password */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="password" className="system-md-semibold my-2 text-text-secondary">
|
||||
{t('common.account.newPassword')}
|
||||
</label>
|
||||
<div className='relative mt-1'>
|
||||
<Input
|
||||
id="password" type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={t('login.passwordPlaceholder') || ''}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant='ghost'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? '👀' : '😝'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='body-xs-regular mt-1 text-text-secondary'>{t('login.error.passwordInvalid')}</div>
|
||||
</div>
|
||||
{/* Confirm Password */}
|
||||
<div className='mb-5'>
|
||||
<label htmlFor="confirmPassword" className="system-md-semibold my-2 text-text-secondary">
|
||||
{t('common.account.confirmPassword')}
|
||||
</label>
|
||||
<div className='relative mt-1'>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('login.confirmPasswordPlaceholder') || ''}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? '👀' : '😝'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-full'
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
{t('login.changePasswordBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showSuccess && (
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="mx-auto w-full">
|
||||
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
|
||||
<RiCheckboxCircleFill className='h-6 w-6 text-text-success' />
|
||||
</div>
|
||||
<h2 className="title-4xl-semi-bold text-text-primary">
|
||||
{t('login.passwordChangedTip')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mx-auto mt-6 w-full">
|
||||
<Button variant='primary' className='w-full' onClick={() => {
|
||||
setLeftTime(undefined)
|
||||
router.replace(getSignInUrl())
|
||||
}}>{t('login.passwordChanged')} ({Math.round(countdown / 1000)}) </Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePasswordForm
|
||||
@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { setAccessToken } from '@/app/components/share/utils'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
|
||||
export default function CheckCode() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const email = decodeURIComponent(searchParams.get('email') as string)
|
||||
const token = decodeURIComponent(searchParams.get('token') as string)
|
||||
const [code, setVerifyCode] = useState('')
|
||||
const [loading, setIsLoading] = useState(false)
|
||||
const { locale } = useContext(I18NContext)
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
const appCode = redirectUrl?.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
|
||||
const verify = async () => {
|
||||
try {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
if (!code.trim()) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.checkCode.emptyCode'),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!/\d{6}/.test(code)) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.checkCode.invalidCode'),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!redirectUrl || !appCode) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('login.error.redirectUrlMissing'),
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
const ret = await webAppEmailLoginWithCode({ email, code, token })
|
||||
if (ret.result === 'success') {
|
||||
localStorage.setItem('webapp_access_token', ret.data.access_token)
|
||||
const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: ret.data.access_token })
|
||||
await setAccessToken(appCode, tokenResp.access_token)
|
||||
router.replace(redirectUrl)
|
||||
}
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resendCode = async () => {
|
||||
try {
|
||||
const ret = await sendWebAppEMailLoginCode(email, locale)
|
||||
if (ret.result === 'success') {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('token', encodeURIComponent(ret.data))
|
||||
router.replace(`/webapp-signin/check-code?${params.toString()}`)
|
||||
}
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}
|
||||
|
||||
return <div className='flex w-[400px] flex-col gap-3'>
|
||||
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
|
||||
<RiMailSendFill className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
|
||||
</div>
|
||||
<div className='pb-4 pt-2'>
|
||||
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
|
||||
<p className='body-md-regular mt-2 text-text-secondary'>
|
||||
<span dangerouslySetInnerHTML={{ __html: t('login.checkCode.tips', { email }) as string }}></span>
|
||||
<br />
|
||||
{t('login.checkCode.validTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action="">
|
||||
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
|
||||
<Input value={code} onChange={e => setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} />
|
||||
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
|
||||
<Countdown onResend={resendCode} />
|
||||
</form>
|
||||
<div className='py-2'>
|
||||
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
|
||||
</div>
|
||||
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
|
||||
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
|
||||
<RiArrowLeftLine size={12} />
|
||||
</div>
|
||||
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { SSOProtocol } from '@/types/feature'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
|
||||
const ExternalMemberSSOAuth = () => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const redirectUrl = searchParams.get('redirect_url')
|
||||
|
||||
const showErrorToast = (message: string) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
const getAppCodeFromRedirectUrl = useCallback(() => {
|
||||
const appCode = redirectUrl?.split('/').pop()
|
||||
if (!appCode)
|
||||
return null
|
||||
|
||||
return appCode
|
||||
}, [redirectUrl])
|
||||
|
||||
const handleSSOLogin = useCallback(async () => {
|
||||
const appCode = getAppCodeFromRedirectUrl()
|
||||
if (!appCode || !redirectUrl) {
|
||||
showErrorToast('redirect url or app code is invalid.')
|
||||
return
|
||||
}
|
||||
|
||||
switch (systemFeatures.webapp_auth.sso_config.protocol) {
|
||||
case SSOProtocol.SAML: {
|
||||
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
|
||||
router.push(samlRes.url)
|
||||
break
|
||||
}
|
||||
case SSOProtocol.OIDC: {
|
||||
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
|
||||
router.push(oidcRes.url)
|
||||
break
|
||||
}
|
||||
case SSOProtocol.OAuth2: {
|
||||
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
|
||||
router.push(oauth2Res.url)
|
||||
break
|
||||
}
|
||||
case '':
|
||||
break
|
||||
default:
|
||||
showErrorToast('SSO protocol is not supported.')
|
||||
}
|
||||
}, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
|
||||
|
||||
useEffect(() => {
|
||||
handleSSOLogin()
|
||||
}, [handleSSOLogin])
|
||||
|
||||
if (!systemFeatures.webapp_auth.sso_config.protocol) {
|
||||
return <div className="flex h-full items-center justify-center">
|
||||
<AppUnavailable code={403} unknownReason='sso protocol is invalid.' />
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ExternalMemberSSOAuth)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue