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
|
- code
|
||||||
- time
|
- 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