Forráskód Böngészése

feat: member invitation and activation (#535)

Co-authored-by: John Wang <takatost@gmail.com>
KVOJJJin 1 éve%!(EXTRA string=óta)
szülő
commit
cd51d3323b
51 módosított fájl, 1235 hozzáadás és 329 törlés
  1. 15 4
      api/.env.example
  2. 5 3
      api/Dockerfile
  3. 2 1
      api/app.py
  4. 16 4
      api/config.py
  5. 1 1
      api/controllers/console/__init__.py
  6. 75 0
      api/controllers/console/auth/activate.py
  7. 5 5
      api/controllers/console/auth/data_source_oauth.py
  8. 3 3
      api/controllers/console/auth/oauth.py
  9. 6 0
      api/controllers/console/error.py
  10. 8 4
      api/controllers/console/workspace/account.py
  11. 6 0
      api/controllers/console/workspace/error.py
  12. 14 4
      api/controllers/console/workspace/members.py
  13. 61 0
      api/extensions/ext_mail.py
  14. 4 0
      api/models/account.py
  15. 3 2
      api/models/model.py
  16. 3 2
      api/requirements.txt
  17. 91 11
      api/services/account_service.py
  18. 52 0
      api/tasks/mail_invite_member_task.py
  19. 29 9
      docker/docker-compose.yaml
  20. 2 2
      web/Dockerfile
  21. 233 0
      web/app/activate/activateForm.tsx
  22. 32 0
      web/app/activate/page.tsx
  23. 4 0
      web/app/activate/style.module.css
  24. BIN
      web/app/activate/team-28x28.png
  25. 13 12
      web/app/components/base/select/locale.tsx
  26. 1 1
      web/app/components/header/account-about/index.module.css
  27. 1 1
      web/app/components/header/account-about/index.tsx
  28. 155 45
      web/app/components/header/account-setting/account-page/index.tsx
  29. 32 21
      web/app/components/header/account-setting/index.tsx
  30. 4 1
      web/app/components/header/account-setting/members-page/index.tsx
  31. 12 12
      web/app/components/header/account-setting/members-page/invite-modal/index.tsx
  32. 3 0
      web/app/components/header/account-setting/members-page/invited-modal/assets/copied.svg
  33. 3 0
      web/app/components/header/account-setting/members-page/invited-modal/assets/copy-hover.svg
  34. 3 0
      web/app/components/header/account-setting/members-page/invited-modal/assets/copy.svg
  35. 16 0
      web/app/components/header/account-setting/members-page/invited-modal/index.module.css
  36. 15 9
      web/app/components/header/account-setting/members-page/invited-modal/index.tsx
  37. 63 0
      web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx
  38. 33 37
      web/app/install/installForm.tsx
  39. 1 7
      web/app/signin/_header.tsx
  40. 2 3
      web/app/signin/forms.tsx
  41. 23 23
      web/app/signin/normalForm.tsx
  42. 22 12
      web/app/signin/oneMoreStep.tsx
  43. 5 6
      web/app/signin/page.tsx
  44. 2 0
      web/context/app-context.tsx
  45. 13 2
      web/docker/entrypoint.sh
  46. 13 3
      web/i18n/lang/common.en.ts
  47. 12 3
      web/i18n/lang/common.zh.ts
  48. 53 37
      web/i18n/lang/login.en.ts
  49. 53 37
      web/i18n/lang/login.zh.ts
  50. 2 0
      web/models/common.ts
  51. 10 2
      web/service/common.ts

+ 15 - 4
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 API base URL
+APP_API_URL=http://127.0.0.1:5001
 
-# Web APP base URL
-APP_URL=http://127.0.0.1:3000
+# 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 <no-reply@dify.ai>
+RESEND_API_KEY=
+
 # Sentry configuration
 SENTRY_DSN=
 

+ 5 - 3
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
 

+ 2 - 1
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)
 
 

+ 16 - 4
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'))

+ 1 - 1
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

+ 75 - 0
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')

+ 5 - 5
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):

+ 3 - 3
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]:

+ 6 - 0
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

+ 8 - 4
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"}
 

+ 6 - 0
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

+ 14 - 4
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)
 

+ 61 - 0
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()

+ 4 - 0
api/models/account.py

@@ -39,6 +39,10 @@ class Account(UserMixin, db.Model):
     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
 

+ 3 - 2
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):

+ 3 - 2
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
+resend~=0.5.1
+pyjwt~=2.6.0

+ 91 - 11
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')

+ 52 - 0
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="""<p>Hi there,</p>
+<p>{inviter_name} invited you to join {workspace_name}.</p>
+<p>Click <a href="{url}">here</a> to join.</p>
+<p>Thanks,</p>
+<p>Dify Team</p>""".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))

+ 29 - 9
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_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_URL: ''
+      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 <no-reply@dify.ai>)'
+      # 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 <no-reply@dify.ai>)'
+      # 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: ''
 

+ 2 - 2
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
 

+ 233 - 0
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 (
+    <div className={
+      cn(
+        'flex flex-col items-center w-full grow items-center justify-center',
+        'px-6',
+        'md:px-[108px]',
+      )
+    }>
+      {!checkRes && <Loading/>}
+      {checkRes && !checkRes.is_valid && (
+        <div className="flex flex-col md:w-[400px]">
+          <div className="w-full mx-auto">
+            <div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">🤷‍♂️</div>
+            <h2 className="text-[32px] font-bold text-gray-900">{t('login.invalid')}</h2>
+          </div>
+          <div className="w-full mx-auto mt-6">
+            <Button type='primary' className='w-full !fone-medium !text-sm'>
+              <a href="https://dify.ai">{t('login.explore')}</a>
+            </Button>
+          </div>
+        </div>
+      )}
+      {checkRes && checkRes.is_valid && !showSuccess && (
+        <div className='flex flex-col md:w-[400px]'>
+          <div className="w-full mx-auto">
+            <div className={`mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold ${style.logo}`}>
+            </div>
+            <h2 className="text-[32px] font-bold text-gray-900">
+              {`${t('login.join')} ${checkRes.workspace_name}`}
+            </h2>
+            <p className='mt-1 text-sm text-gray-600 '>
+              {`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`}
+            </p>
+          </div>
+
+          <div className="w-full mx-auto mt-6">
+            <div className="bg-white">
+              {/* username */}
+              <div className='mb-5'>
+                <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
+                  {t('login.name')}
+                </label>
+                <div className="mt-1 relative rounded-md shadow-sm">
+                  <input
+                    id="name"
+                    type="text"
+                    value={name}
+                    onChange={e => 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'}
+                  />
+                </div>
+              </div>
+              {/* password */}
+              <div className='mb-5'>
+                <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
+                  {t('login.password')}
+                </label>
+                <div className="mt-1 relative rounded-md shadow-sm">
+                  <input
+                    id="password"
+                    type='password'
+                    value={password}
+                    onChange={e => 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'}
+                  />
+                </div>
+                <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
+              </div>
+              {/* language */}
+              <div className='mb-5'>
+                <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
+                  {t('login.interfaceLanguage')}
+                </label>
+                <div className="relative mt-1 rounded-md shadow-sm">
+                  <SimpleSelect
+                    defaultValue={languageMaps.en}
+                    items={languages}
+                    onSelect={(item) => {
+                      setLanguage(item.value as string)
+                    }}
+                  />
+                </div>
+              </div>
+              {/* timezone */}
+              <div className='mb-4'>
+                <label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
+                  {t('login.timezone')}
+                </label>
+                <div className="relative mt-1 rounded-md shadow-sm">
+                  <SimpleSelect
+                    defaultValue={timezone}
+                    items={timezones}
+                    onSelect={(item) => {
+                      setTimezone(item.value as string)
+                    }}
+                  />
+                </div>
+              </div>
+              <div>
+                <Button
+                  type='primary'
+                  className='w-full !fone-medium !text-sm'
+                  onClick={handleActivate}
+                >
+                  {`${t('login.join')} ${checkRes.workspace_name}`}
+                </Button>
+              </div>
+              <div className="block w-hull mt-2 text-xs text-gray-600">
+                {t('login.license.tip')}
+                &nbsp;
+                <Link
+                  className='text-primary-600'
+                  target={'_blank'}
+                  href='https://docs.dify.ai/community/open-source'
+                >{t('login.license.link')}</Link>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+      {checkRes && checkRes.is_valid && showSuccess && (
+        <div className="flex flex-col md:w-[400px]">
+          <div className="w-full mx-auto">
+            <div className="mb-3 flex justify-center items-center w-20 h-20 p-5 rounded-[20px] border border-gray-100 shadow-lg text-[40px] font-bold">
+              <CheckCircleIcon className='w-10 h-10 text-[#039855]' />
+            </div>
+            <h2 className="text-[32px] font-bold text-gray-900">
+              {`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`}
+            </h2>
+          </div>
+          <div className="w-full mx-auto mt-6">
+            <Button type='primary' className='w-full !fone-medium !text-sm'>
+              <a href="/signin">{t('login.activated')}</a>
+            </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default ActivateForm

+ 32 - 0
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 (
+    <div className={cn(
+      style.background,
+      'flex w-full min-h-screen',
+      'sm:p-4 lg:p-8',
+      'gap-x-20',
+      'justify-center lg:justify-start',
+    )}>
+      <div className={
+        cn(
+          'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
+          'space-between',
+        )
+      }>
+        <Header />
+        <ActivateForm />
+        <div className='px-8 py-6 text-sm font-normal text-gray-500'>
+          © {new Date().getFullYear()} Dify, Inc. All rights reserved.
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default Activate

+ 4 - 0
web/app/activate/style.module.css

@@ -0,0 +1,4 @@
+.logo {
+  background: #fff center no-repeat url(./team-28x28.png);
+  background-size: 56px;
+}

BIN
web/app/activate/team-28x28.png


+ 13 - 12
web/app/components/base/select/locale.tsx

@@ -11,7 +11,7 @@ export const RFC_LOCALES = [
   { value: 'en-US', name: 'EN' },
   { value: 'zh-Hans', name: '简体中文' },
 ]
-interface ISelectProps {
+type ISelectProps = {
   items: Array<{ value: string; name: string }>
   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({
     <div className="w-56 text-right">
       <Menu as="div" className="relative inline-block text-left">
         <div>
-          <Menu.Button className="inline-flex w-full justify-center items-center
-          rounded-lg px-2 py-1 
-          text-gray-600 text-xs font-medium
-          border border-gray-200">
-            <GlobeAltIcon className="w-5 h-5 mr-2 " aria-hidden="true" />
+          <Menu.Button className="inline-flex w-full h-[44px]justify-center items-center
+          rounded-lg px-[10px] py-[6px]
+          text-gray-900 text-[13px] font-medium
+          border border-gray-200
+          hover:bg-gray-100">
+            <GlobeAltIcon className="w-5 h-5 mr-1" aria-hidden="true" />
             {item?.name}
           </Menu.Button>
         </div>
@@ -46,14 +47,14 @@ export default function Select({
           leaveFrom="transform opacity-100 scale-100"
           leaveTo="transform opacity-0 scale-95"
         >
-          <Menu.Items className="absolute right-0 mt-2 w-28 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
+          <Menu.Items className="absolute right-0 mt-2 w-[120px] origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
             <div className="px-1 py-1 ">
               {items.map((item) => {
                 return <Menu.Item key={item.value}>
                   {({ active }) => (
                     <button
                       className={`${active ? 'bg-gray-100' : ''
-                        } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
+                      } group flex w-full items-center rounded-lg px-3 py-2 text-sm text-gray-700`}
                       onClick={(evt) => {
                         evt.preventDefault()
                         onChange && onChange(item.value)
@@ -77,7 +78,7 @@ export default function Select({
 export function InputSelect({
   items,
   value,
-  onChange
+  onChange,
 }: ISelectProps) {
   const item = items.filter(item => item.value === value)[0]
   return (
@@ -104,7 +105,7 @@ export function InputSelect({
                   {({ active }) => (
                     <button
                       className={`${active ? 'bg-gray-100' : ''
-                        } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
+                      } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
                       onClick={() => {
                         onChange && onChange(item.value)
                       }}
@@ -122,4 +123,4 @@ export function InputSelect({
       </Menu>
     </div>
   )
-}
+}

+ 1 - 1
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);
 }
 

+ 1 - 1
web/app/components/header/account-about/index.tsx

@@ -34,7 +34,7 @@ export default function AccountAbout({
         <div>
           <div className={classNames(
             s['logo-icon'],
-            'mx-auto mb-3 w-12 h-12 bg-white rounded border border-gray-200',
+            'mx-auto mb-3 w-12 h-12 bg-white rounded-xl border border-gray-200',
           )} />
           <div className={classNames(
             s['logo-text'],

+ 155 - 45
web/app/components/header/account-setting/account-page/index.tsx

@@ -25,13 +25,19 @@ const inputClassName = `
   text-sm font-normal text-gray-800
 `
 
+const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
+
 export default function AccountPage() {
+  const { t } = useTranslation()
   const { mutateUserProfile, userProfile, apps } = useAppContext()
   const { notify } = useContext(ToastContext)
   const [editNameModalVisible, setEditNameModalVisible] = useState(false)
   const [editName, setEditName] = useState('')
   const [editing, setEditing] = useState(false)
-  const { t } = useTranslation()
+  const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
+  const [currentPassword, setCurrentPassword] = useState('')
+  const [password, setPassword] = useState('')
+  const [confirmPassword, setConfirmPassword] = useState('')
 
   const handleEditName = () => {
     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 (
       <div className='flex px-3 py-1'>
@@ -80,51 +136,105 @@ export default function AccountPage() {
         <div className={titleClassName}>{t('common.account.email')}</div>
         <div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
       </div>
-      {
-        !!apps.length && (
-          <>
-            <div className='mb-6 border-[0.5px] border-gray-100' />
-            <div className='mb-8'>
-              <div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
-              <div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
-              <Collapse
-                title={`${t('common.account.showAppLength', { length: apps.length })}`}
-                items={apps.map(app => ({ key: app.id, name: app.name }))}
-                renderItem={renderAppItem}
-                wrapperClassName='mt-2'
-              />
-            </div>
-          </>
-        )
-      }
-      {
-        editNameModalVisible && (
-          <Modal
-            isShow
-            onClose={() => setEditNameModalVisible(false)}
-            className={s.modal}
-          >
-            <div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
-            <div className={titleClassName}>{t('common.account.name')}</div>
-            <input
-              className={inputClassName}
-              value={editName}
-              onChange={e => setEditName(e.target.value)}
+      <div className='mb-8'>
+        <div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
+        <div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
+        <Button className='font-medium !text-gray-700 !px-3 !py-[7px] !text-[13px]' onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
+      </div>
+      {!!apps.length && (
+        <>
+          <div className='mb-6 border-[0.5px] border-gray-100' />
+          <div className='mb-8'>
+            <div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
+            <div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
+            <Collapse
+              title={`${t('common.account.showAppLength', { length: apps.length })}`}
+              items={apps.map(app => ({ key: app.id, name: app.name }))}
+              renderItem={renderAppItem}
+              wrapperClassName='mt-2'
             />
-            <div className='flex justify-end mt-10'>
-              <Button className='mr-2 text-sm font-medium' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
-              <Button
-                disabled={editing || !editName}
-                type='primary'
-                className='text-sm font-medium'
-                onClick={handleSaveName}
-              >
-                {t('common.operation.save')}
-              </Button>
-            </div>
-          </Modal>
-        )
-      }
+          </div>
+        </>
+      )}
+      {editNameModalVisible && (
+        <Modal
+          isShow
+          onClose={() => setEditNameModalVisible(false)}
+          className={s.modal}
+        >
+          <div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
+          <div className={titleClassName}>{t('common.account.name')}</div>
+          <input
+            className={inputClassName}
+            value={editName}
+            onChange={e => setEditName(e.target.value)}
+          />
+          <div className='flex justify-end mt-10'>
+            <Button className='mr-2 text-sm font-medium' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
+            <Button
+              disabled={editing || !editName}
+              type='primary'
+              className='text-sm font-medium'
+              onClick={handleSaveName}
+            >
+              {t('common.operation.save')}
+            </Button>
+          </div>
+        </Modal>
+      )}
+      {editPasswordModalVisible && (
+        <Modal
+          isShow
+          onClose={() => {
+            setEditPasswordModalVisible(false)
+            resetPasswordForm()
+          }}
+          className={s.modal}
+        >
+          <div className='mb-6 text-lg font-medium text-gray-900'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
+          {userProfile.is_password_set && (
+            <>
+              <div className={titleClassName}>{t('common.account.currentPassword')}</div>
+              <input
+                type="password"
+                className={inputClassName}
+                value={currentPassword}
+                onChange={e => setCurrentPassword(e.target.value)}
+              />
+            </>
+          )}
+          <div className='mt-8 text-sm font-medium text-gray-900'>
+            {userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
+          </div>
+          <input
+            type="password"
+            className={inputClassName}
+            value={password}
+            onChange={e => setPassword(e.target.value)}
+          />
+          <div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
+          <input
+            type="password"
+            className={inputClassName}
+            value={confirmPassword}
+            onChange={e => setConfirmPassword(e.target.value)}
+          />
+          <div className='flex justify-end mt-10'>
+            <Button className='mr-2 text-sm font-medium' onClick={() => {
+              setEditPasswordModalVisible(false)
+              resetPasswordForm()
+            }}>{t('common.operation.cancel')}</Button>
+            <Button
+              disabled={editing}
+              type='primary'
+              className='text-sm font-medium'
+              onClick={handleSavePassowrd}
+            >
+              {userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
+            </Button>
+          </div>
+        </Modal>
+      )}
     </>
   )
 }

+ 32 - 21
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<HTMLDivElement>(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 (
     <Modal
@@ -115,29 +136,19 @@ export default function AccountSetting({
             }
           </div>
         </div>
-        <div className='w-[520px] h-[580px] px-6 py-4 overflow-y-auto'>
-          <div className='flex items-center justify-between h-6 mb-8 text-base font-medium text-gray-900 '>
+        <div ref={scrollRef} className='relative w-[520px] h-[580px] pb-4 overflow-y-auto'>
+          <div className={cn('sticky top-0 px-6 py-4 flex items-center justify-between h-14 mb-4 bg-white text-base font-medium text-gray-900', scrolled && scrolledClassName)}>
             {[...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)?.name}
             <XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
           </div>
-          {
-            activeMenu === 'account' && <AccountPage />
-          }
-          {
-            activeMenu === 'members' && <MembersPage />
-          }
-          {
-            activeMenu === 'integrations' && <IntegrationsPage />
-          }
-          {
-            activeMenu === 'language' && <LanguagePage />
-          }
-          {
-            activeMenu === 'provider' && <ProviderPage />
-          }
-          {
-            activeMenu === 'data-source' && <DataSourcePage />
-          }
+          <div className='px-6'>
+            {activeMenu === 'account' && <AccountPage />}
+            {activeMenu === 'members' && <MembersPage />}
+            {activeMenu === 'integrations' && <IntegrationsPage />}
+            {activeMenu === 'language' && <LanguagePage />}
+            {activeMenu === 'provider' && <ProviderPage />}
+            {activeMenu === 'data-source' && <DataSourcePage />}
+          </div>
         </div>
       </div>
     </Modal>

+ 4 - 1
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 && (
           <InviteModal
             onCancel={() => setInviteModalVisible(false)}
-            onSend={() => {
+            onSend={(url) => {
               setInvitedModalVisible(true)
+              setInvitationLink(url)
               mutate()
             }}
           />
@@ -103,6 +105,7 @@ const MembersPage = () => {
       {
         invitedModalVisible && (
           <InvitedModal
+            invitationLink={invitationLink}
             onCancel={() => setInvitedModalVisible(false)}
           />
         )

+ 12 - 12
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 = ({
           <div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div>
           <input
             className='
-              block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none 
+              block w-full py-2 mb-9 px-3 bg-gray-50 outline-none border-none
               appearance-none text-sm text-gray-900 rounded-lg
             '
             value={email}
             onChange={e => setEmail(e.target.value)}
             placeholder={t('common.members.emailPlaceholder') || ''}
           />
-          <Button 
-            className='w-full text-sm font-medium' 
+          <Button
+            className='w-full text-sm font-medium'
             onClick={handleSend}
             type='primary'
           >

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 3 - 0
web/app/components/header/account-setting/members-page/invited-modal/assets/copied.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 3 - 0
web/app/components/header/account-setting/members-page/invited-modal/assets/copy-hover.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 3 - 0
web/app/components/header/account-setting/members-page/invited-modal/assets/copy.svg


+ 16 - 0
web/app/components/header/account-setting/members-page/invited-modal/index.module.css

@@ -2,4 +2,20 @@
   padding: 32px !important;
   width: 480px !important;
   background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !important;
+}
+
+.copyIcon {
+  background-image: url(./assets/copy.svg);
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.copyIcon:hover {
+  background-image: url(./assets/copy-hover.svg);
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.copyIcon.copied {
+  background-image: url(./assets/copied.svg);
 }

+ 15 - 9
web/app/components/header/account-setting/members-page/invited-modal/index.tsx

@@ -1,15 +1,17 @@
 import { CheckCircleIcon } from '@heroicons/react/24/solid'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import { useTranslation } from 'react-i18next'
+import InvitationLink from './invitation-link'
+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'
-
-interface IInvitedModalProps {
-  onCancel: () => void,
+type IInvitedModalProps = {
+  invitationLink: string
+  onCancel: () => void
 }
 const InvitedModal = ({
-  onCancel
+  invitationLink,
+  onCancel,
 }: IInvitedModalProps) => {
   const { t } = useTranslation()
 
@@ -27,10 +29,14 @@ const InvitedModal = ({
           <XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
         </div>
         <div className='mb-1 text-xl font-semibold text-gray-900'>{t('common.members.invitationSent')}</div>
-        <div className='mb-10 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
+        <div className='mb-5 text-sm text-gray-500'>{t('common.members.invitationSentTip')}</div>
+        <div className='mb-9'>
+          <div className='py-2 text-sm font-Medium text-gray-900'>{t('common.members.invitationLink')}</div>
+          <InvitationLink value={invitationLink} />
+        </div>
         <div className='flex justify-end'>
-          <Button 
-            className='w-[96px] text-sm font-medium' 
+          <Button
+            className='w-[96px] text-sm font-medium'
             onClick={onCancel}
             type='primary'
           >
@@ -42,4 +48,4 @@ const InvitedModal = ({
   )
 }
 
-export default InvitedModal
+export default InvitedModal

+ 63 - 0
web/app/components/header/account-setting/members-page/invited-modal/invitation-link.tsx

@@ -0,0 +1,63 @@
+'use client'
+import React, { useCallback, useEffect, useState } from 'react'
+import { t } from 'i18next'
+import s from './index.module.css'
+import Tooltip from '@/app/components/base/tooltip'
+import useCopyToClipboard from '@/hooks/use-copy-to-clipboard'
+
+type IInvitationLinkProps = {
+  value?: string
+}
+
+const InvitationLink = ({
+  value = '',
+}: IInvitationLinkProps) => {
+  const [isCopied, setIsCopied] = useState(false)
+  const [_, copy] = useCopyToClipboard()
+
+  const copyHandle = useCallback(() => {
+    copy(value)
+    setIsCopied(true)
+  }, [value, copy])
+
+  useEffect(() => {
+    if (isCopied) {
+      const timeout = setTimeout(() => {
+        setIsCopied(false)
+      }, 1000)
+
+      return () => {
+        clearTimeout(timeout)
+      }
+    }
+  }, [isCopied])
+
+  return (
+    <div className='flex rounded-lg bg-gray-100 hover:bg-gray-100 border border-gray-200 py-2 items-center'>
+      <div className="flex items-center flex-grow h-5">
+        <div className='flex-grow bg-gray-100 text-[13px] relative h-full'>
+          <Tooltip
+            selector="top-uniq"
+            content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
+            className='z-10'
+          >
+            <div className='absolute top-0 left-0 w-full pl-2 pr-2 truncate cursor-pointer r-0' onClick={copyHandle}>{value}</div>
+          </Tooltip>
+        </div>
+        <div className="flex-shrink-0 h-4 bg-gray-200 border" />
+        <Tooltip
+          selector="top-uniq"
+          content={isCopied ? `${t('appApi.copied')}` : `${t('appApi.copy')}`}
+          className='z-10'
+        >
+          <div className="px-0.5 flex-shrink-0">
+            <div className={`box-border w-[30px] h-[30px] flex items-center justify-center rounded-lg hover:bg-gray-100 cursor-pointer ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle}>
+            </div>
+          </div>
+        </Tooltip>
+      </div>
+    </div>
+  )
+}
+
+export default InvitationLink

+ 33 - 37
web/app/install/installForm.tsx

@@ -1,10 +1,10 @@
 'use client'
 import React from 'react'
 import { useTranslation } from 'react-i18next'
-import Button from '@/app/components/base/button'
 import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import Toast from '../components/base/toast'
+import Button from '@/app/components/base/button'
 import { setup } from '@/service/common'
 
 const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/
@@ -40,36 +40,37 @@ const InstallForm = () => {
       showErrorMessage(t('login.error.passwordEmpty'))
       return false
     }
-    if (!validPassword.test(password)) {
+    if (!validPassword.test(password))
       showErrorMessage(t('login.error.passwordInvalid'))
-    }
+
     return true
   }
   const handleSetting = async () => {
-    if (!valid()) return
+    if (!valid())
+      return
     await setup({
       body: {
         email,
         name,
-        password
-      }
+        password,
+      },
     })
     router.push('/signin')
   }
   return (
     <>
       <div className="sm:mx-auto sm:w-full sm:max-w-md">
-        <h2 className="text-3xl font-normal text-gray-900">{t('login.setAdminAccount')}</h2>
+        <h2 className="text-[32px] font-bold text-gray-900">{t('login.setAdminAccount')}</h2>
         <p className='
-          mt-2 text-sm text-gray-600
+          mt-1 text-sm text-gray-600
         '>{t('login.setAdminAccountDesc')}</p>
       </div>
 
       <div className="grow mt-8 sm:mx-auto sm:w-full sm:max-w-md">
         <div className="bg-white ">
-          <form className="space-y-6" onSubmit={() => { }}>
-            <div>
-              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
+          <form onSubmit={() => { }}>
+            <div className='mb-5'>
+              <label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
                 {t('login.email')}
               </label>
               <div className="mt-1">
@@ -78,13 +79,14 @@ const InstallForm = () => {
                   type="email"
                   value={email}
                   onChange={e => setEmail(e.target.value)}
-                  className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
+                  placeholder={t('login.emailPlaceholder') || ''}
+                  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'}
                 />
               </div>
             </div>
 
-            <div>
-              <label htmlFor="name" className="block text-sm font-medium text-gray-700">
+            <div className='mb-5'>
+              <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
                 {t('login.name')}
               </label>
               <div className="mt-1 relative rounded-md shadow-sm">
@@ -93,13 +95,14 @@ const InstallForm = () => {
                   type="text"
                   value={name}
                   onChange={e => setName(e.target.value)}
-                  className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10'}
+                  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'}
                 />
-
               </div>
             </div>
-            <div>
-              <label htmlFor="password" className="block text-sm font-medium text-gray-700">
+
+            <div className='mb-5'>
+              <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
                 {t('login.password')}
               </label>
               <div className="mt-1 relative rounded-md shadow-sm">
@@ -108,7 +111,8 @@ const InstallForm = () => {
                   type='password'
                   value={password}
                   onChange={e => setPassword(e.target.value)}
-                  className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10'}
+                  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'}
                 />
               </div>
               <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
@@ -123,29 +127,21 @@ const InstallForm = () => {
                 </div>
               </div>
             </div> */}
-            {/*  agree to our Terms and Privacy Policy. */}
-            <div className="block mt-6 text-xs text-gray-600">
-              {t('login.tosDesc')}
-              &nbsp;
-              <Link
-                className='text-primary-600'
-                target={'_blank'}
-                href='https://docs.dify.ai/user-agreement/terms-of-service'
-              >{t('login.tos')}</Link>
-              &nbsp;&&nbsp;
-              <Link
-                className='text-primary-600'
-                target={'_blank'}
-                href='https://langgenius.ai/privacy-policy'
-              >{t('login.pp')}</Link>
-            </div>
-
             <div>
-              <Button type='primary' onClick={handleSetting}>
+              <Button type='primary' className='w-full !fone-medium !text-sm' onClick={handleSetting}>
                 {t('login.installBtn')}
               </Button>
             </div>
           </form>
+          <div className="block w-hull mt-2 text-xs text-gray-600">
+            {t('login.license.tip')}
+            &nbsp;
+            <Link
+              className='text-primary-600'
+              target={'_blank'}
+              href='https://docs.dify.ai/community/open-source'
+            >{t('login.license.link')}</Link>
+          </div>
         </div>
       </div>
     </>

+ 1 - 7
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)

+ 2 - 3
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 <div className={
-    classNames(
+    cn(
       'flex flex-col items-center w-full grow items-center justify-center',
       'px-6',
       'md:px-[108px]',
@@ -28,7 +28,6 @@ const Forms = () => {
     <div className='flex flex-col md:w-[400px]'>
       {getForm()}
     </div>
-    
   </div>
 }
 

+ 23 - 23
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 (
     <>
       <div className="w-full mx-auto">
-        <h2 className="text-3xl font-normal text-gray-900">{t('login.pageTitle')}</h2>
-        <p className='mt-2 text-sm text-gray-600 '>{t('login.welcome')}</p>
+        <h2 className="text-[32px] font-bold text-gray-900">{t('login.pageTitle')}</h2>
+        <p className='mt-1 text-sm text-gray-600'>{t('login.welcome')}</p>
       </div>
 
       <div className="w-full mx-auto mt-8">
@@ -145,7 +145,7 @@ const NormalForm = () => {
                   <Button
                     type='default'
                     disabled={isLoading}
-                    className='w-full'
+                    className='w-full hover:!bg-gray-50 !text-sm !font-medium'
                   >
                     <>
                       <span className={
@@ -154,7 +154,7 @@ const NormalForm = () => {
                           'w-5 h-5 mr-2',
                         )
                       } />
-                      <span className="truncate">{t('login.withGitHub')}</span>
+                      <span className="truncate text-gray-800">{t('login.withGitHub')}</span>
                     </>
                   </Button>
                 </a>
@@ -164,7 +164,7 @@ const NormalForm = () => {
                   <Button
                     type='default'
                     disabled={isLoading}
-                    className='w-full'
+                    className='w-full hover:!bg-gray-50 !text-sm !font-medium'
                   >
                     <>
                       <span className={
@@ -173,7 +173,7 @@ const NormalForm = () => {
                           'w-5 h-5 mr-2',
                         )
                       } />
-                      <span className="truncate">{t('login.withGoogle')}</span>
+                      <span className="truncate text-gray-800">{t('login.withGoogle')}</span>
                     </>
                   </Button>
                 </a>
@@ -192,9 +192,9 @@ const NormalForm = () => {
                 </div>
               </div> */}
 
-              <form className="space-y-6" onSubmit={() => { }}>
-                <div>
-                  <label htmlFor="email" className="block text-sm font-medium text-gray-700">
+              <form onSubmit={() => { }}>
+                <div className='mb-5'>
+                  <label htmlFor="email" className="my-2 block text-sm font-medium text-gray-900">
                     {t('login.email')}
                   </label>
                   <div className="mt-1">
@@ -204,13 +204,14 @@ const NormalForm = () => {
                       id="email"
                       type="email"
                       autoComplete="email"
-                      className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
+                      placeholder={t('login.emailPlaceholder') || ''}
+                      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'}
                     />
                   </div>
                 </div>
 
-                <div>
-                  <label htmlFor="password" className="flex items-center justify-between text-sm font-medium text-gray-700">
+                <div className='mb-4'>
+                  <label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
                     <span>{t('login.password')}</span>
                     {/* <Tooltip
                       selector='forget-password'
@@ -235,10 +236,8 @@ const NormalForm = () => {
                       onChange={e => setPassword(e.target.value)}
                       type={showPassword ? 'text' : 'password'}
                       autoComplete="current-password"
-                      className={`appearance-none block w-full px-3 py-2
-                  border border-gray-300
-                  focus:outline-none focus:ring-indigo-500 focus:border-indigo-500
-                  rounded-md shadow-sm placeholder-gray-400 sm:text-sm pr-10`}
+                      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'}
                     />
                     <div className="absolute inset-y-0 right-0 flex items-center pr-3">
                       <button
@@ -252,18 +251,19 @@ const NormalForm = () => {
                   </div>
                 </div>
 
-                <div>
+                <div className='mb-2'>
                   <Button
                     type='primary'
                     onClick={handleEmailPasswordLogin}
                     disabled={isLoading}
+                    className="w-full !fone-medium !text-sm"
                   >{t('login.signBtn')}</Button>
                 </div>
               </form>
             </>
           }
           {/*  agree to our Terms and Privacy Policy. */}
-          <div className="block mt-6 text-xs text-gray-600">
+          <div className="w-hull text-center block mt-2 text-xs text-gray-600">
             {t('login.tosDesc')}
             &nbsp;
             <Link

+ 22 - 12
web/app/signin/oneMoreStep.tsx

@@ -1,6 +1,7 @@
 'use client'
 import React, { useEffect, useReducer } from 'react'
 import { useTranslation } from 'react-i18next'
+import Link from 'next/link'
 import useSWR from 'swr'
 import { useRouter } from 'next/navigation'
 import Button from '@/app/components/base/button'
@@ -74,14 +75,14 @@ const OneMoreStep = () => {
   return (
     <>
       <div className="w-full mx-auto">
-        <h2 className="text-3xl font-normal text-gray-900">{t('login.oneMoreStep')}</h2>
-        <p className='mt-2 text-sm text-gray-600 '>{t('login.createSample')}</p>
+        <h2 className="text-[32px] font-bold text-gray-900">{t('login.oneMoreStep')}</h2>
+        <p className='mt-1 text-sm text-gray-600 '>{t('login.createSample')}</p>
       </div>
 
-      <div className="w-full mx-auto mt-8">
-        <div className="space-y-6 bg-white">
-          <div className="">
-            <label className="flex items-center justify-between text-sm font-medium text-gray-900">
+      <div className="w-full mx-auto mt-6">
+        <div className="bg-white">
+          <div className="mb-5">
+            <label className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
               {t('login.invitationCode')}
               <Tooltip
                 clickable
@@ -103,16 +104,16 @@ const OneMoreStep = () => {
                 id="invitation_code"
                 value={state.invitation_code}
                 type="text"
-                className={'appearance-none block w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-primary-600 focus:border-primary-600 rounded-md shadow-sm placeholder-gray-400 sm:text-sm'}
+                placeholder={t('login.invitationCodePlaceholder') || ''}
+                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'}
                 onChange={(e) => {
                   dispatch({ type: 'invitation_code', value: e.target.value.trim() })
                 }}
               />
             </div>
           </div>
-
-          <div>
-            <label htmlFor="name" className="block text-sm font-medium text-gray-700">
+          <div className='mb-5'>
+            <label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-gray-900">
               {t('login.interfaceLanguage')}
             </label>
             <div className="relative mt-1 rounded-md shadow-sm">
@@ -125,8 +126,7 @@ const OneMoreStep = () => {
               />
             </div>
           </div>
-          <div>
-
+          <div className='mb-4'>
             <label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
               {t('login.timezone')}
             </label>
@@ -143,6 +143,7 @@ const OneMoreStep = () => {
           <div>
             <Button
               type='primary'
+              className='w-full !fone-medium !text-sm'
               disabled={state.formState === 'processing'}
               onClick={() => {
                 dispatch({ type: 'formState', value: 'processing' })
@@ -151,6 +152,15 @@ const OneMoreStep = () => {
               {t('login.go')}
             </Button>
           </div>
+          <div className="block w-hull mt-2 text-xs text-gray-600">
+            {t('login.license.tip')}
+            &nbsp;
+            <Link
+              className='text-primary-600'
+              target={'_blank'}
+              href='https://docs.dify.ai/community/open-source'
+            >{t('login.license.link')}</Link>
+          </div>
         </div>
       </div>
     </>

+ 5 - 6
web/app/signin/page.tsx

@@ -1,24 +1,23 @@
 import React from 'react'
+import cn from 'classnames'
 import Forms from './forms'
 import Header from './_header'
 import style from './page.module.css'
-import classNames from 'classnames'
 
 const SignIn = () => {
-
   return (
     <>
-      <div className={classNames(
+      <div className={cn(
         style.background,
         'flex w-full min-h-screen',
         'sm:p-4 lg:p-8',
         'gap-x-20',
-        'justify-center lg:justify-start'
+        'justify-center lg:justify-start',
       )}>
         <div className={
-          classNames(
+          cn(
             'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
-            'space-between'
+            'space-between',
           )
         }>
           <Header />

+ 2 - 0
web/context/app-context.tsx

@@ -37,6 +37,8 @@ const AppContext = createContext<AppContextValue>({
     id: '',
     name: '',
     email: '',
+    avatar: '',
+    is_password_set: false,
   },
   mutateUserProfile: () => { },
   pageContainerRef: createRef(),

+ 13 - 2
web/docker/entrypoint.sh

@@ -4,8 +4,19 @@ set -e
 
 export NEXT_PUBLIC_DEPLOY_ENV=${DEPLOY_ENV}
 export NEXT_PUBLIC_EDITION=${EDITION}
-export NEXT_PUBLIC_API_PREFIX=${CONSOLE_URL}/console/api
-export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_URL}/api
+
+if [[ -z "$CONSOLE_URL" ]]; then
+  export NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api
+else
+  export NEXT_PUBLIC_API_PREFIX=${CONSOLE_URL}/console/api
+fi
+
+if [[ -z "$APP_URL" ]]; then
+  export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_API_URL}/api
+else
+  export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_URL}/api
+fi
+
 export NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}
 
 /usr/local/bin/pm2 -v

+ 13 - 3
web/i18n/lang/common.en.ts

@@ -14,6 +14,7 @@ const translation = {
     edit: 'Edit',
     add: 'Add',
     refresh: 'Restart',
+    reset: 'Reset',
     search: 'Search',
     change: 'Change',
     remove: 'Remove',
@@ -95,6 +96,14 @@ const translation = {
     avatar: 'Avatar',
     name: 'Name',
     email: 'Email',
+    password: 'Password',
+    passwordTip: 'You can set a permanent password if you don’t want to use temporary login codes',
+    setPassword: 'Set a password',
+    resetPassword: 'Reset password',
+    currentPassword: 'Current password',
+    newPassword: 'New password',
+    confirmPassword: 'Confirm password',
+    notEqual: 'Two passwords are different.',
     langGeniusAccount: 'Dify account',
     langGeniusAccountTip: 'Your Dify account and associated user data.',
     editName: 'Edit Name',
@@ -111,15 +120,16 @@ const translation = {
     admin: 'Admin',
     adminTip: 'Can build apps & manage team settings',
     normal: 'Normal',
-    normalTip: 'Only can use appscan not build apps',
+    normalTip: 'Only can use apps, can not build apps',
     inviteTeamMember: 'Add team member',
     inviteTeamMemberTip: 'They can access your team data directly after signing in.',
     email: 'Email',
     emailInvalid: 'Invalid Email Format',
     emailPlaceholder: 'Input Email',
     sendInvite: 'Add',
-    invitationSent: 'Added',
-    invitationSentTip: 'Added, and they can sign in to Dify to access your team data.',
+    invitationSent: 'Invitation sent',
+    invitationSentTip: 'Invitation sent, and they can sign in to Dify to access your team data.',
+    invitationLink: 'Invitation Link',
     ok: 'OK',
     removeFromTeam: 'Remove from team',
     removeFromTeamTip: 'Will remove team access',

+ 12 - 3
web/i18n/lang/common.zh.ts

@@ -14,6 +14,7 @@ const translation = {
     edit: '编辑',
     add: '添加',
     refresh: '重新开始',
+    reset: '重置',
     search: '搜索',
     change: '更改',
     remove: '移除',
@@ -95,7 +96,14 @@ const translation = {
     avatar: '头像',
     name: '用户名',
     email: '邮箱',
-    edit: '编辑',
+    password: '密码',
+    passwordTip: '如果您不想使用验证码登录,可以设置永久密码',
+    setPassword: '设置密码',
+    resetPassword: '重置密码',
+    currentPassword: '原密码',
+    newPassword: '新密码',
+    notEqual: '两个密码不相同',
+    confirmPassword: '确认密码',
     langGeniusAccount: 'Dify 账号',
     langGeniusAccountTip: '您的 Dify 账号和相关的用户数据。',
     editName: '编辑名字',
@@ -119,8 +127,9 @@ const translation = {
     emailInvalid: '邮箱格式无效',
     emailPlaceholder: '输入邮箱',
     sendInvite: '添加',
-    invitationSent: '已添加',
-    invitationSentTip: '已添加,对方登录 Dify 后即可访问你的团队数据。',
+    invitationSent: '邀请已发送',
+    invitationSentTip: '邀请已发送,对方登录 Dify 后即可访问你的团队数据。',
+    invitationLink: '邀请链接',
     ok: '好的',
     removeFromTeam: '移除团队',
     removeFromTeamTip: '将取消团队访问',

+ 53 - 37
web/i18n/lang/login.en.ts

@@ -1,41 +1,57 @@
 const translation = {
-  "pageTitle": "Hey, let's get started!👋",
-  "welcome": "Welcome to Dify, please log in to continue.",
-  "email": "Email address",
-  "password": "Password",
-  "name": "Name",
-  "forget": "Forgot your password?",
-  "signBtn": "Sign in",
-  "installBtn": "Setting",
-  "setAdminAccount": "Setting up an admin account",
-  "setAdminAccountDesc": "Maximum privileges for admin account, which can be used to create applications and manage LLM providers, etc.",
-  "createAndSignIn": "Create and sign in",
-  "oneMoreStep": "One more step",
-  "createSample": "Based on this information, we’ll create sample application for you",
-  "invitationCode": "Invitation Code",
-  "interfaceLanguage": "Interface Dify",
-  "timezone": "Time zone",
-  "go": "Go to Dify",
-  "sendUsMail": "Email us your introduction, and we'll handle the invitation request.",
-  "acceptPP": "I have read and accept the privacy policy",
-  "reset": "Please run following command to reset your password",
-  "withGitHub": "Continue with GitHub",
-  "withGoogle": "Continue with Google",
-  "rightTitle": "Unlock the full potential of LLM",
-  "rightDesc": "Effortlessly build visually captivating, operable, and improvable AI applications.",
-  "tos": "Terms of Service",
-  "pp": "Privacy Policy",
-  "tosDesc": "By signing up, you agree to our",
-  "donthave": "Don't have?",
-  "invalidInvitationCode": "Invalid invitation code",
-  "accountAlreadyInited": "Account already inited",
-  "error": {
-    "emailEmpty": "Email address is required",
-    "emailInValid": "Please enter a valid email address",
-    "nameEmpty": "Name is required",
-    "passwordEmpty": "Password is required",
-    "passwordInvalid": "Password must contain letters and numbers, and the length must be greater than 8",
-  }
+  pageTitle: 'Hey, let\'s get started!👋',
+  welcome: 'Welcome to Dify, please log in to continue.',
+  email: 'Email address',
+  emailPlaceholder: 'Your email',
+  password: 'Password',
+  passwordPlaceholder: 'Your password',
+  name: 'Username',
+  namePlaceholder: 'Your username',
+  forget: 'Forgot your password?',
+  signBtn: 'Sign in',
+  installBtn: 'Setting',
+  setAdminAccount: 'Setting up an admin account',
+  setAdminAccountDesc: 'Maximum privileges for admin account, which can be used to create applications and manage LLM providers, etc.',
+  createAndSignIn: 'Create and sign in',
+  oneMoreStep: 'One more step',
+  createSample: 'Based on this information, we’ll create sample application for you',
+  invitationCode: 'Invitation Code',
+  invitationCodePlaceholder: 'Your invitation code',
+  interfaceLanguage: 'Interface Language',
+  timezone: 'Time zone',
+  go: 'Go to Dify',
+  sendUsMail: 'Email us your introduction, and we\'ll handle the invitation request.',
+  acceptPP: 'I have read and accept the privacy policy',
+  reset: 'Please run following command to reset your password',
+  withGitHub: 'Continue with GitHub',
+  withGoogle: 'Continue with Google',
+  rightTitle: 'Unlock the full potential of LLM',
+  rightDesc: 'Effortlessly build visually captivating, operable, and improvable AI applications.',
+  tos: 'Terms of Service',
+  pp: 'Privacy Policy',
+  tosDesc: 'By signing up, you agree to our',
+  donthave: 'Don\'t have?',
+  invalidInvitationCode: 'Invalid invitation code',
+  accountAlreadyInited: 'Account already inited',
+  error: {
+    emailEmpty: 'Email address is required',
+    emailInValid: 'Please enter a valid email address',
+    nameEmpty: 'Name is required',
+    passwordEmpty: 'Password is required',
+    passwordInvalid: 'Password must contain letters and numbers, and the length must be greater than 8',
+  },
+  license: {
+    tip: 'Before starting Dify Community Edition, read the GitHub',
+    link: 'Open-source License',
+  },
+  join: 'Join',
+  joinTipStart: 'Invite you join',
+  joinTipEnd: 'team on Dify',
+  invalid: 'The link has expired',
+  explore: 'Explore Dify',
+  activatedTipStart: 'You have joined the',
+  activatedTipEnd: 'team',
+  activated: 'Sign In Now',
 }
 
 export default translation

+ 53 - 37
web/i18n/lang/login.zh.ts

@@ -1,41 +1,57 @@
 const translation = {
-  "pageTitle": "嗨,近来可好 👋",
-  "welcome": "欢迎来到 Dify, 登录以继续",
-  "email": "邮箱",
-  "password": "密码",
-  "name": "用户名",
-  "forget": "忘记密码?",
-  "signBtn": "登录",
-  "installBtn": "设置",
-  "setAdminAccount": "设置管理员账户",
-  "setAdminAccountDesc": "管理员拥有的最大权限,可用于创建应用和管理 LLM 供应商等。",
-  "createAndSignIn": "创建账户",
-  "oneMoreStep": "还差一步",
-  "createSample": "基于这些信息,我们将为您创建一个示例应用",
-  "invitationCode": "邀请码",
-  "interfaceLanguage": "界面语言",
-  "timezone": "时区",
-  "go": "跳转至 Dify",
-  "sendUsMail": "发封邮件介绍你自己,我们会尽快处理。",
-  "acceptPP": "我已阅读并接受隐私政策",
-  "reset": "请运行以下命令重置密码",
-  "withGitHub": "使用 GitHub 登录",
-  "withGoogle": "使用 Google 登录",
-  "rightTitle": "释放大型语言模型的全部潜能",
-  "rightDesc": "简单构建可视化、可运营、可改进的 AI 应用",
-  "tos": "使用协议",
-  "pp": "隐私政策",
-  "tosDesc": "使用即代表你并同意我们的",
-  "donthave": "还没有邀请码?",
-  "invalidInvitationCode": "无效的邀请码",
-  "accountAlreadyInited": "账户已经初始化",
-  "error": {
-    "emailEmpty": "邮箱不能为空",
-    "emailInValid": "请输入有效的邮箱地址",
-    "nameEmpty": "用户名不能为空",
-    "passwordEmpty": "密码不能为空",
-    "passwordInvalid": "密码必须包含字母和数字,且长度不小于8位",
-  }
+  pageTitle: '嗨,近来可好 👋',
+  welcome: '欢迎来到 Dify, 登录以继续',
+  email: '邮箱',
+  emailPlaceholder: '输入邮箱地址',
+  password: '密码',
+  passwordPlaceholder: '输入密码',
+  name: '用户名',
+  namePlaceholder: '输入用户名',
+  forget: '忘记密码?',
+  signBtn: '登录',
+  installBtn: '设置',
+  setAdminAccount: '设置管理员账户',
+  setAdminAccountDesc: '管理员拥有的最大权限,可用于创建应用和管理 LLM 供应商等。',
+  createAndSignIn: '创建账户',
+  oneMoreStep: '还差一步',
+  createSample: '基于这些信息,我们将为您创建一个示例应用',
+  invitationCode: '邀请码',
+  invitationCodePlaceholder: '输入邀请码',
+  interfaceLanguage: '界面语言',
+  timezone: '时区',
+  go: '跳转至 Dify',
+  sendUsMail: '发封邮件介绍你自己,我们会尽快处理。',
+  acceptPP: '我已阅读并接受隐私政策',
+  reset: '请运行以下命令重置密码',
+  withGitHub: '使用 GitHub 登录',
+  withGoogle: '使用 Google 登录',
+  rightTitle: '释放大型语言模型的全部潜能',
+  rightDesc: '简单构建可视化、可运营、可改进的 AI 应用',
+  tos: '使用协议',
+  pp: '隐私政策',
+  tosDesc: '使用即代表你并同意我们的',
+  donthave: '还没有邀请码?',
+  invalidInvitationCode: '无效的邀请码',
+  accountAlreadyInited: '账户已经初始化',
+  error: {
+    emailEmpty: '邮箱不能为空',
+    emailInValid: '请输入有效的邮箱地址',
+    nameEmpty: '用户名不能为空',
+    passwordEmpty: '密码不能为空',
+    passwordInvalid: '密码必须包含字母和数字,且长度不小于8位',
+  },
+  license: {
+    tip: '启动 Dify 社区版之前, 请阅读 GitHub 上的',
+    link: '开源协议',
+  },
+  join: '加入',
+  joinTipStart: '邀请你加入',
+  joinTipEnd: '团队',
+  invalid: '链接已失效',
+  explore: '探索 Dify',
+  activatedTipStart: '您已加入',
+  activatedTipEnd: '团队',
+  activated: '现在登录',
 }
 
 export default translation

+ 2 - 0
web/models/common.ts

@@ -10,6 +10,8 @@ export type UserProfileResponse = {
   id: string
   name: string
   email: string
+  avatar: string
+  is_password_set: boolean
   interface_language?: string
   interface_theme?: string
   timezone?: string

+ 10 - 2
web/service/common.ts

@@ -66,8 +66,8 @@ export const fetchAccountIntegrates: Fetcher<{ data: AccountIntegrate[] | null }
   return get(url, { params }) as Promise<{ data: AccountIntegrate[] | null }>
 }
 
-export const inviteMember: Fetcher<CommonResponse & { account: Member }, { url: string; body: Record<string, any> }> = ({ url, body }) => {
-  return post(url, { body }) as Promise<CommonResponse & { account: Member }>
+export const inviteMember: Fetcher<CommonResponse & { account: Member; invite_url: string }, { url: string; body: Record<string, any> }> = ({ url, body }) => {
+  return post(url, { body }) as Promise<CommonResponse & { account: Member; invite_url: string }>
 }
 
 export const updateMemberRole: Fetcher<CommonResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {
@@ -101,3 +101,11 @@ export const syncDataSourceNotion: Fetcher<CommonResponse, { url: string }> = ({
 export const updateDataSourceNotionAction: Fetcher<CommonResponse, { url: string }> = ({ url }) => {
   return patch(url) as Promise<CommonResponse>
 }
+
+export const invitationCheck: Fetcher<CommonResponse & { is_valid: boolean; workspace_name: string }, { url: string; params: { workspace_id: string; email: string; token: string } }> = ({ url, params }) => {
+  return get(url, { params }) as Promise<CommonResponse & { is_valid: boolean; workspace_name: string }>
+}
+
+export const activateMember: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => {
+  return post(url, { body }) as Promise<CommonResponse>
+}