Explorar el Código

feat: member invitation and activation (#535)

Co-authored-by: John Wang <takatost@gmail.com>
tags/0.3.8
KVOJJJin hace 2 años
padre
commit
cd51d3323b
No account linked to committer's email address
Se han modificado 51 ficheros con 1235 adiciones y 329 borrados
  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 Ver fichero

SECRET_KEY= SECRET_KEY=


# Console API base URL # 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 # 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 configuration
CELERY_BROKER_URL=redis://:difyai123456@localhost:6379/1 CELERY_BROKER_URL=redis://:difyai123456@localhost:6379/1
QDRANT_URL=path:storage/qdrant QDRANT_URL=path:storage/qdrant
QDRANT_API_KEY=your-qdrant-api-key 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 configuration
SENTRY_DSN= SENTRY_DSN=



+ 5
- 3
api/Dockerfile Ver fichero

ENV FLASK_APP app.py ENV FLASK_APP app.py
ENV EDITION SELF_HOSTED ENV EDITION SELF_HOSTED
ENV DEPLOY_ENV PRODUCTION 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 EXPOSE 5001



+ 2
- 1
api/app.py Ver fichero

from flask_cors import CORS from flask_cors import CORS


from extensions import ext_session, ext_celery, ext_sentry, ext_redis, ext_login, ext_migrate, \ 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_database import db
from extensions.ext_login import login_manager from extensions.ext_login import login_manager


ext_celery.init_app(app) ext_celery.init_app(app)
ext_session.init_app(app) ext_session.init_app(app)
ext_login.init_app(app) ext_login.init_app(app)
ext_mail.init_app(app)
ext_sentry.init_app(app) ext_sentry.init_app(app)





+ 16
- 4
api/config.py Ver fichero

'SESSION_REDIS_USE_SSL': 'False', 'SESSION_REDIS_USE_SSL': 'False',
'OAUTH_REDIRECT_PATH': '/console/api/oauth/authorize', 'OAUTH_REDIRECT_PATH': '/console/api/oauth/authorize',
'OAUTH_REDIRECT_INDEX_PATH': '/', '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_TYPE': 'local',
'STORAGE_LOCAL_PATH': 'storage', 'STORAGE_LOCAL_PATH': 'storage',
'CHECK_UPDATE_URL': 'https://updates.dify.ai', 'CHECK_UPDATE_URL': 'https://updates.dify.ai',


def __init__(self): def __init__(self):
# app settings # 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.CONSOLE_URL = get_env('CONSOLE_URL')
self.API_URL = get_env('API_URL') self.API_URL = get_env('API_URL')
self.APP_URL = get_env('APP_URL') self.APP_URL = get_env('APP_URL')


# cors settings # cors settings
self.CONSOLE_CORS_ALLOW_ORIGINS = get_cors_allow_origins( 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( self.WEB_API_CORS_ALLOW_ORIGINS = get_cors_allow_origins(
'WEB_API_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 # sentry settings
self.SENTRY_DSN = get_env('SENTRY_DSN') self.SENTRY_DSN = get_env('SENTRY_DSN')
self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE')) self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE'))

+ 1
- 1
api/controllers/console/__init__.py Ver fichero

from .app import app, site, completion, model_config, statistic, conversation, message, generator, audio from .app import app, site, completion, model_config, statistic, conversation, message, generator, audio


# Import auth controllers # Import auth controllers
from .auth import login, oauth, data_source_oauth
from .auth import login, oauth, data_source_oauth, activate


# Import datasets controllers # Import datasets controllers
from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing, data_source from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing, data_source

+ 75
- 0
api/controllers/console/auth/activate.py Ver fichero

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 Ver fichero

client_secret=current_app.config.get( client_secret=current_app.config.get(
'NOTION_CLIENT_SECRET'), 'NOTION_CLIENT_SECRET'),
redirect_uri=current_app.config.get( 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 = { OAUTH_PROVIDERS = {
'notion': notion_oauth 'notion': notion_oauth
if current_app.config.get('NOTION_INTEGRATION_TYPE') == 'internal': if current_app.config.get('NOTION_INTEGRATION_TYPE') == 'internal':
internal_secret = current_app.config.get('NOTION_INTERNAL_SECRET') internal_secret = current_app.config.get('NOTION_INTERNAL_SECRET')
oauth_provider.save_internal_access_token(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: else:
auth_url = oauth_provider.get_authorization_url() auth_url = oauth_provider.get_authorization_url()
return redirect(auth_url) return redirect(auth_url)
f"An error occurred during the OAuthCallback process with {provider}: {e.response.text}") f"An error occurred during the OAuthCallback process with {provider}: {e.response.text}")
return {'error': 'OAuth data source process failed'}, 400 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: elif 'error' in request.args:
error = request.args.get('error') 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: 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): class OAuthDataSourceSync(Resource):

+ 3
- 3
api/controllers/console/auth/oauth.py Ver fichero

client_secret=current_app.config.get( client_secret=current_app.config.get(
'GITHUB_CLIENT_SECRET'), 'GITHUB_CLIENT_SECRET'),
redirect_uri=current_app.config.get( 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'), google_oauth = GoogleOAuth(client_id=current_app.config.get('GOOGLE_CLIENT_ID'),
client_secret=current_app.config.get( client_secret=current_app.config.get(
'GOOGLE_CLIENT_SECRET'), 'GOOGLE_CLIENT_SECRET'),
redirect_uri=current_app.config.get( redirect_uri=current_app.config.get(
'CONSOLE_URL') + '/console/api/oauth/authorize/google')
'CONSOLE_API_URL') + '/console/api/oauth/authorize/google')


OAUTH_PROVIDERS = { OAUTH_PROVIDERS = {
'github': github_oauth, 'github': github_oauth,
flask_login.login_user(account, remember=True) flask_login.login_user(account, remember=True)
AccountService.update_last_login(account, request) 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]: def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Optional[Account]:

+ 6
- 0
api/controllers/console/error.py Ver fichero

error_code = 'account_not_link_tenant' error_code = 'account_not_link_tenant'
description = "Account not link tenant." description = "Account not link tenant."
code = 403 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 Ver fichero

from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_restful import Resource, reqparse, fields, marshal_with 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 import api
from controllers.console.setup import setup_required from controllers.console.setup import setup_required
from controllers.console.workspace.error import AccountAlreadyInitedError, InvalidInvitationCodeError, \ from controllers.console.workspace.error import AccountAlreadyInitedError, InvalidInvitationCodeError, \
RepeatPasswordNotMatchError
RepeatPasswordNotMatchError, CurrentPasswordIncorrectError
from controllers.console.wraps import account_initialization_required from controllers.console.wraps import account_initialization_required
from libs.helper import TimestampField, supported_language, timezone from libs.helper import TimestampField, supported_language, timezone
from extensions.ext_database import db from extensions.ext_database import db
from models.account import InvitationCode, AccountIntegrate from models.account import InvitationCode, AccountIntegrate
from services.account_service import AccountService from services.account_service import AccountService



account_fields = { account_fields = {
'id': fields.String, 'id': fields.String,
'name': fields.String, 'name': fields.String,
'avatar': fields.String, 'avatar': fields.String,
'email': fields.String, 'email': fields.String,
'is_password_set': fields.Boolean,
'interface_language': fields.String, 'interface_language': fields.String,
'interface_theme': fields.String, 'interface_theme': fields.String,
'timezone': fields.String, 'timezone': fields.String,
if args['new_password'] != args['repeat_new_password']: if args['new_password'] != args['repeat_new_password']:
raise RepeatPasswordNotMatchError() 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"} return {"result": "success"}



+ 6
- 0
api/controllers/console/workspace/error.py Ver fichero

code = 400 code = 400




class CurrentPasswordIncorrectError(BaseHTTPException):
error_code = 'current_password_incorrect'
description = "Current password is incorrect."
code = 400


class ProviderRequestFailedError(BaseHTTPException): class ProviderRequestFailedError(BaseHTTPException):
error_code = 'provider_request_failed' error_code = 'provider_request_failed'
description = None description = None

+ 14
- 4
api/controllers/console/workspace/members.py Ver fichero

# -*- coding:utf-8 -*- # -*- coding:utf-8 -*-
from flask import current_app
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal


inviter = current_user inviter = current_user


try: 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( account = db.session.query(Account, TenantAccountJoin.role).join(
TenantAccountJoin, Account.id == TenantAccountJoin.account_id TenantAccountJoin, Account.id == TenantAccountJoin.account_id
).filter(Account.email == args['email']).first() ).filter(Account.email == args['email']).first()


# todo:413 # 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): class MemberCancelInviteApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
def delete(self, member_id): 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: if not member:
abort(404) abort(404)



+ 61
- 0
api/extensions/ext_mail.py Ver fichero

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 Ver fichero

created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) 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 @property
def current_tenant(self): def current_tenant(self):
return self._current_tenant return self._current_tenant

+ 3
- 2
api/models/model.py Ver fichero



@property @property
def api_base_url(self): 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 @property
def tenant(self): def tenant(self):


@property @property
def app_base_url(self): 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): class ApiToken(db.Model):

+ 3
- 2
api/requirements.txt Ver fichero

boto3~=1.26.123 boto3~=1.26.123
tenacity==8.2.2 tenacity==8.2.2
cachetools~=5.3.0 cachetools~=5.3.0
weaviate-client~=3.16.2
weaviate-client~=3.21.0
qdrant_client~=1.1.6 qdrant_client~=1.1.6
mailchimp-transactional~=1.0.50 mailchimp-transactional~=1.0.50
scikit-learn==1.2.2 scikit-learn==1.2.2
chardet~=5.1.0 chardet~=5.1.0
docx2txt==0.8 docx2txt==0.8
pypdfium2==4.16.0 pypdfium2==4.16.0
pyjwt~=2.6.0
resend~=0.5.1
pyjwt~=2.6.0

+ 91
- 11
api/services/account_service.py Ver fichero

import base64 import base64
import logging import logging
import secrets import secrets
import uuid
from datetime import datetime from datetime import datetime
from hashlib import sha256
from typing import Optional from typing import Optional


from flask import session from flask import session
from sqlalchemy import func from sqlalchemy import func


from events.tenant_event import tenant_was_created from events.tenant_event import tenant_was_created
from extensions.ext_redis import redis_client
from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \ from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \
TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \ TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \
RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError
from libs.password import compare_password, hash_password from libs.password import compare_password, hash_password
from libs.rsa import generate_key_pair from libs.rsa import generate_key_pair
from models.account import * from models.account import *
from tasks.mail_invite_member_task import send_invite_member_mail_task




class AccountService: class AccountService:
@staticmethod @staticmethod
def update_account_password(account, password, new_password): def update_account_password(account, password, new_password):
"""update account password""" """update account password"""
# todo: split validation and update
if account.password and not compare_password(password, account.password, account.password_salt): if account.password and not compare_password(password, account.password, account.password_salt):
raise CurrentPasswordIncorrectError("Current password is incorrect.") 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() base64_password_hashed = base64.b64encode(password_hashed).decode()
account.password = base64_password_hashed account.password = base64_password_hashed
account.password_salt = base64_salt
db.session.commit() db.session.commit()
return account return account


@staticmethod @staticmethod
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account) -> None: def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account) -> None:
"""Remove member from tenant""" """Remove member from tenant"""
# todo: check permission

if operator.id == account.id and TenantService.check_member_permission(tenant, operator, account, 'remove'): if operator.id == account.id and TenantService.check_member_permission(tenant, operator, account, 'remove'):
raise CannotOperateSelfError("Cannot operate self.") raise CannotOperateSelfError("Cannot operate self.")


raise MemberNotInTenantError("Member not in tenant.") raise MemberNotInTenantError("Member not in tenant.")


db.session.delete(ta) db.session.delete(ta)

account.initialized_at = None
account.status = AccountStatus.PENDING.value
account.password = None
account.password_salt = None

db.session.commit() db.session.commit()


@staticmethod @staticmethod


class RegisterService: 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() db.session.begin_nested()
"""Register account""" """Register account"""
try: try:


return account 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""" """Invite new member"""
account = Account.query.filter_by(email=email).first() account = Account.query.filter_by(email=email).first()


if ta: if ta:
raise AccountAlreadyInTenantError("Account already in tenant.") 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 Ver fichero

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 Ver fichero

LOG_LEVEL: INFO 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`. # 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 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. # different from api or web app domain.
# example: http://cloud.dify.ai # 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 # The URL for Service API endpoints,refers to the base URL of the current API service if api domain is
# different from console domain. # different from console domain.
# example: http://api.dify.ai # 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. # console or api domain.
# example: http://udify.app # 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. # When enabled, migrations will be executed prior to application startup and the application will start after the migrations have completed.
MIGRATION_ENABLED: 'true' MIGRATION_ENABLED: 'true'
# The configurations of postgres database connection. # The configurations of postgres database connection.
QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/' QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/'
# The Qdrant API key. # The Qdrant API key.
QDRANT_API_KEY: 'ak-difyai' 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. # The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled.
SENTRY_DSN: '' SENTRY_DSN: ''
# The sample rate for Sentry events. Default: `1.0` # The sample rate for Sentry events. Default: `1.0`
VECTOR_STORE: weaviate VECTOR_STORE: weaviate
WEAVIATE_ENDPOINT: http://weaviate:8080 WEAVIATE_ENDPOINT: http://weaviate:8080
WEAVIATE_API_KEY: WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih 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: depends_on:
- db - db
- redis - redis
restart: always restart: always
environment: environment:
EDITION: SELF_HOSTED 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. # different from api or web app domain.
# example: http://cloud.dify.ai # 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. # console or api domain.
# example: http://udify.app # example: http://udify.app
APP_URL: ''
APP_API_URL: ''
# The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled. # The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled.
SENTRY_DSN: '' SENTRY_DSN: ''



+ 2
- 2
web/Dockerfile Ver fichero



ENV EDITION SELF_HOSTED ENV EDITION SELF_HOSTED
ENV DEPLOY_ENV PRODUCTION 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 EXPOSE 3000



+ 233
- 0
web/app/activate/activateForm.tsx Ver fichero

'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 Ver fichero

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 Ver fichero

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

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


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

{ value: 'en-US', name: 'EN' }, { value: 'en-US', name: 'EN' },
{ value: 'zh-Hans', name: '简体中文' }, { value: 'zh-Hans', name: '简体中文' },
] ]
interface ISelectProps {
type ISelectProps = {
items: Array<{ value: string; name: string }> items: Array<{ value: string; name: string }>
value?: string value?: string
className?: string className?: string
export default function Select({ export default function Select({
items, items,
value, value,
onChange
onChange,
}: ISelectProps) { }: ISelectProps) {
const item = items.filter(item => item.value === value)[0] const item = items.filter(item => item.value === value)[0]


<div className="w-56 text-right"> <div className="w-56 text-right">
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className="relative inline-block text-left">
<div> <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} {item?.name}
</Menu.Button> </Menu.Button>
</div> </div>
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" 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 "> <div className="px-1 py-1 ">
{items.map((item) => { {items.map((item) => {
return <Menu.Item key={item.value}> return <Menu.Item key={item.value}>
{({ active }) => ( {({ active }) => (
<button <button
className={`${active ? 'bg-gray-100' : '' 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) => { onClick={(evt) => {
evt.preventDefault() evt.preventDefault()
onChange && onChange(item.value) onChange && onChange(item.value)
export function InputSelect({ export function InputSelect({
items, items,
value, value,
onChange
onChange,
}: ISelectProps) { }: ISelectProps) {
const item = items.filter(item => item.value === value)[0] const item = items.filter(item => item.value === value)[0]
return ( return (
{({ active }) => ( {({ active }) => (
<button <button
className={`${active ? 'bg-gray-100' : '' 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={() => { onClick={() => {
onChange && onChange(item.value) onChange && onChange(item.value)
}} }}
</Menu> </Menu>
</div> </div>
) )
}
}

+ 1
- 1
web/app/components/header/account-about/index.module.css Ver fichero

.logo-icon { .logo-icon {
background: url(../assets/logo-icon.png) center center no-repeat; 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); 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 Ver fichero

<div> <div>
<div className={classNames( <div className={classNames(
s['logo-icon'], 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( <div className={classNames(
s['logo-text'], s['logo-text'],

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

text-sm font-normal text-gray-800 text-sm font-normal text-gray-800
` `


const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/

export default function AccountPage() { export default function AccountPage() {
const { t } = useTranslation()
const { mutateUserProfile, userProfile, apps } = useAppContext() const { mutateUserProfile, userProfile, apps } = useAppContext()
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false) const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false) 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 = () => { const handleEditName = () => {
setEditNameModalVisible(true) setEditNameModalVisible(true)
setEditing(false) 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) => { const renderAppItem = (item: IItem) => {
return ( return (
<div className='flex px-3 py-1'> <div className='flex px-3 py-1'>
<div className={titleClassName}>{t('common.account.email')}</div> <div className={titleClassName}>{t('common.account.email')}</div>
<div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div> <div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
</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 Ver fichero

'use client' 'use client'
import { useTranslation } from 'react-i18next' 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 { 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 { GlobeAltIcon as GlobalAltIconSolid, UserIcon as UserIconSolid, UsersIcon as UsersIconSolid } from '@heroicons/react/24/solid'
import AccountPage from './account-page' import AccountPage from './account-page'
w-4 h-4 ml-3 mr-2 w-4 h-4 ml-3 mr-2
` `


const scrolledClassName = `
border-b shadow-xs bg-white/[.98]
`

type IAccountSettingProps = { type IAccountSettingProps = {
onCancel: () => void onCancel: () => void
activeTab?: string activeTab?: string
], ],
}, },
] ]
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 ( return (
<Modal <Modal
} }
</div> </div>
</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} {[...menuItems[0].items, ...menuItems[1].items].find(item => item.key === activeMenu)?.name}
<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} /> <XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
</div> </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>
</div> </div>
</Modal> </Modal>

+ 4
- 1
web/app/components/header/account-setting/members-page/index.tsx Ver fichero

const { userProfile } = useAppContext() const { userProfile } = useAppContext()
const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers) const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers)
const [inviteModalVisible, setInviteModalVisible] = useState(false) const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationLink, setInvitationLink] = useState('')
const [invitedModalVisible, setInvitedModalVisible] = useState(false) const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || [] const accounts = data?.accounts || []
const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email
inviteModalVisible && ( inviteModalVisible && (
<InviteModal <InviteModal
onCancel={() => setInviteModalVisible(false)} onCancel={() => setInviteModalVisible(false)}
onSend={() => {
onSend={(url) => {
setInvitedModalVisible(true) setInvitedModalVisible(true)
setInvitationLink(url)
mutate() mutate()
}} }}
/> />
{ {
invitedModalVisible && ( invitedModalVisible && (
<InvitedModal <InvitedModal
invitationLink={invitationLink}
onCancel={() => setInvitedModalVisible(false)} onCancel={() => setInvitedModalVisible(false)}
/> />
) )

+ 12
- 12
web/app/components/header/account-setting/members-page/invite-modal/index.tsx Ver fichero

import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import s from './index.module.css'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import s from './index.module.css'
import { inviteMember } from '@/service/common' import { inviteMember } from '@/service/common'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'


interface IInviteModalProps {
onCancel: () => void,
onSend: () => void,
type IInviteModalProps = {
onCancel: () => void
onSend: (url: string) => void
} }
const InviteModal = ({ const InviteModal = ({
onCancel, onCancel,
const handleSend = async () => { const handleSend = async () => {
if (emailRegex.test(email)) { if (emailRegex.test(email)) {
try { 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') { if (res.result === 'success') {
onCancel() onCancel()
onSend()
onSend(res.invite_url)
} }
} catch (e) {
} }
} else {
catch (e) {}
}
else {
notify({ type: 'error', message: t('common.members.emailInvalid') }) notify({ type: 'error', message: t('common.members.emailInvalid') })
} }
} }
<div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div> <div className='mb-2 text-sm font-medium text-gray-900'>{t('common.members.email')}</div>
<input <input
className=' 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 appearance-none text-sm text-gray-900 rounded-lg
' '
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
placeholder={t('common.members.emailPlaceholder') || ''} placeholder={t('common.members.emailPlaceholder') || ''}
/> />
<Button
className='w-full text-sm font-medium'
<Button
className='w-full text-sm font-medium'
onClick={handleSend} onClick={handleSend}
type='primary' type='primary'
> >

+ 3
- 0
web/app/components/header/account-setting/members-page/invited-modal/assets/copied.svg Ver fichero

<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

+ 3
- 0
web/app/components/header/account-setting/members-page/invited-modal/assets/copy-hover.svg Ver fichero

<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#1D2939" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

+ 3
- 0
web/app/components/header/account-setting/members-page/invited-modal/assets/copy.svg Ver fichero

<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

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

padding: 32px !important; padding: 32px !important;
width: 480px !important; width: 480px !important;
background: linear-gradient(180deg, rgba(3, 152, 85, 0.05) 0%, rgba(3, 152, 85, 0) 22.44%), #F9FAFB !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 Ver fichero

import { CheckCircleIcon } from '@heroicons/react/24/solid' import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import InvitationLink from './invitation-link'
import s from './index.module.css'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button' 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 = ({ const InvitedModal = ({
onCancel
invitationLink,
onCancel,
}: IInvitedModalProps) => { }: IInvitedModalProps) => {
const { t } = useTranslation() const { t } = useTranslation()


<XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} /> <XMarkIcon className='w-4 h-4 cursor-pointer' onClick={onCancel} />
</div> </div>
<div className='mb-1 text-xl font-semibold text-gray-900'>{t('common.members.invitationSent')}</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'> <div className='flex justify-end'>
<Button
className='w-[96px] text-sm font-medium'
<Button
className='w-[96px] text-sm font-medium'
onClick={onCancel} onClick={onCancel}
type='primary' type='primary'
> >
) )
} }


export default InvitedModal
export default InvitedModal

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

'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 Ver fichero

'use client' 'use client'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Toast from '../components/base/toast' import Toast from '../components/base/toast'
import Button from '@/app/components/base/button'
import { setup } from '@/service/common' import { setup } from '@/service/common'


const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/ const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/
showErrorMessage(t('login.error.passwordEmpty')) showErrorMessage(t('login.error.passwordEmpty'))
return false return false
} }
if (!validPassword.test(password)) {
if (!validPassword.test(password))
showErrorMessage(t('login.error.passwordInvalid')) showErrorMessage(t('login.error.passwordInvalid'))
}
return true return true
} }
const handleSetting = async () => { const handleSetting = async () => {
if (!valid()) return
if (!valid())
return
await setup({ await setup({
body: { body: {
email, email,
name, name,
password
}
password,
},
}) })
router.push('/signin') router.push('/signin')
} }
return ( return (
<> <>
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <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=' <p className='
mt-2 text-sm text-gray-600
mt-1 text-sm text-gray-600
'>{t('login.setAdminAccountDesc')}</p> '>{t('login.setAdminAccountDesc')}</p>
</div> </div>


<div className="grow mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div className="grow mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white "> <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')} {t('login.email')}
</label> </label>
<div className="mt-1"> <div className="mt-1">
type="email" type="email"
value={email} value={email}
onChange={e => setEmail(e.target.value)} 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> </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')} {t('login.name')}
</label> </label>
<div className="mt-1 relative rounded-md shadow-sm"> <div className="mt-1 relative rounded-md shadow-sm">
type="text" type="text"
value={name} value={name}
onChange={e => setName(e.target.value)} 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> </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')} {t('login.password')}
</label> </label>
<div className="mt-1 relative rounded-md shadow-sm"> <div className="mt-1 relative rounded-md shadow-sm">
type='password' type='password'
value={password} value={password}
onChange={e => setPassword(e.target.value)} 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>
<div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div> <div className='mt-1 text-xs text-gray-500'>{t('login.error.passwordInvalid')}</div>
</div> </div>
</div> </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> <div>
<Button type='primary' onClick={handleSetting}>
<Button type='primary' className='w-full !fone-medium !text-sm' onClick={handleSetting}>
{t('login.installBtn')} {t('login.installBtn')}
</Button> </Button>
</div> </div>
</form> </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>
</div> </div>
</> </>

+ 1
- 7
web/app/signin/_header.tsx Ver fichero

'use client' 'use client'
import React from 'react' import React from 'react'
import { useContext } from 'use-context-selector'
import style from './page.module.css' import style from './page.module.css'
import Select, { LOCALES } from '@/app/components/base/select/locale' import Select, { LOCALES } from '@/app/components/base/select/locale'
import { type Locale } from '@/i18n' import { type Locale } from '@/i18n'
import I18n from '@/context/i18n' import I18n from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n/client'
import { useContext } from 'use-context-selector'


type IHeaderProps = {
locale: string
}


const Header = () => { const Header = () => {
const { locale, setLocaleOnClient } = useContext(I18n) const { locale, setLocaleOnClient } = useContext(I18n)

+ 2
- 3
web/app/signin/forms.tsx Ver fichero

import React from 'react' import React from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'


import cn from 'classnames'
import NormalForm from './normalForm' import NormalForm from './normalForm'
import OneMoreStep from './oneMoreStep' import OneMoreStep from './oneMoreStep'
import classNames from 'classnames'


const Forms = () => { const Forms = () => {
const searchParams = useSearchParams() const searchParams = useSearchParams()
} }
} }
return <div className={ return <div className={
classNames(
cn(
'flex flex-col items-center w-full grow items-center justify-center', 'flex flex-col items-center w-full grow items-center justify-center',
'px-6', 'px-6',
'md:px-[108px]', 'md:px-[108px]',
<div className='flex flex-col md:w-[400px]'> <div className='flex flex-col md:w-[400px]'>
{getForm()} {getForm()}
</div> </div>
</div> </div>
} }



+ 23
- 23
web/app/signin/normalForm.tsx Ver fichero

import React, { useEffect, useReducer, useState } from 'react' import React, { useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { IS_CE_EDITION } from '@/config'
import classNames from 'classnames' import classNames from 'classnames'
import useSWR from 'swr' import useSWR from 'swr'
import Link from 'next/link' import Link from 'next/link'
import Toast from '../components/base/toast'
import style from './page.module.css' import style from './page.module.css'
// import Tooltip from '@/app/components/base/tooltip/index' // 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 Button from '@/app/components/base/button'
import { login, oauth } from '@/service/common' import { login, oauth } from '@/service/common'
import { apiPrefix } from '@/config'


const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/ const validEmailReg = /^[\w\.-]+@([\w-]+\.)+[\w-]{2,}$/


remember_me: true, remember_me: true,
}, },
}) })
router.push('/')
} finally {
router.push('/apps')
}
finally {
setIsLoading(false) setIsLoading(false)
} }
} }
return ( return (
<> <>
<div className="w-full mx-auto"> <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>


<div className="w-full mx-auto mt-8"> <div className="w-full mx-auto mt-8">
<Button <Button
type='default' type='default'
disabled={isLoading} disabled={isLoading}
className='w-full'
className='w-full hover:!bg-gray-50 !text-sm !font-medium'
> >
<> <>
<span className={ <span className={
'w-5 h-5 mr-2', 'w-5 h-5 mr-2',
) )
} /> } />
<span className="truncate">{t('login.withGitHub')}</span>
<span className="truncate text-gray-800">{t('login.withGitHub')}</span>
</> </>
</Button> </Button>
</a> </a>
<Button <Button
type='default' type='default'
disabled={isLoading} disabled={isLoading}
className='w-full'
className='w-full hover:!bg-gray-50 !text-sm !font-medium'
> >
<> <>
<span className={ <span className={
'w-5 h-5 mr-2', 'w-5 h-5 mr-2',
) )
} /> } />
<span className="truncate">{t('login.withGoogle')}</span>
<span className="truncate text-gray-800">{t('login.withGoogle')}</span>
</> </>
</Button> </Button>
</a> </a>
</div> </div>
</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')} {t('login.email')}
</label> </label>
<div className="mt-1"> <div className="mt-1">
id="email" id="email"
type="email" type="email"
autoComplete="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> </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> <span>{t('login.password')}</span>
{/* <Tooltip {/* <Tooltip
selector='forget-password' selector='forget-password'
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
autoComplete="current-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"> <div className="absolute inset-y-0 right-0 flex items-center pr-3">
<button <button
</div> </div>
</div> </div>


<div>
<div className='mb-2'>
<Button <Button
type='primary' type='primary'
onClick={handleEmailPasswordLogin} onClick={handleEmailPasswordLogin}
disabled={isLoading} disabled={isLoading}
className="w-full !fone-medium !text-sm"
>{t('login.signBtn')}</Button> >{t('login.signBtn')}</Button>
</div> </div>
</form> </form>
</> </>
} }
{/* agree to our Terms and Privacy Policy. */} {/* 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')} {t('login.tosDesc')}
&nbsp; &nbsp;
<Link <Link

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

'use client' 'use client'
import React, { useEffect, useReducer } from 'react' import React, { useEffect, useReducer } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import useSWR from 'swr' import useSWR from 'swr'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
return ( return (
<> <>
<div className="w-full mx-auto"> <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>


<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')} {t('login.invitationCode')}
<Tooltip <Tooltip
clickable clickable
id="invitation_code" id="invitation_code"
value={state.invitation_code} value={state.invitation_code}
type="text" 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) => { onChange={(e) => {
dispatch({ type: 'invitation_code', value: e.target.value.trim() }) dispatch({ type: 'invitation_code', value: e.target.value.trim() })
}} }}
/> />
</div> </div>
</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')} {t('login.interfaceLanguage')}
</label> </label>
<div className="relative mt-1 rounded-md shadow-sm"> <div className="relative mt-1 rounded-md shadow-sm">
/> />
</div> </div>
</div> </div>
<div>

<div className='mb-4'>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700"> <label htmlFor="timezone" className="block text-sm font-medium text-gray-700">
{t('login.timezone')} {t('login.timezone')}
</label> </label>
<div> <div>
<Button <Button
type='primary' type='primary'
className='w-full !fone-medium !text-sm'
disabled={state.formState === 'processing'} disabled={state.formState === 'processing'}
onClick={() => { onClick={() => {
dispatch({ type: 'formState', value: 'processing' }) dispatch({ type: 'formState', value: 'processing' })
{t('login.go')} {t('login.go')}
</Button> </Button>
</div> </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> </div>
</> </>

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

import React from 'react' import React from 'react'
import cn from 'classnames'
import Forms from './forms' import Forms from './forms'
import Header from './_header' import Header from './_header'
import style from './page.module.css' import style from './page.module.css'
import classNames from 'classnames'


const SignIn = () => { const SignIn = () => {

return ( return (
<> <>
<div className={classNames(
<div className={cn(
style.background, style.background,
'flex w-full min-h-screen', 'flex w-full min-h-screen',
'sm:p-4 lg:p-8', 'sm:p-4 lg:p-8',
'gap-x-20', 'gap-x-20',
'justify-center lg:justify-start'
'justify-center lg:justify-start',
)}> )}>
<div className={ <div className={
classNames(
cn(
'flex w-full flex-col bg-white shadow rounded-2xl shrink-0', 'flex w-full flex-col bg-white shadow rounded-2xl shrink-0',
'space-between'
'space-between',
) )
}> }>
<Header /> <Header />

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

id: '', id: '',
name: '', name: '',
email: '', email: '',
avatar: '',
is_password_set: false,
}, },
mutateUserProfile: () => { }, mutateUserProfile: () => { },
pageContainerRef: createRef(), pageContainerRef: createRef(),

+ 13
- 2
web/docker/entrypoint.sh Ver fichero



export NEXT_PUBLIC_DEPLOY_ENV=${DEPLOY_ENV} export NEXT_PUBLIC_DEPLOY_ENV=${DEPLOY_ENV}
export NEXT_PUBLIC_EDITION=${EDITION} 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} export NEXT_PUBLIC_SENTRY_DSN=${SENTRY_DSN}


/usr/local/bin/pm2 -v /usr/local/bin/pm2 -v

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

edit: 'Edit', edit: 'Edit',
add: 'Add', add: 'Add',
refresh: 'Restart', refresh: 'Restart',
reset: 'Reset',
search: 'Search', search: 'Search',
change: 'Change', change: 'Change',
remove: 'Remove', remove: 'Remove',
avatar: 'Avatar', avatar: 'Avatar',
name: 'Name', name: 'Name',
email: 'Email', 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', langGeniusAccount: 'Dify account',
langGeniusAccountTip: 'Your Dify account and associated user data.', langGeniusAccountTip: 'Your Dify account and associated user data.',
editName: 'Edit Name', editName: 'Edit Name',
admin: 'Admin', admin: 'Admin',
adminTip: 'Can build apps & manage team settings', adminTip: 'Can build apps & manage team settings',
normal: 'Normal', normal: 'Normal',
normalTip: 'Only can use appscan not build apps',
normalTip: 'Only can use apps, can not build apps',
inviteTeamMember: 'Add team member', inviteTeamMember: 'Add team member',
inviteTeamMemberTip: 'They can access your team data directly after signing in.', inviteTeamMemberTip: 'They can access your team data directly after signing in.',
email: 'Email', email: 'Email',
emailInvalid: 'Invalid Email Format', emailInvalid: 'Invalid Email Format',
emailPlaceholder: 'Input Email', emailPlaceholder: 'Input Email',
sendInvite: 'Add', 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', ok: 'OK',
removeFromTeam: 'Remove from team', removeFromTeam: 'Remove from team',
removeFromTeamTip: 'Will remove team access', removeFromTeamTip: 'Will remove team access',

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

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

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

const translation = { 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 export default translation

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

const translation = { 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 export default translation

+ 2
- 0
web/models/common.ts Ver fichero

id: string id: string
name: string name: string
email: string email: string
avatar: string
is_password_set: boolean
interface_language?: string interface_language?: string
interface_theme?: string interface_theme?: string
timezone?: string timezone?: string

+ 10
- 2
web/service/common.ts Ver fichero

return get(url, { params }) as Promise<{ 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 }) => { export const updateMemberRole: Fetcher<CommonResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {
export const updateDataSourceNotionAction: Fetcher<CommonResponse, { url: string }> = ({ url }) => { export const updateDataSourceNotionAction: Fetcher<CommonResponse, { url: string }> = ({ url }) => {
return patch(url) as Promise<CommonResponse> 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>
}

Cargando…
Cancelar
Guardar