diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 6abad511fd..4b2259c3f3 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1244,6 +1244,9 @@ dependencies: '@types/node-fetch': specifier: ~2.6.2 version: 2.6.11 + '@types/otp-generator': + specifier: ^4.0.2 + version: 4.0.2 '@types/passport-github2': specifier: ^1.2.9 version: 1.2.9 @@ -1625,9 +1628,15 @@ dependencies: octokit: specifier: ^3.1.1 version: 3.2.1 + otp-generator: + specifier: ^4.0.1 + version: 4.0.1 p-queue: specifier: ~7.3.0 version: 7.3.4 + passport-custom: + specifier: ~1.1.1 + version: 1.1.1 passport-github2: specifier: ~0.1.12 version: 0.1.12 @@ -9204,6 +9213,10 @@ packages: resolution: {integrity: sha512-5PjwB0uP2XDp3nt5u5NJAG2DORHIRClPzWT/TTZhJ2Ekwe8M5bA9tvPdi9NO/n2uvu2/ictat8kgqvLfcIE1SA==} dev: false + /@types/otp-generator@4.0.2: + resolution: {integrity: sha512-9+qqWzuFb332hXPbLgjUyOXlbcaTQkmkmqQjTduvNuOmPV5fW+iLv70JsVEhdUy0DWi4kY34++HDCaWl6N0AYg==} + dev: false + /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: false @@ -18551,6 +18564,11 @@ packages: mem: 4.3.0 dev: false + /otp-generator@4.0.1: + resolution: {integrity: sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==} + engines: {node: '>=14.10.0'} + dev: false + /p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -18774,6 +18792,13 @@ packages: tslib: 2.6.2 dev: false + /passport-custom@1.1.1: + resolution: {integrity: sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==} + engines: {node: '>= 0.10.0'} + dependencies: + passport-strategy: 1.0.0 + dev: false + /passport-github2@0.1.12: resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==} engines: {node: '>= 0.8.0'} @@ -23597,13 +23622,14 @@ packages: dev: false file:projects/account.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.20.1)(ts-node@10.9.2)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-kGVjnMyyrgr9iznwGzYUmdCLfT1xilVVLSKnYO5Lw/+rIIDboDsql1L2V54L0f+8nPAPGdR8D2KjUYNfHAdEug==, tarball: file:projects/account.tgz} + resolution: {integrity: sha512-DvyfbaDA/mO99PJfqk/phC+LK9BcE0UvdVNgH9aritUgfPI9yCNePIy8FIXcosZKgOyFxi4XQLmfgkr2U8M6kw==, tarball: file:projects/account.tgz} id: file:projects/account.tgz name: '@rush-temp/account' version: 0.0.0 dependencies: '@types/jest': 29.5.12 '@types/node-fetch': 2.6.11 + '@types/otp-generator': 4.0.2 '@types/ws': 8.5.11 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) @@ -23615,6 +23641,7 @@ packages: jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) mongodb: 6.8.0 node-fetch: 2.7.0 + otp-generator: 4.0.1 prettier: 3.2.5 ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) typescript: 5.3.3 @@ -24023,7 +24050,7 @@ packages: dev: false file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-1rnMBtJmT15O/aYKqKngvn9iIxcbSOBJOUNyivTY89+Rmp5NvP/p8J+2IMJZbCvqdA0xRgWtAcgVlFD2dwCGww==, tarball: file:projects/auth-providers.tgz} + resolution: {integrity: sha512-ixfEzxmzLbrHghRymDcvdyz35q9DsDnA1qqheztJNa2zLH5102sfEMEooVLShU3cD+uf/KhNbcTyea1on8GlfA==, tarball: file:projects/auth-providers.tgz} id: file:projects/auth-providers.tgz name: '@rush-temp/auth-providers' version: 0.0.0 @@ -24050,6 +24077,7 @@ packages: koa-router: 12.0.1 koa-session: 6.4.0 mongodb: 6.8.0 + passport-custom: 1.1.1 passport-github2: 0.1.12 passport-google-oauth20: 2.0.0 prettier: 3.2.5 diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index e431404535..d8fb0b1c6d 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -48,7 +48,7 @@ services: ports: - 3000:3000 volumes: - - ./branding.json:/var/cfg/branding.json + - ./branding.json:/var/cfg/branding.json environment: - ACCOUNT_PORT=3000 - SERVER_SECRET=secret @@ -56,12 +56,12 @@ services: - TRANSACTOR_URL=ws://transactor:3333;ws://localhost:3333 - SES_URL= - STORAGE_CONFIG=${STORAGE_CONFIG} - - FRONT_URL=http://front:8080 + - FRONT_URL=http://localhost:8087 - RESERVED_DB_NAMES=telegram,gmail,github - MODEL_ENABLED=* - LAST_NAME_FIRST=true - ACCOUNTS_URL=http://localhost:3000 - - BRANDING_PATH=/var/cfg/branding.json + - BRANDING_PATH=/var/cfg/branding.json restart: unless-stopped collaborator: image: hardcoreeng/collaborator diff --git a/packages/platform/lang/en.json b/packages/platform/lang/en.json index b63848b406..5e01432a6e 100644 --- a/packages/platform/lang/en.json +++ b/packages/platform/lang/en.json @@ -18,6 +18,7 @@ "WorkspaceRateLimit": "Server is busy, Please wait a bit and try again", "AccountAlreadyConfirmed": "Account already confirmed", "WorkspaceAlreadyExists": "Workspace already exists", - "ProductIdMismatch": "Product Mismatch" + "ProductIdMismatch": "Product Mismatch", + "InvalidOtp": "Invalid code" } } diff --git a/packages/platform/lang/es.json b/packages/platform/lang/es.json index 6bba987588..04c6ec0b1d 100644 --- a/packages/platform/lang/es.json +++ b/packages/platform/lang/es.json @@ -18,6 +18,7 @@ "WorkspaceRateLimit": "El servidor está ocupado. Espere un momento e inténtelo de nuevo", "AccountAlreadyConfirmed": "La cuenta ya está confirmada", "WorkspaceAlreadyExists": "El espacio de trabajo ya existe", - "ProductIdMismatch": "Desajuste de producto" + "ProductIdMismatch": "Desajuste de producto", + "InvalidOtp": "Código no válido" } } \ No newline at end of file diff --git a/packages/platform/lang/fr.json b/packages/platform/lang/fr.json index 5febb6f283..09ae879b64 100644 --- a/packages/platform/lang/fr.json +++ b/packages/platform/lang/fr.json @@ -18,6 +18,7 @@ "WorkspaceRateLimit": "Le serveur est occupé, veuillez patienter un moment et réessayer", "AccountAlreadyConfirmed": "Compte déjà confirmé", "WorkspaceAlreadyExists": "L'espace de travail existe déjà", - "ProductIdMismatch": "Incompatibilité de produit" + "ProductIdMismatch": "Incompatibilité de produit", + "InvalidOtp": "Code invalide" } } \ No newline at end of file diff --git a/packages/platform/lang/pt.json b/packages/platform/lang/pt.json index 90ce433d4c..ece7718437 100644 --- a/packages/platform/lang/pt.json +++ b/packages/platform/lang/pt.json @@ -18,6 +18,7 @@ "WorkspaceRateLimit": "O servidor está ocupado. Por favor, espere um pouco e tente novamente", "AccountAlreadyConfirmed": "Conta já confirmada", "WorkspaceAlreadyExists": "Espaço de trabalho já existe", - "ProductIdMismatch": "Discrepância de produto" + "ProductIdMismatch": "Discrepância de produto", + "InvalidOtp": "Código inválido" } } \ No newline at end of file diff --git a/packages/platform/lang/ru.json b/packages/platform/lang/ru.json index c527949615..7b5759a159 100644 --- a/packages/platform/lang/ru.json +++ b/packages/platform/lang/ru.json @@ -18,6 +18,7 @@ "WorkspaceRateLimit": "Сервер перегружен, пожалуйста, подождите", "AccountAlreadyConfirmed": "Аккаунт уже подтвержден", "WorkspaceAlreadyExists": "Рабочее пространство уже существует", - "ProductIdMismatch": "Продукт не соответствует" + "ProductIdMismatch": "Продукт не соответствует", + "InvalidOtp": "Неверный код" } } diff --git a/packages/platform/lang/zh.json b/packages/platform/lang/zh.json index d5425c4e06..b153e21385 100644 --- a/packages/platform/lang/zh.json +++ b/packages/platform/lang/zh.json @@ -18,6 +18,7 @@ "WorkspaceRateLimit": "服务器繁忙,请稍后再试", "AccountAlreadyConfirmed": "账户已确认", "WorkspaceAlreadyExists": "工作区已存在", - "ProductIdMismatch": "产品不匹配" + "ProductIdMismatch": "产品不匹配", + "InvalidOtp": "无效的代码" } } diff --git a/packages/platform/src/platform.ts b/packages/platform/src/platform.ts index 3c152c55e7..eb9c784306 100644 --- a/packages/platform/src/platform.ts +++ b/packages/platform/src/platform.ts @@ -153,7 +153,8 @@ export default plugin(platformId, { AccountAlreadyConfirmed: '' as StatusCode<{ account: string }>, WorkspaceAlreadyExists: '' as StatusCode<{ workspace: string }>, WorkspaceRateLimit: '' as StatusCode<{ workspace: string }>, - ProductIdMismatch: '' as StatusCode<{ productId: string }> + ProductIdMismatch: '' as StatusCode<{ productId: string }>, + InvalidOtp: '' as StatusCode }, metadata: { locale: '' as Metadata, diff --git a/packages/ui/src/components/CodeInput.svelte b/packages/ui/src/components/CodeInput.svelte new file mode 100644 index 0000000000..ce53c077f8 --- /dev/null +++ b/packages/ui/src/components/CodeInput.svelte @@ -0,0 +1,80 @@ + + + + +
+ +
+ + diff --git a/packages/ui/src/components/TimeLeft.svelte b/packages/ui/src/components/TimeLeft.svelte new file mode 100644 index 0000000000..1ca3d3e14c --- /dev/null +++ b/packages/ui/src/components/TimeLeft.svelte @@ -0,0 +1,66 @@ + + + + +{#if displayTime > 0} + {getDisplayTime(displayTime)} +{/if} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4b2e7dff34..c14bf31e7d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -265,6 +265,8 @@ export { default as SimpleTimePopup } from './components/calendar/SimpleTimePopu export { default as NumberInput } from './components/NumberInput.svelte' export { default as Lazy } from './components/Lazy.svelte' export { default as TimeZonesPopup } from './components/TimeZonesPopup.svelte' +export { default as CodeInput } from './components/CodeInput.svelte' +export { default as TimeLeft } from './components/TimeLeft.svelte' export { default as Dock } from './components/Dock.svelte' diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index 7cca47286e..e627fc91d8 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -42,6 +42,20 @@ "ConfirmationSent": "A message has been sent to your email containing a link to confirm your address.", "ConfirmationSent2": "Please follow the link to complete your sign up.", "Slogan": "A unique place to manage all of your work\nWelcome to the Platform", - "ContinueWith": "Continue with {provider}" + "ContinueWith": "Continue with {provider}", + "HaventReceivedCode": "Haven't received the code?", + "ResendCode": "Resend code", + "WrongEmail": "Wrong email?", + "ChangeEmail": "Change email", + "SentTo": "We've sent a code to ", + "CanFindCode": "Can't find your code? Check your spam folder.", + "LoginWithPassword": "Login with password", + "LoginWithCode": "Login with code", + "FillInProfile": "Fill in your profile", + "SetUpPassword": "Set up your password", + "Next": "Next", + "Skip": "Skip", + "SignUpCompleted": "Sign up completed", + "StartUsingHuly": "Start using Huly" } } diff --git a/plugins/login-assets/lang/es.json b/plugins/login-assets/lang/es.json index d35c5df1de..aa22e6039d 100644 --- a/plugins/login-assets/lang/es.json +++ b/plugins/login-assets/lang/es.json @@ -42,6 +42,20 @@ "ConfirmationSent": "Se ha enviado un mensaje a su correo electrónico con un enlace para confirmar su dirección.", "ConfirmationSent2": "Por favor, siga el enlace para completar su registro.", "Slogan": "Un lugar único para gestionar todo tu trabajo\nBienvenido/a a la Plataforma", - "ContinueWith": "Continuar com {provider}" + "ContinueWith": "Continuar com {provider}", + "HaventReceivedCode": "¿No has recibido el código?", + "ResendCode": "Reenviar código", + "WrongEmail": "¿Correo electrónico incorrecto?", + "ChangeEmail": "Cambiar correo electrónico", + "SentTo": "Hemos enviado un código a ", + "CanFindCode": "¿No encuentras tu código? Revisa tu", + "LoginWithPassword": "Iniciar sesión con contraseña", + "LoginWithCode": "Iniciar sesión con código", + "FillInProfile": "Rellena tu perfil", + "SetUpPassword": "Establecer tu contraseña", + "Next": "Siguiente", + "Skip": "Saltar", + "SignUpCompleted": "Registro completado", + "StartUsingHuly": "Comienza a usar Huly" } } \ No newline at end of file diff --git a/plugins/login-assets/lang/fr.json b/plugins/login-assets/lang/fr.json index cc6f25cbd6..f9831f3053 100644 --- a/plugins/login-assets/lang/fr.json +++ b/plugins/login-assets/lang/fr.json @@ -42,6 +42,20 @@ "ConfirmationSent": "Un message a été envoyé à votre adresse e-mail contenant un lien pour confirmer votre adresse.", "ConfirmationSent2": "Veuillez suivre le lien pour compléter votre inscription.", "Slogan": "Un lieu unique pour gérer tout votre travail\nBienvenue sur la plateforme", - "ContinueWith": "Continuer avec {provider}" + "ContinueWith": "Continuer avec {provider}", + "HaventReceivedCode": "Vous n'avez pas reçu le code ?", + "ResendCode": "Renvoyer le code", + "WrongEmail": "Mauvaise adresse e-mail ?", + "ChangeEmail": "Changer l'adresse e-mail", + "SentTo": "Nous avons envoyé un code à ", + "CanFindCode": "Vous ne trouvez pas votre code ? Vérifiez votre", + "LoginWithPassword": "Connexion avec mot de passe", + "LoginWithCode": "Connexion avec code", + "FillInProfile": "Remplissez votre profil", + "SetUpPassword": "Définir votre mot de passe", + "Next": "Suivant", + "Skip": "Passer", + "SignUpCompleted": "Inscription terminée", + "StartUsingHuly": "Commencez à utiliser Huly" } } \ No newline at end of file diff --git a/plugins/login-assets/lang/pt.json b/plugins/login-assets/lang/pt.json index 3b3e4cfde2..244e29c3d1 100644 --- a/plugins/login-assets/lang/pt.json +++ b/plugins/login-assets/lang/pt.json @@ -42,6 +42,20 @@ "ConfirmationSent": "Foi enviada uma mensagem para o seu email contendo um link para confirmar o seu endereço.", "ConfirmationSent2": "Por favor, siga o link para concluir o seu registo.", "Slogan": "Um local único para gerir todo o seu trabalho\nBem-vindo à Plataforma", - "ContinueWith": "Continuar com {provider}" + "ContinueWith": "Continuar com {provider}", + "HaventReceivedCode": "Não recebeu o código?", + "ResendCode": "Reenviar código", + "WrongEmail": "Email errado?", + "ChangeEmail": "Alterar email", + "SentTo": "Enviamos um código para ", + "CanFindCode": "Não encontra o seu código? Verifique a sua pasta de spam.", + "LoginWithPassword": "Iniciar sessão com palavra-passe", + "LoginWithCode": "Iniciar sessão com código", + "FillInProfile": "Preencha o seu perfil", + "SetUpPassword": "Definir a sua palavra-passe", + "Next": "Seguinte", + "Skip": "Saltar", + "SignUpCompleted": "Registo concluído", + "StartUsingHuly": "Começar a usar Huly" } } \ No newline at end of file diff --git a/plugins/login-assets/lang/ru.json b/plugins/login-assets/lang/ru.json index 9e8a36c644..8112290770 100644 --- a/plugins/login-assets/lang/ru.json +++ b/plugins/login-assets/lang/ru.json @@ -42,6 +42,20 @@ "ConfirmationSent": "На Вашу почту отправлено сообщение, c ссылкой для подтверждения email.", "ConfirmationSent2": "Пожалуйста, перейдите по ссылке для завершения регистрации.", "Slogan": "Уникальное место для организации всей вашей работы\nДобро пожаловать в Платформу", - "ContinueWith": "Войти через {provider}" + "ContinueWith": "Войти через {provider}", + "HaventReceivedCode": "Не получили код?", + "ResendCode": "Отправить код повторно", + "WrongEmail": "Неверный email?", + "ChangeEmail": "Изменить email", + "SentTo": "Мы отправили код на ", + "CanFindCode": "Не нашли код? Проверьте папку со спамом.", + "LoginWithPassword": "Войти с паролем", + "LoginWithCode": "Войти с кодом", + "FillInProfile": "Заполните профиль", + "SetUpPassword": "Установите пароль", + "Next": "Дальше", + "Skip": "Пропустить", + "SignUpCompleted": "Регистрация завершена", + "StartUsingHuly": "Начать использовать Huly" } } diff --git a/plugins/login-assets/lang/zh.json b/plugins/login-assets/lang/zh.json index 750816f485..9c7a2ca8f0 100644 --- a/plugins/login-assets/lang/zh.json +++ b/plugins/login-assets/lang/zh.json @@ -42,6 +42,20 @@ "ConfirmationSent": "已发送确认邮件到您的邮箱,包含一个确认地址的链接。", "ConfirmationSent2": "请点击链接完成注册。", "Slogan": "一个独特的地方来管理你所有的工作\n欢迎来到平台", - "ContinueWith": "继续使用 {provider}" + "ContinueWith": "继续使用 {provider}", + "HaventReceivedCode": "没有收到验证码?", + "ResendCode": "重新发送验证码", + "WrongEmail": "错误的电子邮件?", + "ChangeEmail": "更改电子邮件", + "SentTo": "我们已发送验证码到 ", + "CanFindCode": "找不到验证码?请检查您的垃圾邮件文件夹。", + "LoginWithPassword": "使用密码登录", + "LoginWithCode": "使用代码登录", + "FillInProfile": "填写您的个人资料", + "SetUpPassword": "设置您的密码", + "Next": "下一个", + "Skip": "跳过", + "SignUpCompleted": "注册完成", + "StartUsingHuly": "开始使用 Huly" } } diff --git a/plugins/login-resources/src/components/Auth.svelte b/plugins/login-resources/src/components/Auth.svelte index 8755bf9e83..56b4fbf7a0 100644 --- a/plugins/login-resources/src/components/Auth.svelte +++ b/plugins/login-resources/src/components/Auth.svelte @@ -5,7 +5,7 @@ import { Loading, setMetadataLocalStorage } from '@hcengineering/ui' import { onMount } from 'svelte' import login from '../plugin' - import { afterConfirm, getSessionLoginInfo, navigateToWorkspace } from '../utils' + import { afterConfirm, getSessionLoginInfo, goTo, navigateToWorkspace } from '../utils' onMount(async () => { const result = await getSessionLoginInfo() @@ -18,7 +18,9 @@ setMetadataLocalStorage(login.metadata.LastToken, result.token) setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint) setMetadataLocalStorage(login.metadata.LoginEmail, result.email) - await afterConfirm() + await afterConfirm('onboard') + } else { + goTo('login') } }) diff --git a/plugins/login-resources/src/components/BottomAction.svelte b/plugins/login-resources/src/components/BottomAction.svelte new file mode 100644 index 0000000000..1d8d5e02c1 --- /dev/null +++ b/plugins/login-resources/src/components/BottomAction.svelte @@ -0,0 +1,45 @@ + + + + +
+ {#if action.caption} + + {/if} + {#if action.page} + + {:else} + + {/if} +
+ + diff --git a/plugins/login-resources/src/components/CreateWorkspaceForm.svelte b/plugins/login-resources/src/components/CreateWorkspaceForm.svelte index 305ba8ce66..0b1d99e773 100644 --- a/plugins/login-resources/src/components/CreateWorkspaceForm.svelte +++ b/plugins/login-resources/src/components/CreateWorkspaceForm.svelte @@ -14,14 +14,13 @@ // limitations under the License. --> @@ -150,28 +138,7 @@ }} > {#if loginState !== 'none'} -
- { - if (loginState !== 'signup') goTab('signup') - }} - > - - { - if (loginState !== 'login') goTab('login') - }} - > - -
+ {:else} {#if subtitle !== undefined}
@@ -234,14 +201,7 @@ {#if bottomActions.length} {/if} @@ -258,28 +218,6 @@ font-size: 1.25rem; color: var(--theme-caption-color); } - .caption a { - padding-bottom: 0.375rem; - border-bottom: 2px solid var(--theme-caption-color); - - &:not(.selected) { - color: var(--theme-dark-color); - border-bottom-color: transparent; - - &:hover { - color: var(--theme-caption-color); - } - } - &.selected { - cursor: default; - } - &:first-child { - margin-right: 1.75rem; - } - &:hover { - text-decoration: none; - } - } .status { padding-top: 1rem; grid-column-start: 1; diff --git a/plugins/login-resources/src/components/Join.svelte b/plugins/login-resources/src/components/Join.svelte index 902325ee63..d97080cd1a 100644 --- a/plugins/login-resources/src/components/Join.svelte +++ b/plugins/login-resources/src/components/Join.svelte @@ -13,20 +13,13 @@ // limitations under the License. --> -
+{#if method === LoginMethods.Otp} + +
+ +
+{:else} + +
+ +
+{/if} + + diff --git a/plugins/login-resources/src/components/LoginOtpForm.svelte b/plugins/login-resources/src/components/LoginOtpForm.svelte new file mode 100644 index 0000000000..1782cc1d76 --- /dev/null +++ b/plugins/login-resources/src/components/LoginOtpForm.svelte @@ -0,0 +1,67 @@ + + + +{#if step === OtpLoginSteps.Email} + +{/if} + +{#if step === OtpLoginSteps.Otp && formData.username !== ''} + +{/if} diff --git a/plugins/login-resources/src/components/LoginPasswordForm.svelte b/plugins/login-resources/src/components/LoginPasswordForm.svelte new file mode 100644 index 0000000000..d5ac9c37a5 --- /dev/null +++ b/plugins/login-resources/src/components/LoginPasswordForm.svelte @@ -0,0 +1,68 @@ + + + + diff --git a/plugins/login-resources/src/components/OnboardForm.svelte b/plugins/login-resources/src/components/OnboardForm.svelte new file mode 100644 index 0000000000..333d14a7f7 --- /dev/null +++ b/plugins/login-resources/src/components/OnboardForm.svelte @@ -0,0 +1,91 @@ + + + +{#if account} + {#if step === OnboardSteps.Workspace} + + {:else if step === OnboardSteps.User} + + {:else if step === OnboardSteps.Finish} + + {/if} +{/if} diff --git a/plugins/login-resources/src/components/OnboardUserForm.svelte b/plugins/login-resources/src/components/OnboardUserForm.svelte new file mode 100644 index 0000000000..6627b788fe --- /dev/null +++ b/plugins/login-resources/src/components/OnboardUserForm.svelte @@ -0,0 +1,67 @@ + + + + { + dispatch('step') + } + } + ]} +/> diff --git a/plugins/login-resources/src/components/OnboardWorkspaceForm.svelte b/plugins/login-resources/src/components/OnboardWorkspaceForm.svelte new file mode 100644 index 0000000000..6429ad3f9b --- /dev/null +++ b/plugins/login-resources/src/components/OnboardWorkspaceForm.svelte @@ -0,0 +1,52 @@ + + + + diff --git a/plugins/login-resources/src/components/OtpForm.svelte b/plugins/login-resources/src/components/OtpForm.svelte new file mode 100644 index 0000000000..a5890f766a --- /dev/null +++ b/plugins/login-resources/src/components/OtpForm.svelte @@ -0,0 +1,326 @@ + + + + + 720 ? '42rem' : '0'} +> +
+ +
+
+
+ +
+ {#each fields as field, index (field.name)} + {#if index === 3} +
+ {/if} +
+ { + trim(field.name) + }} + /> +
+ {/each} +
+ + + +
+ +
+ + + diff --git a/plugins/login-resources/src/components/Tabs.svelte b/plugins/login-resources/src/components/Tabs.svelte new file mode 100644 index 0000000000..0b90eb28d9 --- /dev/null +++ b/plugins/login-resources/src/components/Tabs.svelte @@ -0,0 +1,82 @@ + + + + + + + diff --git a/plugins/login-resources/src/index.ts b/plugins/login-resources/src/index.ts index 608a0a03a8..01df18ea8f 100644 --- a/plugins/login-resources/src/index.ts +++ b/plugins/login-resources/src/index.ts @@ -61,16 +61,33 @@ export const pages = [ 'join', 'confirm', 'confirmationSend', - 'auth' + 'auth', + 'login-password', + 'onboard' ] as const +export enum OnboardSteps { + Workspace = 'workspace', + User = 'user', + Finish = 'finish' +} + +export enum OtpLoginSteps { + Email = 'email', + Otp = 'otp' +} + +export enum LoginMethods { + Password = 'password', + Otp = 'otp' +} export type Pages = (typeof pages)[number] export interface BottomAction { i18n: IntlString page?: Pages func: () => void - caption: IntlString + caption?: IntlString } export * from './utils' diff --git a/plugins/login-resources/src/plugin.ts b/plugins/login-resources/src/plugin.ts index ce3cb0265c..ded18a5d42 100644 --- a/plugins/login-resources/src/plugin.ts +++ b/plugins/login-resources/src/plugin.ts @@ -59,6 +59,20 @@ export default mergeIds(loginId, login, { ConfirmationSent: '' as IntlString, ConfirmationSent2: '' as IntlString, Slogan: '' as IntlString, - ContinueWith: '' as IntlString + ContinueWith: '' as IntlString, + HaventReceivedCode: '' as IntlString, + ResendCode: '' as IntlString, + WrongEmail: '' as IntlString, + ChangeEmail: '' as IntlString, + SentTo: '' as IntlString, + CanFindCode: '' as IntlString, + LoginWithCode: '' as IntlString, + LoginWithPassword: '' as IntlString, + FillInProfile: '' as IntlString, + SetUpPassword: '' as IntlString, + Next: '' as IntlString, + Skip: '' as IntlString, + SignUpCompleted: '' as IntlString, + StartUsingHuly: '' as IntlString } }) diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index 584016f7c5..07e71ffe8b 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -14,8 +14,8 @@ // import { Analytics } from '@hcengineering/analytics' -import { AccountRole, concatLink, type Doc, type Ref } from '@hcengineering/core' -import login, { loginId, type LoginInfo, type Workspace, type WorkspaceLoginInfo } from '@hcengineering/login' +import { AccountRole, type Doc, type Ref, concatLink } from '@hcengineering/core' +import { loginId, type LoginInfo, type OtpInfo, type Workspace, type WorkspaceLoginInfo } from '@hcengineering/login' import { OK, PlatformError, @@ -24,7 +24,8 @@ import { translate, unknownError, unknownStatus, - type Status + Status, + Severity } from '@hcengineering/platform' import presentation from '@hcengineering/presentation' import { @@ -37,6 +38,8 @@ import { type Location } from '@hcengineering/ui' import { workbenchId } from '@hcengineering/workbench' + +import login from './plugin' import { type Pages } from './index' /** @@ -117,6 +120,40 @@ export async function signUp ( } } +export async function signUpOtp (email: string): Promise<[Status, OtpInfo | undefined]> { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + + if (accountsUrl === undefined) { + throw new Error('accounts url not specified') + } + + const request = { + method: 'signUpOtp', + params: [email] + } + + try { + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }) + const result = await response.json() + if (result.error == null) { + Analytics.handleEvent('signUpOtp') + Analytics.setUser(email) + } else { + await handleStatusError('Sign up error', result.error) + } + return [result.error ?? OK, result.result] + } catch (err: any) { + Analytics.handleError(err) + return [unknownError(err), undefined] + } +} + export async function createWorkspace ( workspaceName: string ): Promise<[Status, (LoginInfo & { workspace: string }) | undefined]> { @@ -376,6 +413,9 @@ export function setLoginInfo (loginInfo: WorkspaceLoginInfo): void { const tokens: Record = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {} tokens[loginInfo.workspace] = loginInfo.token + setMetadata(presentation.metadata.Token, loginInfo.token) + setMetadataLocalStorage(login.metadata.LastToken, loginInfo.token) + setMetadataLocalStorage(login.metadata.LoginTokens, tokens) setMetadataLocalStorage(login.metadata.LoginEndpoint, loginInfo.endpoint) setMetadataLocalStorage(login.metadata.LoginEmail, loginInfo.email) @@ -627,6 +667,36 @@ export async function changePassword (oldPassword: string, password: string): Pr } } +export async function changeUsername (first: string, last: string): Promise { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + + if (accountsUrl === undefined) { + throw new Error('accounts url not specified') + } + + const token = getMetadata(presentation.metadata.Token) as string + + const request = { + method: 'changeUsername', + params: [first, last] + } + + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }) + const resp = await response.json() + if (resp.error !== undefined) { + const err = new PlatformError(resp.error) + Analytics.handleError(err) + throw err + } +} + export async function leaveWorkspace (email: string): Promise { const accountsUrl = getMetadata(login.metadata.AccountsUrl) @@ -802,20 +872,16 @@ export function getHref (path: Pages): string { return host + url } -export async function afterConfirm (): Promise { +export async function afterConfirm (wspage: 'onboard' | 'createWorkspace' = 'createWorkspace'): Promise { const joinedWS = await getWorkspaces() if (joinedWS.length === 0) { - goTo('createWorkspace') + goTo(wspage) } else if (joinedWS.length === 1) { const result = (await selectWorkspace(joinedWS[0].workspace, null))[1] if (result !== undefined) { setMetadata(presentation.metadata.Token, result.token) setMetadataLocalStorage(login.metadata.LastToken, result.token) - setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint) - setMetadataLocalStorage(login.metadata.LoginEmail, result.email) - const tokens: Record = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {} - tokens[result.workspace] = result.token - setMetadataLocalStorage(login.metadata.LoginTokens, tokens) + setLoginInfo(result) navigateToWorkspace(joinedWS[0].workspace, result) } @@ -866,3 +932,124 @@ export async function getProviders (): Promise { } return [] } + +export async function sendOtp (email: string): Promise<[Status, OtpInfo | undefined]> { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + + if (accountsUrl === undefined) { + throw new Error('accounts url not specified') + } + + const request = { + method: 'sendOtp', + params: [email] + } + + try { + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }) + const result = await response.json() + + if (result.error == null) { + Analytics.handleEvent('sendOtp') + Analytics.setUser(email) + } else { + await handleStatusError('Send otp error', result.error) + } + return [result.error ?? OK, result.result] + } catch (err: any) { + console.error('Send otp error', err) + Analytics.handleError(err) + return [unknownError(err), undefined] + } +} + +export async function loginWithOtp (email: string, otp: string): Promise<[Status, LoginInfo | undefined]> { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + + if (accountsUrl === undefined) { + throw new Error('accounts url not specified') + } + + const request = { + method: 'validateOtp', + params: [email, otp] + } + + try { + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }) + const result = await response.json() + + if (result.error == null) { + Analytics.handleEvent('loginWithOtp') + Analytics.setUser(email) + } else { + await handleStatusError('Login with otp error', result.error) + } + return [result.error ?? OK, result.result] + } catch (err: any) { + console.error('Login with otp error', err) + Analytics.handleError(err) + return [unknownError(err), undefined] + } +} + +export async function doLoginNavigate ( + result: LoginInfo | undefined, + updateStatus: (status: Status) => void, + navigateUrl?: string +): Promise { + if (result !== undefined) { + setMetadata(presentation.metadata.Token, result.token) + setMetadataLocalStorage(login.metadata.LastToken, result.token) + setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint) + setMetadataLocalStorage(login.metadata.LoginEmail, result.email) + + if (navigateUrl !== undefined) { + try { + const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location + const workspace = loc.path[1] + if (workspace !== undefined) { + const workspaces = await getWorkspaces() + if (workspaces.find((p) => p.workspace === workspace) !== undefined) { + updateStatus(new Status(Severity.INFO, login.status.ConnectingToServer, {})) + + const [loginStatus, result] = await selectWorkspace(workspace, undefined) + updateStatus(loginStatus) + navigateToWorkspace(workspace, result, navigateUrl) + return + } + } + } catch (err: any) { + // Json parse error could be ignored + } + } + const loc = getCurrentLocation() + loc.path[1] = result.confirmed ? 'selectWorkspace' : 'confirmationSend' + loc.path.length = 2 + if (navigateUrl !== undefined) { + loc.query = { ...loc.query, navigateUrl } + } + navigate(loc) + } +} + +export async function ensureConfirmed (account: LoginInfo): Promise { + if (!account.confirmed) { + const loc = getCurrentLocation() + loc.path[1] = 'confirmationSend' + loc.path.length = 2 + navigate(loc) + } +} diff --git a/plugins/login/src/index.ts b/plugins/login/src/index.ts index ebcda0d571..c30a30b73c 100644 --- a/plugins/login/src/index.ts +++ b/plugins/login/src/index.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import { AccountRole, Doc, Ref } from '@hcengineering/core' +import { AccountRole, Doc, Ref, Timestamp } from '@hcengineering/core' import type { Asset, IntlString, Metadata, Plugin, Resource, Status } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import type { AnyComponent } from '@hcengineering/ui' @@ -56,6 +56,11 @@ export interface LoginInfo { email: string } +export interface OtpInfo { + sent: boolean + retryOn: Timestamp +} + export default plugin(loginId, { metadata: { AccountsUrl: '' as Asset, diff --git a/pods/authProviders/package.json b/pods/authProviders/package.json index ea9f406311..beca9b14b9 100644 --- a/pods/authProviders/package.json +++ b/pods/authProviders/package.json @@ -48,6 +48,7 @@ "mongodb": "^6.8.0", "@hcengineering/core": "^0.6.32", "@hcengineering/account": "^0.6.0", + "passport-custom": "~1.1.1", "passport-google-oauth20": "~2.0.0", "passport-github2": "~0.1.12", "koa-passport": "^6.0.0", diff --git a/pods/authProviders/src/index.ts b/pods/authProviders/src/index.ts index b83ee7fa99..ae35c64fb4 100644 --- a/pods/authProviders/src/index.ts +++ b/pods/authProviders/src/index.ts @@ -5,6 +5,7 @@ import session from 'koa-session' import { Db } from 'mongodb' import { registerGithub } from './github' import { registerGoogle } from './google' +import { registerToken } from './token' import { BrandingMap, MeasureContext } from '@hcengineering/core' export type Passport = typeof passport @@ -59,6 +60,8 @@ export function registerProviders ( }) }) + registerToken(ctx, passport, router, accountsUrl, db, productId, frontUrl, brandings) + const res: string[] = [] const providers: AuthProvider[] = [registerGoogle, registerGithub] for (const provider of providers) { diff --git a/pods/authProviders/src/token.ts b/pods/authProviders/src/token.ts new file mode 100644 index 0000000000..727844f7d1 --- /dev/null +++ b/pods/authProviders/src/token.ts @@ -0,0 +1,65 @@ +import { getAccountInfoByToken } from '@hcengineering/account' +import { BrandingMap, concatLink, MeasureContext } from '@hcengineering/core' +import Router from 'koa-router' +import { Db } from 'mongodb' +import { Strategy as CustomStrategy } from 'passport-custom' +import { Passport } from '.' +import { getBranding, getHost, safeParseAuthState } from './utils' + +export function registerToken ( + measureCtx: MeasureContext, + passport: Passport, + router: Router, + accountsUrl: string, + db: Db, + productId: string, + frontUrl: string, + brandings: BrandingMap +): string | undefined { + passport.use( + 'token', + new CustomStrategy(function (req: any, done: any) { + const token = req.body.token ?? req.query.token + + getAccountInfoByToken(measureCtx, db, productId, null, token) + .then((user: any) => done(null, user)) + .catch((err: any) => done(err)) + }) + ) + + router.get( + '/auth/token', + async (ctx, next) => { + measureCtx.info('try auth via', { provider: 'token' }) + const host = getHost(ctx.request.headers) + const branding = host !== undefined ? brandings[host]?.key ?? undefined : undefined + const state = encodeURIComponent( + JSON.stringify({ + inviteId: ctx.query?.inviteId, + branding + }) + ) + + await passport.authenticate('token', { session: true, state })(ctx, next) + }, + async (ctx, next) => { + measureCtx.info('Provider auth success', { type: 'token', user: ctx.state?.user }) + const user = ctx.state.user + if (user !== undefined) { + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + + if (ctx.session != null) { + ctx.session.loginInfo = user + } + + measureCtx.info('Success auth, redirect', { email: user.email, type: 'token' }) + // Successful authentication, redirect to your application + ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login/auth')) + } + await next() + } + ) + + return 'token' +} diff --git a/qms-tests/sanity/tests/login/registration.spec.ts b/qms-tests/sanity/tests/login/registration.spec.ts index 7091c1dc33..61ce18ecf8 100644 --- a/qms-tests/sanity/tests/login/registration.spec.ts +++ b/qms-tests/sanity/tests/login/registration.spec.ts @@ -82,6 +82,7 @@ test.describe('Registration tests', () => { await allure.tms('TESTS-396', 'https://front.hc.engineering/workbench/platform/tracker/TESTS-396') await test.step('1. Try to login with wrong password', async () => { const loginPage = new LoginPage(page) + await loginPage.loginWithPassword.click() await loginPage.login('user1', 'wrongPassword') await loginPage.checkIfUserIsLoggedIn('wrong-password') }) @@ -93,6 +94,7 @@ test.describe('Registration tests', () => { await allure.tms('TESTS-397', 'https://front.hc.engineering/workbench/platform/tracker/TESTS-397') await test.step('1. Try to login with wrong email', async () => { const loginPage = new LoginPage(page) + await loginPage.loginWithPassword.click() await loginPage.login('wrongEmail', '1234') await loginPage.checkIfUserIsLoggedIn('wrong-email') }) @@ -104,6 +106,7 @@ test.describe('Registration tests', () => { await allure.tms('TESTS-398', 'https://front.hc.engineering/workbench/platform/tracker/TESTS-397') await test.step('1. Try to login with working credentials', async () => { const loginPage = new LoginPage(page) + await loginPage.loginWithPassword.click() await loginPage.login('user1', '1234') await loginPage.checkIfUserIsLoggedIn('correct-credentials') }) diff --git a/qms-tests/sanity/tests/model/login-page.ts b/qms-tests/sanity/tests/model/login-page.ts index 53f299fbcf..44ff560641 100644 --- a/qms-tests/sanity/tests/model/login-page.ts +++ b/qms-tests/sanity/tests/model/login-page.ts @@ -7,6 +7,7 @@ export class LoginPage { readonly inputPassword: Locator readonly buttonLogin: Locator readonly buttonSignUp: Locator + readonly loginWithPassword: Locator constructor (page: Page) { this.page = page @@ -14,6 +15,7 @@ export class LoginPage { this.inputPassword = page.locator('input[name=current-password]') this.buttonLogin = page.locator('button', { hasText: 'Log In' }) this.buttonSignUp = page.locator('a.title', { hasText: 'Sign Up' }) + this.loginWithPassword = page.locator('a', { hasText: 'Login with password' }) } async goto (): Promise { diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index 5a6b229219..0f1468e09e 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -8,8 +8,9 @@ import account, { UpgradeWorker, accountId, cleanInProgressWorkspaces, - getAllTransactors, - getMethods + getMethods, + cleanExpiredOtp, + getAllTransactors } from '@hcengineering/account' import accountEn from '@hcengineering/account/lang/en.json' import accountRu from '@hcengineering/account/lang/ru.json' @@ -88,6 +89,8 @@ export function serveAccount ( setMetadata(account.metadata.Transactors, transactorUri) setMetadata(platform.metadata.locale, lang) setMetadata(account.metadata.ProductName, productName) + setMetadata(account.metadata.OtpTimeToLiveSec, parseInt(process.env.OTP_TIME_TO_LIVE ?? '60')) + setMetadata(account.metadata.OtpRetryDelaySec, parseInt(process.env.OTP_RETRY_DELAY ?? '60')) setMetadata(account.metadata.SES_URL, ses) setMetadata(account.metadata.FrontURL, frontURL) @@ -105,10 +108,17 @@ export function serveAccount ( let client: MongoClient | Promise = MongoClient.connect(dbUri) + let worker: UpgradeWorker | undefined + const app = new Koa() const router = new Router() - let worker: UpgradeWorker | undefined + app.use( + cors({ + credentials: true + }) + ) + app.use(bodyParser()) void client.then(async (p: MongoClient) => { const db = p.db(ACCOUNT_DB) @@ -117,6 +127,13 @@ export function serveAccount ( // We need to clean workspace with creating === true, since server is restarted. void cleanInProgressWorkspaces(db, productId) + setInterval( + () => { + void cleanExpiredOtp(db) + }, + 3 * 60 * 1000 + ) + const performUpgrade = (process.env.PERFORM_UPGRADE ?? 'true') === 'true' if (performUpgrade) { await measureCtx.with('upgrade-all-models', {}, async (ctx) => { @@ -241,12 +258,6 @@ export function serveAccount ( ctx.body = result }) - app.use( - cors({ - credentials: true - }) - ) - app.use(bodyParser()) app.use(router.routes()).use(router.allowedMethods()) const server = app.listen(ACCOUNT_PORT, () => { diff --git a/server/account/lang/en.json b/server/account/lang/en.json index 5fa6f847b1..ee8d655aa6 100644 --- a/server/account/lang/en.json +++ b/server/account/lang/en.json @@ -8,6 +8,9 @@ "RecoverySubject": "Password recovery", "InviteText": "You were invited to {ws}. To join, please paste the following link into your browser's URL bar: {link}. The link is valid for {expHours} hours.", "InviteHTML": "

You were invited to {ws}. To join, please click the link below: Join

If the invite link above does not work, paste the following link into your browser's URL bar: {link}

The link is valid for {expHours} hours.

", - "InviteSubject": "Invitation to {ws}" + "InviteSubject": "Invitation to {ws}", + "OtpText": "Confirm your email address to access {app}!\n\nYour confirmation code is below - enter it in the window where you've started signing in for {app}.\n\n{code}\n\nIf you didn’t request this email, there’s nothing to worry about — you can safely ignore it.", + "OtpHTML": "

Confirm your email address to access {app}!

Your confirmation code is below - enter it in the window where you've started signing in for {app}.


{code}

If you didn’t request this email, there’s nothing to worry about — you can safely ignore it.

", + "OtpSubject": "{app} confirmation code: {code}" } } diff --git a/server/account/lang/es.json b/server/account/lang/es.json index 62d9e310b9..ae44c223e0 100644 --- a/server/account/lang/es.json +++ b/server/account/lang/es.json @@ -8,6 +8,9 @@ "RecoverySubject": "Recuperación de Contraseña", "InviteText": "Te invitaron a {ws}. Para unirte, pega el siguiente enlace en la barra de URL de tu navegador web: {link}. Enlace válido por {expHours} horas.", "InviteHTML": "

Te invitaron a {ws}. Para unirte, haz clic en el enlace a continuación: Unirse

Si el enlace de invitación anterior no funciona, pega el siguiente enlace en la barra de URL de tu navegador web: {link}

Enlace válido por {expHours} horas.

", - "InviteSubject": "Invitación a {ws}" + "InviteSubject": "Invitación a {ws}", + "OtpText": "¡Confirma tu dirección de correo electrónico para acceder a {app}!\n\nTu código de confirmación está a continuación: ingrésalo en la ventana donde iniciaste sesión en {app}.\n\n{code}\n\nSi no solicitaste este correo electrónico, no hay de qué preocuparse; puedes ignorarlo con seguridad.", + "OtpHTML": "

¡Confirma tu dirección de correo electrónico para acceder a {app}!

Tu código de confirmación está a continuación: ingrésalo en la ventana donde iniciaste sesión en {app}.


{code}

Si no solicitaste este correo electrónico, no hay de qué preocuparse; puedes ignorarlo con seguridad.

", + "OtpSubject": "Código de confirmación de {app}: {code}" } } \ No newline at end of file diff --git a/server/account/lang/fr.json b/server/account/lang/fr.json index 3a492c7a55..768924413c 100644 --- a/server/account/lang/fr.json +++ b/server/account/lang/fr.json @@ -8,6 +8,9 @@ "RecoverySubject": "Réinitialisation du mot de passe", "InviteText": "Vous avez été invité à {ws}. Pour rejoindre, veuillez coller le lien suivant dans la barre d'adresse de votre navigateur web : {link}. Le lien est valable pendant {expHours} heures.", "InviteHTML": "

Vous avez été invité à {ws}. Pour rejoindre, veuillez cliquer sur le lien ci-dessous : Rejoindre

Si le lien d'invitation ci-dessus ne fonctionne pas, collez le lien suivant dans la barre d'adresse de votre navigateur web : {link}

Le lien est valable pendant {expHours} heures.

", - "InviteSubject": "Invitation à {ws}" + "InviteSubject": "Invitation à {ws}", + "OtpText": "Confirmez votre adresse e-mail pour accéder à {app}!\n\nVotre code de confirmation est ci-dessous - entrez-le dans la fenêtre où vous avez commencé à vous connecter pour {app}.\n\n{code}\n\nSi vous n'avez pas demandé cet e-mail, ne vous inquiétez pas - vous pouvez l'ignorer en toute sécurité.", + "OtpHTML": "

Confirmez votre adresse e-mail pour accéder à {app}!

Votre code de confirmation est ci-dessous - entrez-le dans la fenêtre où vous avez commencé à vous connecter pour {app}.


{code}

Si vous n'avez pas demandé cet e-mail, ne vous inquiétez pas - vous pouvez l'ignorer en toute sécurité.

", + "OtpSubject": "Code de confirmation {app} : {code}" } } \ No newline at end of file diff --git a/server/account/lang/pt.json b/server/account/lang/pt.json index 07c8b313ac..aa7f066f42 100644 --- a/server/account/lang/pt.json +++ b/server/account/lang/pt.json @@ -8,6 +8,9 @@ "RecoverySubject": "Recuperação de Senha", "InviteText": "Foi convidado para {ws}. Para se juntar, cole o seguinte link na barra de URL do seu navegador web: {link}. Link válido por {expHours} horas.", "InviteHTML": "

Foi convidado para {ws}. Para se juntar, clique no link abaixo: Juntar-se

Se o link de convite acima não funcionar, cole o seguinte link na barra de URL do seu navegador web: {link}

Link válido por {expHours} horas.

", - "InviteSubject": "Convite para {ws}" + "InviteSubject": "Convite para {ws}", + "OtpText": "Confirme o seu endereço de email para aceder a {app}!\n\nO seu código de confirmação está abaixo - introduza-o na janela onde começou a iniciar sessão para {app}.\n\n{code}\n\nSe não solicitou este email, não há motivo para preocupação - pode ignorá-lo com segurança.", + "OtpHTML": "

Confirme o seu endereço de email para aceder a {app}!

O seu código de confirmação está abaixo - introduza-o na janela onde começou a iniciar sessão para {app}.


{code}

Se não solicitou este email, não há motivo para preocupação - pode ignorá-lo com segurança.

", + "OtpSubject": "Código de confirmação {app}: {code}" } } \ No newline at end of file diff --git a/server/account/lang/ru.json b/server/account/lang/ru.json index e73fa43877..335393704d 100644 --- a/server/account/lang/ru.json +++ b/server/account/lang/ru.json @@ -8,6 +8,9 @@ "RecoverySubject": "Восстановление пароля", "InviteText": "Вы были приглашены в {ws}. Чтобы присоединиться, пожалуйста, скопируйте ссылку ниже в адресную строку вашего браузера: {link}. Ссылка действительна в течение {expHours} ч.", "InviteHTML": "

Вы были приглашены в {ws}. Чтобы присоединиться, пожалуйста, скопируйте ссылку ниже в адресную строку вашего браузера: Присоединиться

Если ссылка выше не работает, скопируйте следующую ссылку в адресную строку вашего браузера: {link}

Ссылка действительна в течение {expHours} ч.

", - "InviteSubjectRU": "Приглашение в {ws}" + "InviteSubjectRU": "Приглашение в {ws}", + "OtpText": "Подтвердите адрес электронной почты для доступа к {app}!\n\nВаш код подтверждения ниже - введите его в окне, где вы начали входить в {app}.\n\n{code}\n\nЕсли вы не запрашивали это письмо, не беспокойтесь - вы можете спокойно проигнорировать его.", + "OtpHTML": "

Подтвердите адрес электронной почты для доступа к {app}!

Ваш код подтверждения ниже - введите его в окне, где вы начали входить в {app}.


{code}

Если вы не запрашивали это письмо, не беспокойтесь - вы можете спокойно проигнорировать его.

", + "OtpSubject": "Код подтверждения {app}: {code}" } } \ No newline at end of file diff --git a/server/account/lang/zh.json b/server/account/lang/zh.json index ddc619792d..94df548e1b 100644 --- a/server/account/lang/zh.json +++ b/server/account/lang/zh.json @@ -8,6 +8,9 @@ "RecoverySubject": "密码恢复", "InviteText": "您被邀请加入 {ws}。要加入,请将以下链接粘贴到您的浏览器地址栏中:{link}。链接有效期为 {expHours} 小时。", "InviteHTML": "

您被邀请加入 {ws}。要加入,请点击以下链接:加入

如果上面的邀请链接不起作用,请将以下链接粘贴到您的浏览器地址栏中:{link}

链接有效期为 {expHours} 小时。

", - "InviteSubject": "邀请加入 {ws}" + "InviteSubject": "邀请加入 {ws}", + "OtpText": "确认您的电子邮件地址以访问 {app}!\n\n您的确认码如下 - 请在您开始登录 {app} 的窗口中输入。\n\n{code}\n\n如果您没有请求此邮件,不用担心 — 您可以放心地忽略它。", + "OtpHTML": "

确认您的电子邮件地址以访问 {app}!

您的确认码如下 - 请在您开始登录 {app} 的窗口中输入。


{code}

如果您没有请求此邮件,不用担心 — 您可以放心地忽略它。

", + "OtpSubject": "{app} 确认码:{code}" } } diff --git a/server/account/package.json b/server/account/package.json index 4d7dd3d117..f5ebc3a125 100644 --- a/server/account/package.json +++ b/server/account/package.json @@ -36,7 +36,8 @@ "@types/node-fetch": "~2.6.2", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "@types/jest": "^29.5.5" + "@types/jest": "^29.5.5", + "@types/otp-generator": "^4.0.2" }, "dependencies": { "mongodb": "^6.8.0", @@ -45,6 +46,7 @@ "@hcengineering/contact": "^0.6.24", "@hcengineering/client-resources": "^0.6.27", "@hcengineering/client": "^0.6.18", + "otp-generator": "^4.0.1", "ws": "^8.18.0", "@hcengineering/model": "^0.6.11", "@hcengineering/server-backup": "^0.6.0", diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 7dc9540e02..d883f64ae8 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -45,6 +45,7 @@ import core, { Version, versionToString, WorkspaceId, + Timestamp, WorkspaceIdWithUrl, type Branding } from '@hcengineering/core' @@ -62,6 +63,7 @@ import toolPlugin, { import { pbkdf2Sync, randomBytes } from 'crypto' import { Binary, Db, Filter, ObjectId, type MongoClient } from 'mongodb' import fetch from 'node-fetch' +import otpGenerator from 'otp-generator' import { DummyFullTextAdapter, Pipeline, @@ -76,10 +78,12 @@ import { registerServerPlugins, registerStringLoaders } from '@hcengineering/server-pipeline' + import { accountPlugin } from './plugin' const WORKSPACE_COLLECTION = 'workspace' const ACCOUNT_COLLECTION = 'account' +const OTP_COLLECTION = 'otp' const INVITE_COLLECTION = 'invite' /** * @public @@ -200,6 +204,18 @@ export interface Workspace extends BaseWorkspaceInfo { transactor?: string // Transactor group name } +export interface OtpRecord { + account: ObjectId + otp: string + expires: Timestamp + createdOn: Timestamp +} + +export interface OtpInfo { + sent: boolean + retryOn: Timestamp +} + /** * @public */ @@ -343,7 +359,36 @@ async function getAccountInfo ( return toAccountInfo(account) } -async function getAccountInfoByToken ( +async function sendOtpEmail (branding: Branding | null, otp: string, email: string): Promise { + const sesURL = getMetadata(accountPlugin.metadata.SES_URL) + if (sesURL === undefined || sesURL === '') { + console.info('Please provide email service url to enable email otp.') + return + } + + const lang = branding?.language + const app = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName) + + const text = await translate(accountPlugin.string.OtpText, { code: otp, app }, lang) + const html = await translate(accountPlugin.string.OtpHTML, { code: otp, app }, lang) + const subject = await translate(accountPlugin.string.OtpSubject, { code: otp, app }, lang) + + const to = email + await fetch(concatLink(sesURL, '/send'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text, + html, + subject, + to + }) + }) +} + +export async function getAccountInfoByToken ( ctx: MeasureContext, db: Db, productId: string, @@ -409,6 +454,117 @@ export async function login ( } } +async function getNewOtp (db: Db): Promise { + let otp = otpGenerator.generate(6, { + upperCaseAlphabets: false, + lowerCaseAlphabets: false, + specialChars: false + }) + + let exist = await db.collection(OTP_COLLECTION).findOne({ otp }) + + while (exist != null) { + otp = otpGenerator.generate(6, { + lowerCaseAlphabets: false + }) + exist = await db.collection(OTP_COLLECTION).findOne({ otp }) + } + + return otp +} + +export async function sendOtp ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + _email: string +): Promise { + const email = cleanEmail(_email) + const account = await getAccount(db, email) + + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + const now = Date.now() + const otpData = ( + await db + .collection(OTP_COLLECTION) + .find({ account: account._id }) + .sort({ createdOn: -1 }) + .limit(1) + .toArray() + )[0] + + const retryDelay = getMetadata(accountPlugin.metadata.OtpRetryDelaySec) ?? 30 + const isValid = otpData !== undefined && otpData.expires > now && otpData.createdOn + retryDelay * 1000 > now + + if (isValid) { + return { sent: true, retryOn: otpData.createdOn + retryDelay * 1000 } + } + const secs = getMetadata(accountPlugin.metadata.OtpTimeToLiveSec) ?? 60 + const timeToLive = secs * 1000 + const expires = now + timeToLive + const otp = await getNewOtp(db) + + await sendOtpEmail(branding, otp, email) + await db.collection(OTP_COLLECTION).insertOne({ account: account._id, otp, expires, createdOn: now }) + + return { sent: true, retryOn: now + retryDelay * 1000 } +} + +async function isOtpValid (db: Db, account: Account, otp: string): Promise { + const now = Date.now() + const otpData = (await db.collection(OTP_COLLECTION).findOne({ account: account._id, otp })) ?? undefined + + return otpData !== undefined && otpData.expires > now +} + +export async function validateOtp ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + _email: string, + otp: string +): Promise { + const email = cleanEmail(_email) + const account = await getAccount(db, email) + + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + const isValid = await isOtpValid(db, account, otp) + + if (!isValid) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {})) + } + + try { + const info = toAccountInfo(account) + + if (account.confirmed !== true) { + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { confirmed: true } }) + } + + const result = { + endpoint: '', + email, + confirmed: true, + token: generateToken(email, getWorkspaceId('', productId), getExtra(info)) + } + await db.collection(OTP_COLLECTION).deleteMany({ account: account._id }) + ctx.info('otp login success', { email, productId }) + return result + } catch (err: any) { + Analytics.handleError(err) + ctx.error('otp login failed', { email, productId, _email, err }) + throw err + } +} + /** * Will add extra props */ @@ -824,6 +980,25 @@ export async function createAccount ( return result } +/** + * @public + */ +export async function signUpOtp ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + _email: string +): Promise { + const email = cleanEmail(_email) + const first = email.split('@', 1)[0] ?? '' + const last = '' + + await createAcc(ctx, db, productId, branding, email, null, first, last, false) + + return await sendOtp(ctx, db, productId, branding, _email) +} + /** * @public */ @@ -894,6 +1069,10 @@ export async function cleanInProgressWorkspaces (db: Db, productId: string): Pro } } +export async function cleanExpiredOtp (db: Db): Promise { + await db.collection(OTP_COLLECTION).deleteMany({ expires: { $lte: Date.now() } }) +} + /** * @public */ @@ -2425,6 +2604,29 @@ export async function loginWithProvider ( } } +/** + * @public + */ +export async function changeUsername ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + token: string, + first: string, + last: string +): Promise { + const { email } = decodeToken(token) + const account = await getAccount(db, email) + + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) + } + + await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { first, last } }) + ctx.info('change-username success', { email }) +} + /** * @public */ @@ -2436,6 +2638,9 @@ export function getMethods ( return { login: wrap(login), join: wrap(join), + sendOtp: wrap(sendOtp), + validateOtp: wrap(validateOtp), + signUpOtp: wrap(signUpOtp), checkJoin: wrap(checkJoin), signUpJoin: wrap(signUpJoin), selectWorkspace: wrap(selectWorkspace), @@ -2455,7 +2660,8 @@ export function getMethods ( sendInvite: wrap(sendInvite), confirm: wrap(confirm), getAccountInfoByToken: wrap(getAccountInfoByToken), - createMissingEmployee: wrap(createMissingEmployee) + createMissingEmployee: wrap(createMissingEmployee), + changeUsername: wrap(changeUsername) // updateAccount: wrap(updateAccount) } } diff --git a/server/account/src/plugin.ts b/server/account/src/plugin.ts index 56c110535f..f00ee30afb 100644 --- a/server/account/src/plugin.ts +++ b/server/account/src/plugin.ts @@ -13,7 +13,9 @@ export const accountPlugin = plugin(accountId, { FrontURL: '' as Metadata, SES_URL: '' as Metadata, ProductName: '' as Metadata, - Transactors: '' as Metadata + Transactors: '' as Metadata, + OtpTimeToLiveSec: '' as Metadata, + OtpRetryDelaySec: '' as Metadata }, string: { ConfirmationText: '' as IntlString, @@ -24,6 +26,9 @@ export const accountPlugin = plugin(accountId, { RecoverySubject: '' as IntlString, InviteText: '' as IntlString, InviteHTML: '' as IntlString, - InviteSubject: '' as IntlString + InviteSubject: '' as IntlString, + OtpText: '' as IntlString, + OtpHTML: '' as IntlString, + OtpSubject: '' as IntlString } }) diff --git a/tests/sanity/tests/login.spec.ts b/tests/sanity/tests/login.spec.ts index 82e1ed1374..5805bc7d74 100644 --- a/tests/sanity/tests/login.spec.ts +++ b/tests/sanity/tests/login.spec.ts @@ -38,11 +38,14 @@ test.describe('login test', () => { test('check if user is able to go to to recovery, then login and then signup', async ({ page }) => { await checkIfUrlContains(page, '/login') await loginPage.checkIfLoginButtonIsDissaabled() + await loginPage.loginWithPassword().click() + await loginPage.checkIfLoginButtonIsDissaabled() await loginPage.clickOnRecover() await checkIfUrlContains(page, '/password') await loginPage.checkIfPasswordRecoveryIsVisible() await loginPage.clickOnRecoveryLogin() await checkIfUrlContains(page, '/login') + await loginPage.loginWithPassword().click() await loginPage.checkIfLoginButtonIsDissaabled() await loginPage.clickOnRecover() await loginPage.clickOnRecoverySignUp() diff --git a/tests/sanity/tests/model/login-page.ts b/tests/sanity/tests/model/login-page.ts index b76fb09591..17a5c7e819 100644 --- a/tests/sanity/tests/model/login-page.ts +++ b/tests/sanity/tests/model/login-page.ts @@ -11,6 +11,7 @@ export class LoginPage { inputEmail = (): Locator => this.page.locator('input[name=email]') inputPassword = (): Locator => this.page.locator('input[name=current-password]') buttonLogin = (): Locator => this.page.locator('button', { hasText: 'Log In' }) + loginWithPassword = (): Locator => this.page.locator('a', { hasText: 'Login with password' }) linkSignUp = (): Locator => this.page.locator('a.title', { hasText: 'Sign Up' }) invalidPasswordMessage = (): Locator => this.page.getByText('Invalid password') recoverLink = (): Locator => this.page.getByRole('link', { name: 'Recover' }) @@ -42,6 +43,7 @@ export class LoginPage { } async login (email: string, password: string): Promise { + await this.loginWithPassword().click() await this.inputEmail().fill(email) await this.inputPassword().fill(password) expect(await this.buttonLogin().isEnabled()).toBe(true)