From cd51d3323b7bf0ae5e456a3c0bba11cac36dad34 Mon Sep 17 00:00:00 2001 From: KVOJJJin Date: Fri, 14 Jul 2023 11:19:26 +0800 Subject: [PATCH] feat: member invitation and activation (#535) Co-authored-by: John Wang --- api/.env.example | 19 +- api/Dockerfile | 8 +- api/app.py | 3 +- api/config.py | 20 +- api/controllers/console/__init__.py | 2 +- api/controllers/console/auth/activate.py | 75 ++++++ .../console/auth/data_source_oauth.py | 10 +- api/controllers/console/auth/oauth.py | 6 +- api/controllers/console/error.py | 6 + api/controllers/console/workspace/account.py | 12 +- api/controllers/console/workspace/error.py | 6 + api/controllers/console/workspace/members.py | 18 +- api/extensions/ext_mail.py | 61 +++++ api/models/account.py | 4 + api/models/model.py | 5 +- api/requirements.txt | 5 +- api/services/account_service.py | 102 +++++++- api/tasks/mail_invite_member_task.py | 52 ++++ docker/docker-compose.yaml | 38 ++- web/Dockerfile | 4 +- web/app/activate/activateForm.tsx | 233 ++++++++++++++++++ web/app/activate/page.tsx | 32 +++ web/app/activate/style.module.css | 4 + web/app/activate/team-28x28.png | Bin 0 -> 7349 bytes web/app/components/base/select/locale.tsx | 25 +- .../header/account-about/index.module.css | 2 +- .../components/header/account-about/index.tsx | 2 +- .../account-setting/account-page/index.tsx | 200 +++++++++++---- .../header/account-setting/index.tsx | 53 ++-- .../account-setting/members-page/index.tsx | 5 +- .../members-page/invite-modal/index.tsx | 24 +- .../invited-modal/assets/copied.svg | 3 + .../invited-modal/assets/copy-hover.svg | 3 + .../invited-modal/assets/copy.svg | 3 + .../invited-modal/index.module.css | 16 ++ .../members-page/invited-modal/index.tsx | 24 +- .../invited-modal/invitation-link.tsx | 63 +++++ web/app/install/installForm.tsx | 70 +++--- web/app/signin/_header.tsx | 8 +- web/app/signin/forms.tsx | 5 +- web/app/signin/normalForm.tsx | 46 ++-- web/app/signin/oneMoreStep.tsx | 34 ++- web/app/signin/page.tsx | 11 +- web/context/app-context.tsx | 2 + web/docker/entrypoint.sh | 15 +- web/i18n/lang/common.en.ts | 16 +- web/i18n/lang/common.zh.ts | 15 +- web/i18n/lang/login.en.ts | 90 ++++--- web/i18n/lang/login.zh.ts | 90 ++++--- web/models/common.ts | 2 + web/service/common.ts | 12 +- 51 files changed, 1235 insertions(+), 329 deletions(-) create mode 100644 api/controllers/console/auth/activate.py create mode 100644 api/extensions/ext_mail.py create mode 100644 api/tasks/mail_invite_member_task.py create mode 100644 web/app/activate/activateForm.tsx create mode 100644 web/app/activate/page.tsx create mode 100644 web/app/activate/style.module.css create mode 100644 web/app/activate/team-28x28.png create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/assets/copied.svg create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/assets/copy-hover.svg create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/assets/copy.svg create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx diff --git a/api/.env.example b/api/.env.example index 8cff79096a..3ea7e5be34 100644 --- a/api/.env.example +++ b/api/.env.example @@ -8,13 +8,19 @@ EDITION=SELF_HOSTED SECRET_KEY= # Console API base URL -CONSOLE_URL=http://127.0.0.1:5001 +CONSOLE_API_URL=http://127.0.0.1:5001 + +# Console frontend web base URL +CONSOLE_WEB_URL=http://127.0.0.1:3000 # Service API base URL -API_URL=http://127.0.0.1:5001 +SERVICE_API_URL=http://127.0.0.1:5001 -# Web APP base URL -APP_URL=http://127.0.0.1:3000 +# Web APP API base URL +APP_API_URL=http://127.0.0.1:5001 + +# Web APP frontend web base URL +APP_WEB_URL=http://127.0.0.1:3000 # celery configuration CELERY_BROKER_URL=redis://:difyai123456@localhost:6379/1 @@ -79,6 +85,11 @@ WEAVIATE_BATCH_SIZE=100 QDRANT_URL=path:storage/qdrant QDRANT_API_KEY=your-qdrant-api-key +# Mail configuration, support: resend +MAIL_TYPE= +MAIL_DEFAULT_SEND_FROM=no-reply +RESEND_API_KEY= + # Sentry configuration SENTRY_DSN= diff --git a/api/Dockerfile b/api/Dockerfile index fb451129d1..0fe7754957 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,9 +5,11 @@ LABEL maintainer="takatost@gmail.com" ENV FLASK_APP app.py ENV EDITION SELF_HOSTED ENV DEPLOY_ENV PRODUCTION -ENV CONSOLE_URL http://127.0.0.1:5001 -ENV API_URL http://127.0.0.1:5001 -ENV APP_URL http://127.0.0.1:5001 +ENV CONSOLE_API_URL http://127.0.0.1:5001 +ENV CONSOLE_WEB_URL http://127.0.0.1:3000 +ENV SERVICE_API_URL http://127.0.0.1:5001 +ENV APP_API_URL http://127.0.0.1:5001 +ENV APP_WEB_URL http://127.0.0.1:3000 EXPOSE 5001 diff --git a/api/app.py b/api/app.py index 886b535040..a281285222 100644 --- a/api/app.py +++ b/api/app.py @@ -15,7 +15,7 @@ import flask_login from flask_cors import CORS from extensions import ext_session, ext_celery, ext_sentry, ext_redis, ext_login, ext_migrate, \ - ext_database, ext_storage + ext_database, ext_storage, ext_mail from extensions.ext_database import db from extensions.ext_login import login_manager @@ -83,6 +83,7 @@ def initialize_extensions(app): ext_celery.init_app(app) ext_session.init_app(app) ext_login.init_app(app) + ext_mail.init_app(app) ext_sentry.init_app(app) diff --git a/api/config.py b/api/config.py index 8c9be617b7..5a0cd304a2 100644 --- a/api/config.py +++ b/api/config.py @@ -28,9 +28,11 @@ DEFAULTS = { 'SESSION_REDIS_USE_SSL': 'False', 'OAUTH_REDIRECT_PATH': '/console/api/oauth/authorize', 'OAUTH_REDIRECT_INDEX_PATH': '/', - 'CONSOLE_URL': 'https://cloud.dify.ai', - 'API_URL': 'https://api.dify.ai', - 'APP_URL': 'https://udify.app', + 'CONSOLE_WEB_URL': 'https://cloud.dify.ai', + 'CONSOLE_API_URL': 'https://cloud.dify.ai', + 'SERVICE_API_URL': 'https://api.dify.ai', + 'APP_WEB_URL': 'https://udify.app', + 'APP_API_URL': 'https://udify.app', 'STORAGE_TYPE': 'local', 'STORAGE_LOCAL_PATH': 'storage', 'CHECK_UPDATE_URL': 'https://updates.dify.ai', @@ -76,6 +78,11 @@ class Config: def __init__(self): # app settings + self.CONSOLE_API_URL = get_env('CONSOLE_URL') if get_env('CONSOLE_URL') else get_env('CONSOLE_API_URL') + self.CONSOLE_WEB_URL = get_env('CONSOLE_URL') if get_env('CONSOLE_URL') else get_env('CONSOLE_WEB_URL') + self.SERVICE_API_URL = get_env('API_URL') if get_env('API_URL') else get_env('SERVICE_API_URL') + self.APP_WEB_URL = get_env('APP_URL') if get_env('APP_URL') else get_env('APP_WEB_URL') + self.APP_API_URL = get_env('APP_URL') if get_env('APP_URL') else get_env('APP_API_URL') self.CONSOLE_URL = get_env('CONSOLE_URL') self.API_URL = get_env('API_URL') self.APP_URL = get_env('APP_URL') @@ -147,10 +154,15 @@ class Config: # cors settings self.CONSOLE_CORS_ALLOW_ORIGINS = get_cors_allow_origins( - 'CONSOLE_CORS_ALLOW_ORIGINS', self.CONSOLE_URL) + 'CONSOLE_CORS_ALLOW_ORIGINS', self.CONSOLE_WEB_URL) self.WEB_API_CORS_ALLOW_ORIGINS = get_cors_allow_origins( 'WEB_API_CORS_ALLOW_ORIGINS', '*') + # mail settings + self.MAIL_TYPE = get_env('MAIL_TYPE') + self.MAIL_DEFAULT_SEND_FROM = get_env('MAIL_DEFAULT_SEND_FROM') + self.RESEND_API_KEY = get_env('RESEND_API_KEY') + # sentry settings self.SENTRY_DSN = get_env('SENTRY_DSN') self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE')) diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 89735e83ae..3fc15d2b59 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -12,7 +12,7 @@ from . import setup, version, apikey, admin from .app import app, site, completion, model_config, statistic, conversation, message, generator, audio # Import auth controllers -from .auth import login, oauth, data_source_oauth +from .auth import login, oauth, data_source_oauth, activate # Import datasets controllers from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing, data_source diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py new file mode 100644 index 0000000000..de4d22b3af --- /dev/null +++ b/api/controllers/console/auth/activate.py @@ -0,0 +1,75 @@ +import base64 +import secrets +from datetime import datetime + +from flask_restful import Resource, reqparse + +from controllers.console import api +from controllers.console.error import AlreadyActivateError +from extensions.ext_database import db +from libs.helper import email, str_len, supported_language, timezone +from libs.password import valid_password, hash_password +from models.account import AccountStatus, Tenant +from services.account_service import RegisterService + + +class ActivateCheckApi(Resource): + def get(self): + parser = reqparse.RequestParser() + parser.add_argument('workspace_id', type=str, required=True, nullable=False, location='args') + parser.add_argument('email', type=email, required=True, nullable=False, location='args') + parser.add_argument('token', type=str, required=True, nullable=False, location='args') + args = parser.parse_args() + + account = RegisterService.get_account_if_token_valid(args['workspace_id'], args['email'], args['token']) + + tenant = db.session.query(Tenant).filter( + Tenant.id == args['workspace_id'], + Tenant.status == 'normal' + ).first() + + return {'is_valid': account is not None, 'workspace_name': tenant.name} + + +class ActivateApi(Resource): + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('workspace_id', type=str, required=True, nullable=False, location='json') + parser.add_argument('email', type=email, required=True, nullable=False, location='json') + parser.add_argument('token', type=str, required=True, nullable=False, location='json') + parser.add_argument('name', type=str_len(30), required=True, nullable=False, location='json') + parser.add_argument('password', type=valid_password, required=True, nullable=False, location='json') + parser.add_argument('interface_language', type=supported_language, required=True, nullable=False, + location='json') + parser.add_argument('timezone', type=timezone, required=True, nullable=False, location='json') + args = parser.parse_args() + + account = RegisterService.get_account_if_token_valid(args['workspace_id'], args['email'], args['token']) + if account is None: + raise AlreadyActivateError() + + RegisterService.revoke_token(args['workspace_id'], args['email'], args['token']) + + account.name = args['name'] + + # generate password salt + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() + + # encrypt password with salt + password_hashed = hash_password(args['password'], salt) + base64_password_hashed = base64.b64encode(password_hashed).decode() + account.password = base64_password_hashed + account.password_salt = base64_salt + account.interface_language = args['interface_language'] + account.timezone = args['timezone'] + account.interface_theme = 'light' + account.status = AccountStatus.ACTIVE.value + account.initialized_at = datetime.utcnow() + db.session.commit() + + return {'result': 'success'} + + +api.add_resource(ActivateCheckApi, '/activate/check') +api.add_resource(ActivateApi, '/activate') diff --git a/api/controllers/console/auth/data_source_oauth.py b/api/controllers/console/auth/data_source_oauth.py index 79549c9ecf..f8106129dd 100644 --- a/api/controllers/console/auth/data_source_oauth.py +++ b/api/controllers/console/auth/data_source_oauth.py @@ -20,7 +20,7 @@ def get_oauth_providers(): client_secret=current_app.config.get( 'NOTION_CLIENT_SECRET'), redirect_uri=current_app.config.get( - 'CONSOLE_URL') + '/console/api/oauth/data-source/callback/notion') + 'CONSOLE_API_URL') + '/console/api/oauth/data-source/callback/notion') OAUTH_PROVIDERS = { 'notion': notion_oauth @@ -42,7 +42,7 @@ class OAuthDataSource(Resource): if current_app.config.get('NOTION_INTEGRATION_TYPE') == 'internal': internal_secret = current_app.config.get('NOTION_INTERNAL_SECRET') oauth_provider.save_internal_access_token(internal_secret) - return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source=success') + return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source=success') else: auth_url = oauth_provider.get_authorization_url() return redirect(auth_url) @@ -66,12 +66,12 @@ class OAuthDataSourceCallback(Resource): f"An error occurred during the OAuthCallback process with {provider}: {e.response.text}") return {'error': 'OAuth data source process failed'}, 400 - return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source=success') + return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source=success') elif 'error' in request.args: error = request.args.get('error') - return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source={error}') + return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source={error}') else: - return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_data_source=access_denied') + return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_data_source=access_denied') class OAuthDataSourceSync(Resource): diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index ababc30de9..c69b124b59 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -20,13 +20,13 @@ def get_oauth_providers(): client_secret=current_app.config.get( 'GITHUB_CLIENT_SECRET'), redirect_uri=current_app.config.get( - 'CONSOLE_URL') + '/console/api/oauth/authorize/github') + 'CONSOLE_API_URL') + '/console/api/oauth/authorize/github') google_oauth = GoogleOAuth(client_id=current_app.config.get('GOOGLE_CLIENT_ID'), client_secret=current_app.config.get( 'GOOGLE_CLIENT_SECRET'), redirect_uri=current_app.config.get( - 'CONSOLE_URL') + '/console/api/oauth/authorize/google') + 'CONSOLE_API_URL') + '/console/api/oauth/authorize/google') OAUTH_PROVIDERS = { 'github': github_oauth, @@ -80,7 +80,7 @@ class OAuthCallback(Resource): flask_login.login_user(account, remember=True) AccountService.update_last_login(account, request) - return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_login=success') + return redirect(f'{current_app.config.get("CONSOLE_WEB_URL")}?oauth_login=success') def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Optional[Account]: diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py index e563364f27..53416b865a 100644 --- a/api/controllers/console/error.py +++ b/api/controllers/console/error.py @@ -18,3 +18,9 @@ class AccountNotLinkTenantError(BaseHTTPException): error_code = 'account_not_link_tenant' description = "Account not link tenant." code = 403 + + +class AlreadyActivateError(BaseHTTPException): + error_code = 'already_activate' + description = "Auth Token is invalid or account already activated, please check again." + code = 403 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 0890cd0468..2ba004a6fa 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -6,22 +6,23 @@ from flask import current_app, request from flask_login import login_required, current_user from flask_restful import Resource, reqparse, fields, marshal_with +from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError from controllers.console import api from controllers.console.setup import setup_required from controllers.console.workspace.error import AccountAlreadyInitedError, InvalidInvitationCodeError, \ - RepeatPasswordNotMatchError + RepeatPasswordNotMatchError, CurrentPasswordIncorrectError from controllers.console.wraps import account_initialization_required from libs.helper import TimestampField, supported_language, timezone from extensions.ext_database import db from models.account import InvitationCode, AccountIntegrate from services.account_service import AccountService - account_fields = { 'id': fields.String, 'name': fields.String, 'avatar': fields.String, 'email': fields.String, + 'is_password_set': fields.Boolean, 'interface_language': fields.String, 'interface_theme': fields.String, 'timezone': fields.String, @@ -194,8 +195,11 @@ class AccountPasswordApi(Resource): if args['new_password'] != args['repeat_new_password']: raise RepeatPasswordNotMatchError() - AccountService.update_account_password( - current_user, args['password'], args['new_password']) + try: + AccountService.update_account_password( + current_user, args['password'], args['new_password']) + except ServiceCurrentPasswordIncorrectError: + raise CurrentPasswordIncorrectError() return {"result": "success"} diff --git a/api/controllers/console/workspace/error.py b/api/controllers/console/workspace/error.py index cb744232ec..99f55835bc 100644 --- a/api/controllers/console/workspace/error.py +++ b/api/controllers/console/workspace/error.py @@ -7,6 +7,12 @@ class RepeatPasswordNotMatchError(BaseHTTPException): code = 400 +class CurrentPasswordIncorrectError(BaseHTTPException): + error_code = 'current_password_incorrect' + description = "Current password is incorrect." + code = 400 + + class ProviderRequestFailedError(BaseHTTPException): error_code = 'provider_request_failed' description = None diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index e0fc2bc19f..7f4857f767 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,5 +1,5 @@ # -*- coding:utf-8 -*- - +from flask import current_app from flask_login import login_required, current_user from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal @@ -60,7 +60,8 @@ class MemberInviteEmailApi(Resource): inviter = current_user try: - RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, inviter=inviter) + token = RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, + inviter=inviter) account = db.session.query(Account, TenantAccountJoin.role).join( TenantAccountJoin, Account.id == TenantAccountJoin.account_id ).filter(Account.email == args['email']).first() @@ -78,7 +79,16 @@ class MemberInviteEmailApi(Resource): # todo:413 - return {'result': 'success', 'account': account}, 201 + return { + 'result': 'success', + 'account': account, + 'invite_url': '{}/activate?workspace_id={}&email={}&token={}'.format( + current_app.config.get("CONSOLE_WEB_URL"), + str(current_user.current_tenant_id), + invitee_email, + token + ) + }, 201 class MemberCancelInviteApi(Resource): @@ -88,7 +98,7 @@ class MemberCancelInviteApi(Resource): @login_required @account_initialization_required def delete(self, member_id): - member = Account.query.get(str(member_id)) + member = db.session.query(Account).filter(Account.id == str(member_id)).first() if not member: abort(404) diff --git a/api/extensions/ext_mail.py b/api/extensions/ext_mail.py new file mode 100644 index 0000000000..21a186228e --- /dev/null +++ b/api/extensions/ext_mail.py @@ -0,0 +1,61 @@ +from typing import Optional + +import resend +from flask import Flask + + +class Mail: + def __init__(self): + self._client = None + self._default_send_from = None + + def is_inited(self) -> bool: + return self._client is not None + + def init_app(self, app: Flask): + if app.config.get('MAIL_TYPE'): + if app.config.get('MAIL_DEFAULT_SEND_FROM'): + self._default_send_from = app.config.get('MAIL_DEFAULT_SEND_FROM') + + if app.config.get('MAIL_TYPE') == 'resend': + api_key = app.config.get('RESEND_API_KEY') + if not api_key: + raise ValueError('RESEND_API_KEY is not set') + + resend.api_key = api_key + self._client = resend.Emails + else: + raise ValueError('Unsupported mail type {}'.format(app.config.get('MAIL_TYPE'))) + + def send(self, to: str, subject: str, html: str, from_: Optional[str] = None): + if not self._client: + raise ValueError('Mail client is not initialized') + + if not from_ and self._default_send_from: + from_ = self._default_send_from + + if not from_: + raise ValueError('mail from is not set') + + if not to: + raise ValueError('mail to is not set') + + if not subject: + raise ValueError('mail subject is not set') + + if not html: + raise ValueError('mail html is not set') + + self._client.send({ + "from": from_, + "to": to, + "subject": subject, + "html": html + }) + + +def init_app(app: Flask): + mail.init_app(app) + + +mail = Mail() diff --git a/api/models/account.py b/api/models/account.py index 8f9d89cde4..903fee4ea4 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -38,6 +38,10 @@ class Account(UserMixin, db.Model): created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + @property + def is_password_set(self): + return self.password is not None + @property def current_tenant(self): return self._current_tenant diff --git a/api/models/model.py b/api/models/model.py index ec4197e588..7b3ba2745b 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -56,7 +56,8 @@ class App(db.Model): @property def api_base_url(self): - return (current_app.config['API_URL'] if current_app.config['API_URL'] else request.host_url.rstrip('/')) + '/v1' + return (current_app.config['SERVICE_API_URL'] if current_app.config['SERVICE_API_URL'] + else request.host_url.rstrip('/')) + '/v1' @property def tenant(self): @@ -515,7 +516,7 @@ class Site(db.Model): @property def app_base_url(self): - return (current_app.config['APP_URL'] if current_app.config['APP_URL'] else request.host_url.rstrip('/')) + return (current_app.config['APP_WEB_URL'] if current_app.config['APP_WEB_URL'] else request.host_url.rstrip('/')) class ApiToken(db.Model): diff --git a/api/requirements.txt b/api/requirements.txt index fe554653b0..5ffda02ced 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -21,7 +21,7 @@ Authlib==1.2.0 boto3~=1.26.123 tenacity==8.2.2 cachetools~=5.3.0 -weaviate-client~=3.16.2 +weaviate-client~=3.21.0 qdrant_client~=1.1.6 mailchimp-transactional~=1.0.50 scikit-learn==1.2.2 @@ -33,4 +33,5 @@ openpyxl==3.1.2 chardet~=5.1.0 docx2txt==0.8 pypdfium2==4.16.0 -pyjwt~=2.6.0 \ No newline at end of file +resend~=0.5.1 +pyjwt~=2.6.0 diff --git a/api/services/account_service.py b/api/services/account_service.py index df401a3ef7..530a6279d8 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -2,13 +2,16 @@ import base64 import logging import secrets +import uuid from datetime import datetime +from hashlib import sha256 from typing import Optional from flask import session from sqlalchemy import func from events.tenant_event import tenant_was_created +from extensions.ext_redis import redis_client from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \ TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \ RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError @@ -16,6 +19,7 @@ from libs.helper import get_remote_ip from libs.password import compare_password, hash_password from libs.rsa import generate_key_pair from models.account import * +from tasks.mail_invite_member_task import send_invite_member_mail_task class AccountService: @@ -48,12 +52,18 @@ class AccountService: @staticmethod def update_account_password(account, password, new_password): """update account password""" - # todo: split validation and update if account.password and not compare_password(password, account.password, account.password_salt): raise CurrentPasswordIncorrectError("Current password is incorrect.") - password_hashed = hash_password(new_password, account.password_salt) + + # generate password salt + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() + + # encrypt password with salt + password_hashed = hash_password(new_password, salt) base64_password_hashed = base64.b64encode(password_hashed).decode() account.password = base64_password_hashed + account.password_salt = base64_salt db.session.commit() return account @@ -283,8 +293,6 @@ class TenantService: @staticmethod def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account) -> None: """Remove member from tenant""" - # todo: check permission - if operator.id == account.id and TenantService.check_member_permission(tenant, operator, account, 'remove'): raise CannotOperateSelfError("Cannot operate self.") @@ -293,6 +301,12 @@ class TenantService: raise MemberNotInTenantError("Member not in tenant.") db.session.delete(ta) + + account.initialized_at = None + account.status = AccountStatus.PENDING.value + account.password = None + account.password_salt = None + db.session.commit() @staticmethod @@ -332,8 +346,8 @@ class TenantService: class RegisterService: - @staticmethod - def register(email, name, password: str = None, open_id: str = None, provider: str = None) -> Account: + @classmethod + def register(cls, email, name, password: str = None, open_id: str = None, provider: str = None) -> Account: db.session.begin_nested() """Register account""" try: @@ -359,9 +373,9 @@ class RegisterService: return account - @staticmethod - def invite_new_member(tenant: Tenant, email: str, role: str = 'normal', - inviter: Account = None) -> TenantAccountJoin: + @classmethod + def invite_new_member(cls, tenant: Tenant, email: str, role: str = 'normal', + inviter: Account = None) -> str: """Invite new member""" account = Account.query.filter_by(email=email).first() @@ -380,5 +394,71 @@ class RegisterService: if ta: raise AccountAlreadyInTenantError("Account already in tenant.") - ta = TenantService.create_tenant_member(tenant, account, role) - return ta + TenantService.create_tenant_member(tenant, account, role) + + token = cls.generate_invite_token(tenant, account) + + # send email + send_invite_member_mail_task.delay( + to=email, + token=cls.generate_invite_token(tenant, account), + inviter_name=inviter.name if inviter else 'Dify', + workspace_id=tenant.id, + workspace_name=tenant.name, + ) + + return token + + @classmethod + def generate_invite_token(cls, tenant: Tenant, account: Account) -> str: + token = str(uuid.uuid4()) + email_hash = sha256(account.email.encode()).hexdigest() + cache_key = 'member_invite_token:{}, {}:{}'.format(str(tenant.id), email_hash, token) + redis_client.setex(cache_key, 3600, str(account.id)) + return token + + @classmethod + def revoke_token(cls, workspace_id: str, email: str, token: str): + email_hash = sha256(email.encode()).hexdigest() + cache_key = 'member_invite_token:{}, {}:{}'.format(workspace_id, email_hash, token) + redis_client.delete(cache_key) + + @classmethod + def get_account_if_token_valid(cls, workspace_id: str, email: str, token: str) -> Optional[Account]: + tenant = db.session.query(Tenant).filter( + Tenant.id == workspace_id, + Tenant.status == 'normal' + ).first() + + if not tenant: + return None + + tenant_account = db.session.query(Account, TenantAccountJoin.role).join( + TenantAccountJoin, Account.id == TenantAccountJoin.account_id + ).filter(Account.email == email, TenantAccountJoin.tenant_id == tenant.id).first() + + if not tenant_account: + return None + + account_id = cls._get_account_id_by_invite_token(workspace_id, email, token) + if not account_id: + return None + + account = tenant_account[0] + if not account: + return None + + if account_id != str(account.id): + return None + + return account + + @classmethod + def _get_account_id_by_invite_token(cls, workspace_id: str, email: str, token: str) -> Optional[str]: + email_hash = sha256(email.encode()).hexdigest() + cache_key = 'member_invite_token:{}, {}:{}'.format(workspace_id, email_hash, token) + account_id = redis_client.get(cache_key) + if not account_id: + return None + + return account_id.decode('utf-8') diff --git a/api/tasks/mail_invite_member_task.py b/api/tasks/mail_invite_member_task.py new file mode 100644 index 0000000000..94145b9144 --- /dev/null +++ b/api/tasks/mail_invite_member_task.py @@ -0,0 +1,52 @@ +import logging +import time + +import click +from celery import shared_task +from flask import current_app + +from extensions.ext_mail import mail + + +@shared_task +def send_invite_member_mail_task(to: str, token: str, inviter_name: str, workspace_id: str, workspace_name: str): + """ + Async Send invite member mail + :param to + :param token + :param inviter_name + :param workspace_id + :param workspace_name + + Usage: send_invite_member_mail_task.delay(to, token, inviter_name, workspace_id, workspace_name) + """ + if not mail.is_inited(): + return + + logging.info(click.style('Start send invite member mail to {} in workspace {}'.format(to, workspace_name), + fg='green')) + start_at = time.perf_counter() + + try: + mail.send( + to=to, + subject="{} invited you to join {}".format(inviter_name, workspace_name), + html="""

Hi there,

+

{inviter_name} invited you to join {workspace_name}.

+

Click here to join.

+

Thanks,

+

Dify Team

""".format(inviter_name=inviter_name, workspace_name=workspace_name, + url='{}/activate?workspace_id={}&email={}&token={}'.format( + current_app.config.get("CONSOLE_WEB_URL"), + workspace_id, + to, + token) + ) + ) + + end_at = time.perf_counter() + logging.info( + click.style('Send invite member mail to {} succeeded: latency: {}'.format(to, end_at - start_at), + fg='green')) + except Exception: + logging.exception("Send invite member mail to {} failed".format(to)) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index eb7c597067..6133fa8ac3 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -11,18 +11,26 @@ services: LOG_LEVEL: INFO # A secret key that is used for securely signing the session cookie and encrypting sensitive information on the database. You can generate a strong key using `openssl rand -base64 42`. SECRET_KEY: sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U - # The base URL of console application, refers to the Console base URL of WEB service if console domain is + # The base URL of console application web frontend, refers to the Console base URL of WEB service if console domain is # different from api or web app domain. # example: http://cloud.dify.ai - CONSOLE_URL: '' + CONSOLE_WEB_URL: '' + # The base URL of console application api server, refers to the Console base URL of WEB service if console domain is + # different from api or web app domain. + # example: http://cloud.dify.ai + CONSOLE_API_URL: '' # The URL for Service API endpoints,refers to the base URL of the current API service if api domain is # different from console domain. # example: http://api.dify.ai - API_URL: '' - # The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from + SERVICE_API_URL: '' + # The URL for Web APP api server, refers to the Web App base URL of WEB service if web app domain is different from # console or api domain. # example: http://udify.app - APP_URL: '' + APP_API_URL: '' + # The URL for Web APP frontend, refers to the Web App base URL of WEB service if web app domain is different from + # console or api domain. + # example: http://udify.app + APP_WEB_URL: '' # When enabled, migrations will be executed prior to application startup and the application will start after the migrations have completed. MIGRATION_ENABLED: 'true' # The configurations of postgres database connection. @@ -93,6 +101,12 @@ services: QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/' # The Qdrant API key. QDRANT_API_KEY: 'ak-difyai' + # Mail configuration, support: resend + MAIL_TYPE: '' + # default send from email address, if not specified + MAIL_DEFAULT_SEND_FROM: 'YOUR EMAIL FROM (eg: no-reply )' + # the api-key for resend (https://resend.com) + RESEND_API_KEY: '' # The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled. SENTRY_DSN: '' # The sample rate for Sentry events. Default: `1.0` @@ -146,6 +160,12 @@ services: VECTOR_STORE: weaviate WEAVIATE_ENDPOINT: http://weaviate:8080 WEAVIATE_API_KEY: WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih + # Mail configuration, support: resend + MAIL_TYPE: '' + # default send from email address, if not specified + MAIL_DEFAULT_SEND_FROM: 'YOUR EMAIL FROM (eg: no-reply )' + # the api-key for resend (https://resend.com) + RESEND_API_KEY: '' depends_on: - db - redis @@ -160,14 +180,14 @@ services: restart: always environment: EDITION: SELF_HOSTED - # The base URL of console application, refers to the Console base URL of WEB service if console domain is + # The base URL of console application api server, refers to the Console base URL of WEB service if console domain is # different from api or web app domain. # example: http://cloud.dify.ai - CONSOLE_URL: '' - # The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from + CONSOLE_API_URL: '' + # The URL for Web APP api server, refers to the Web App base URL of WEB service if web app domain is different from # console or api domain. # example: http://udify.app - APP_URL: '' + APP_API_URL: '' # The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled. SENTRY_DSN: '' diff --git a/web/Dockerfile b/web/Dockerfile index f45a819947..fcd8315cd9 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -4,8 +4,8 @@ LABEL maintainer="takatost@gmail.com" ENV EDITION SELF_HOSTED ENV DEPLOY_ENV PRODUCTION -ENV CONSOLE_URL http://127.0.0.1:5001 -ENV APP_URL http://127.0.0.1:5001 +ENV CONSOLE_API_URL http://127.0.0.1:5001 +ENV APP_API_URL http://127.0.0.1:5001 EXPOSE 3000 diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx new file mode 100644 index 0000000000..1e0917d4b6 --- /dev/null +++ b/web/app/activate/activateForm.tsx @@ -0,0 +1,233 @@ +'use client' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import useSWR from 'swr' +import { useSearchParams } from 'next/navigation' +import cn from 'classnames' +import Link from 'next/link' +import { CheckCircleIcon } from '@heroicons/react/24/solid' +import style from './style.module.css' +import Button from '@/app/components/base/button' + +import { SimpleSelect } from '@/app/components/base/select' +import { timezones } from '@/utils/timezone' +import { languageMaps, languages } from '@/utils/language' +import { activateMember, invitationCheck } from '@/service/common' +import Toast from '@/app/components/base/toast' +import Loading from '@/app/components/base/loading' + +const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +const ActivateForm = () => { + const { t } = useTranslation() + const searchParams = useSearchParams() + const workspaceID = searchParams.get('workspace_id') + const email = searchParams.get('email') + const token = searchParams.get('token') + + const checkParams = { + url: '/activate/check', + params: { + workspace_id: workspaceID, + email, + token, + }, + } + const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, { + revalidateOnFocus: false, + }) + + const [name, setName] = useState('') + const [password, setPassword] = useState('') + const [timezone, setTimezone] = useState('Asia/Shanghai') + const [language, setLanguage] = useState('en-US') + const [showSuccess, setShowSuccess] = useState(false) + + const showErrorMessage = (message: string) => { + Toast.notify({ + type: 'error', + message, + }) + } + const valid = () => { + if (!name.trim()) { + showErrorMessage(t('login.error.nameEmpty')) + return false + } + if (!password.trim()) { + showErrorMessage(t('login.error.passwordEmpty')) + return false + } + if (!validPassword.test(password)) + showErrorMessage(t('login.error.passwordInvalid')) + + return true + } + + const handleActivate = async () => { + if (!valid()) + return + try { + await activateMember({ + url: '/activate', + body: { + workspace_id: workspaceID, + email, + token, + name, + password, + interface_language: language, + timezone, + }, + }) + setShowSuccess(true) + } + catch { + recheck() + } + } + + return ( +
+ {!checkRes && } + {checkRes && !checkRes.is_valid && ( +
+
+
🤷‍♂️
+

{t('login.invalid')}

+
+ +
+ )} + {checkRes && checkRes.is_valid && !showSuccess && ( +
+
+
+
+

+ {`${t('login.join')} ${checkRes.workspace_name}`} +

+

+ {`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`} +

+
+ +
+
+ {/* username */} +
+ +
+ setName(e.target.value)} + placeholder={t('login.namePlaceholder') || ''} + className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} + /> +
+
+ {/* password */} +
+ +
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} + /> +
+
{t('login.error.passwordInvalid')}
+
+ {/* language */} +
+ +
+ { + setLanguage(item.value as string) + }} + /> +
+
+ {/* timezone */} +
+ +
+ { + setTimezone(item.value as string) + }} + /> +
+
+
+ +
+
+ {t('login.license.tip')} +   + {t('login.license.link')} +
+
+
+
+ )} + {checkRes && checkRes.is_valid && showSuccess && ( +
+
+
+ +
+

+ {`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`} +

+
+ +
+ )} +
+ ) +} + +export default ActivateForm diff --git a/web/app/activate/page.tsx b/web/app/activate/page.tsx new file mode 100644 index 0000000000..d2c2bddac2 --- /dev/null +++ b/web/app/activate/page.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import cn from 'classnames' +import Header from '../signin/_header' +import style from '../signin/page.module.css' +import ActivateForm from './activateForm' + +const Activate = () => { + return ( +
+
+
+ +
+ © {new Date().getFullYear()} Dify, Inc. All rights reserved. +
+
+
+ ) +} + +export default Activate diff --git a/web/app/activate/style.module.css b/web/app/activate/style.module.css new file mode 100644 index 0000000000..17798981fa --- /dev/null +++ b/web/app/activate/style.module.css @@ -0,0 +1,4 @@ +.logo { + background: #fff center no-repeat url(./team-28x28.png); + background-size: 56px; +} \ No newline at end of file diff --git a/web/app/activate/team-28x28.png b/web/app/activate/team-28x28.png new file mode 100644 index 0000000000000000000000000000000000000000..b5175210e6818c036d5cffe7c26f56f13c650879 GIT binary patch literal 7349 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 value?: string className?: string @@ -21,7 +21,7 @@ interface ISelectProps { export default function Select({ items, value, - onChange + onChange, }: ISelectProps) { const item = items.filter(item => item.value === value)[0] @@ -29,11 +29,12 @@ export default function Select({
- -
@@ -46,14 +47,14 @@ export default function Select({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
{items.map((item) => { return {({ active }) => (
) -} \ No newline at end of file +} diff --git a/web/app/components/header/account-about/index.module.css b/web/app/components/header/account-about/index.module.css index 1c5e123fc0..e54a70c9b7 100644 --- a/web/app/components/header/account-about/index.module.css +++ b/web/app/components/header/account-about/index.module.css @@ -1,6 +1,6 @@ .logo-icon { background: url(../assets/logo-icon.png) center center no-repeat; - background-size: contain; + background-size: 32px; box-shadow: 0px 4px 6px -1px rgba(0, 0, 0, 0.05), 0px 2px 4px -2px rgba(0, 0, 0, 0.05); } diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx index e19a1994b7..6358fb6147 100644 --- a/web/app/components/header/account-about/index.tsx +++ b/web/app/components/header/account-about/index.tsx @@ -34,7 +34,7 @@ export default function AccountAbout({
{ setEditNameModalVisible(true) @@ -52,6 +58,56 @@ export default function AccountPage() { setEditing(false) } } + + const showErrorMessage = (message: string) => { + notify({ + type: 'error', + message, + }) + } + const valid = () => { + if (!password.trim()) { + showErrorMessage(t('login.error.passwordEmpty')) + return false + } + if (!validPassword.test(password)) + showErrorMessage(t('login.error.passwordInvalid')) + if (password !== confirmPassword) + showErrorMessage(t('common.account.notEqual')) + + return true + } + const resetPasswordForm = () => { + setCurrentPassword('') + setPassword('') + setConfirmPassword('') + } + const handleSavePassowrd = async () => { + if (!valid()) + return + try { + setEditing(true) + await updateUserProfile({ + url: 'account/password', + body: { + password: currentPassword, + new_password: password, + repeat_new_password: confirmPassword, + }, + }) + notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) + mutateUserProfile() + setEditPasswordModalVisible(false) + resetPasswordForm() + setEditing(false) + } + catch (e) { + notify({ type: 'error', message: (e as Error).message }) + setEditPasswordModalVisible(false) + setEditing(false) + } + } + const renderAppItem = (item: IItem) => { return (
@@ -80,51 +136,105 @@ export default function AccountPage() {
{t('common.account.email')}
{userProfile.email}
- { - !!apps.length && ( - <> -
-
-
{t('common.account.langGeniusAccount')}
-
{t('common.account.langGeniusAccountTip')}
- ({ key: app.id, name: app.name }))} - renderItem={renderAppItem} - wrapperClassName='mt-2' - /> -
- - ) - } - { - editNameModalVisible && ( - setEditNameModalVisible(false)} - className={s.modal} - > -
{t('common.account.editName')}
-
{t('common.account.name')}
- setEditName(e.target.value)} +
+
{t('common.account.password')}
+
{t('common.account.passwordTip')}
+ +
+ {!!apps.length && ( + <> +
+
+
{t('common.account.langGeniusAccount')}
+
{t('common.account.langGeniusAccountTip')}
+ ({ key: app.id, name: app.name }))} + renderItem={renderAppItem} + wrapperClassName='mt-2' /> -
- - -
- - ) - } +
+ + )} + {editNameModalVisible && ( + setEditNameModalVisible(false)} + className={s.modal} + > +
{t('common.account.editName')}
+
{t('common.account.name')}
+ setEditName(e.target.value)} + /> +
+ + +
+
+ )} + {editPasswordModalVisible && ( + { + setEditPasswordModalVisible(false) + resetPasswordForm() + }} + className={s.modal} + > +
{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}
+ {userProfile.is_password_set && ( + <> +
{t('common.account.currentPassword')}
+ setCurrentPassword(e.target.value)} + /> + + )} +
+ {userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')} +
+ setPassword(e.target.value)} + /> +
{t('common.account.confirmPassword')}
+ setConfirmPassword(e.target.value)} + /> +
+ + +
+
+ )} ) } diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 6bbd883376..e9504aa0c2 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -1,6 +1,7 @@ 'use client' import { useTranslation } from 'react-i18next' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import cn from 'classnames' import { AtSymbolIcon, CubeTransparentIcon, GlobeAltIcon, UserIcon, UsersIcon, XMarkIcon } from '@heroicons/react/24/outline' import { GlobeAltIcon as GlobalAltIconSolid, UserIcon as UserIconSolid, UsersIcon as UsersIconSolid } from '@heroicons/react/24/solid' import AccountPage from './account-page' @@ -18,6 +19,10 @@ const iconClassName = ` w-4 h-4 ml-3 mr-2 ` +const scrolledClassName = ` + border-b shadow-xs bg-white/[.98] +` + type IAccountSettingProps = { onCancel: () => void activeTab?: string @@ -78,6 +83,22 @@ export default function AccountSetting({ ], }, ] + const scrollRef = useRef(null) + const [scrolled, setScrolled] = useState(false) + const scrollHandle = (e: any) => { + if (e.target.scrollTop > 0) + setScrolled(true) + + else + setScrolled(false) + } + useEffect(() => { + const targetElement = scrollRef.current + targetElement?.addEventListener('scroll', scrollHandle) + return () => { + targetElement?.removeEventListener('scroll', scrollHandle) + } + }, []) return (
-
-
+
+
{[...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)?.name}
- { - activeMenu === 'account' && - } - { - activeMenu === 'members' && - } - { - activeMenu === 'integrations' && - } - { - activeMenu === 'language' && - } - { - activeMenu === 'provider' && - } - { - activeMenu === 'data-source' && - } +
+ {activeMenu === 'account' && } + {activeMenu === 'members' && } + {activeMenu === 'integrations' && } + {activeMenu === 'language' && } + {activeMenu === 'provider' && } + {activeMenu === 'data-source' && } +
diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 4242b74151..78d6548f10 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -30,6 +30,7 @@ const MembersPage = () => { const { userProfile } = useAppContext() const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers) const [inviteModalVisible, setInviteModalVisible] = useState(false) + const [invitationLink, setInvitationLink] = useState('') const [invitedModalVisible, setInvitedModalVisible] = useState(false) const accounts = data?.accounts || [] const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email @@ -93,8 +94,9 @@ const MembersPage = () => { inviteModalVisible && ( setInviteModalVisible(false)} - onSend={() => { + onSend={(url) => { setInvitedModalVisible(true) + setInvitationLink(url) mutate() }} /> @@ -103,6 +105,7 @@ const MembersPage = () => { { invitedModalVisible && ( setInvitedModalVisible(false)} /> ) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 353a0b373d..541eb25f91 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -3,16 +3,16 @@ import { useState } from 'react' import { useContext } from 'use-context-selector' import { XMarkIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' +import s from './index.module.css' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' -import s from './index.module.css' import { inviteMember } from '@/service/common' import { emailRegex } from '@/config' import { ToastContext } from '@/app/components/base/toast' -interface IInviteModalProps { - onCancel: () => void, - onSend: () => void, +type IInviteModalProps = { + onCancel: () => void + onSend: (url: string) => void } const InviteModal = ({ onCancel, @@ -25,16 +25,16 @@ const InviteModal = ({ const handleSend = async () => { if (emailRegex.test(email)) { try { - const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin'} }) + const res = await inviteMember({ url: '/workspaces/current/members/invite-email', body: { email, role: 'admin' } }) if (res.result === 'success') { onCancel() - onSend() + onSend(res.invite_url) } - } catch (e) { - } - } else { + catch (e) {} + } + else { notify({ type: 'error', message: t('common.members.emailInvalid') }) } } @@ -51,15 +51,15 @@ const InviteModal = ({
{t('common.members.email')}
setEmail(e.target.value)} placeholder={t('common.members.emailPlaceholder') || ''} /> -
{t('common.members.invitationSent')}
-
{t('common.members.invitationSentTip')}
+
{t('common.members.invitationSentTip')}
+
+
{t('common.members.invitationLink')}
+ +
-
+
+ {t('login.license.tip')} +   + {t('login.license.link')} +
diff --git a/web/app/signin/_header.tsx b/web/app/signin/_header.tsx index 14cb298226..4e4212cd21 100644 --- a/web/app/signin/_header.tsx +++ b/web/app/signin/_header.tsx @@ -1,16 +1,10 @@ 'use client' import React from 'react' +import { useContext } from 'use-context-selector' import style from './page.module.css' import Select, { LOCALES } from '@/app/components/base/select/locale' import { type Locale } from '@/i18n' import I18n from '@/context/i18n' -import { setLocaleOnClient } from '@/i18n/client' -import { useContext } from 'use-context-selector' - - -type IHeaderProps = { - locale: string -} const Header = () => { const { locale, setLocaleOnClient } = useContext(I18n) diff --git a/web/app/signin/forms.tsx b/web/app/signin/forms.tsx index d6cbbe6c3d..be30fd0631 100644 --- a/web/app/signin/forms.tsx +++ b/web/app/signin/forms.tsx @@ -2,9 +2,9 @@ import React from 'react' import { useSearchParams } from 'next/navigation' +import cn from 'classnames' import NormalForm from './normalForm' import OneMoreStep from './oneMoreStep' -import classNames from 'classnames' const Forms = () => { const searchParams = useSearchParams() @@ -19,7 +19,7 @@ const Forms = () => { } } return
{
{getForm()}
-
} diff --git a/web/app/signin/normalForm.tsx b/web/app/signin/normalForm.tsx index 93e6b0d561..a4af08e7fe 100644 --- a/web/app/signin/normalForm.tsx +++ b/web/app/signin/normalForm.tsx @@ -2,16 +2,15 @@ import React, { useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRouter } from 'next/navigation' -import { IS_CE_EDITION } from '@/config' import classNames from 'classnames' import useSWR from 'swr' import Link from 'next/link' +import Toast from '../components/base/toast' import style from './page.module.css' // import Tooltip from '@/app/components/base/tooltip/index' -import Toast from '../components/base/toast' +import { IS_CE_EDITION, apiPrefix } from '@/config' import Button from '@/app/components/base/button' import { login, oauth } from '@/service/common' -import { apiPrefix } from '@/config' const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/ @@ -91,8 +90,9 @@ const NormalForm = () => { remember_me: true, }, }) - router.push('/') - } finally { + router.push('/apps') + } + finally { setIsLoading(false) } } @@ -132,8 +132,8 @@ const NormalForm = () => { return ( <>
-

{t('login.pageTitle')}

-

{t('login.welcome')}

+

{t('login.pageTitle')}

+

{t('login.welcome')}

@@ -145,7 +145,7 @@ const NormalForm = () => { @@ -164,7 +164,7 @@ const NormalForm = () => { @@ -192,9 +192,9 @@ const NormalForm = () => {
*/} -
{ }}> -
-