Add login and signup by code (#6097)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2024-07-30 14:52:10 +07:00
committed by Andrey Sobolev
parent 59bb49c186
commit 8a45a3fdde
53 changed files with 1721 additions and 236 deletions
+30 -2
View File
@@ -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
+3 -3
View File
@@ -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
+2 -1
View File
@@ -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"
}
}
+2 -1
View File
@@ -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"
}
}
+2 -1
View File
@@ -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"
}
}
+2 -1
View File
@@ -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"
}
}
+2 -1
View File
@@ -18,6 +18,7 @@
"WorkspaceRateLimit": "Сервер перегружен, пожалуйста, подождите",
"AccountAlreadyConfirmed": "Аккаунт уже подтвержден",
"WorkspaceAlreadyExists": "Рабочее пространство уже существует",
"ProductIdMismatch": "Продукт не соответствует"
"ProductIdMismatch": "Продукт не соответствует",
"InvalidOtp": "Неверный код"
}
}
+2 -1
View File
@@ -18,6 +18,7 @@
"WorkspaceRateLimit": "服务器繁忙,请稍后再试",
"AccountAlreadyConfirmed": "账户已确认",
"WorkspaceAlreadyExists": "工作区已存在",
"ProductIdMismatch": "产品不匹配"
"ProductIdMismatch": "产品不匹配",
"InvalidOtp": "无效的代码"
}
}
+2 -1
View File
@@ -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<string>,
@@ -0,0 +1,80 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let value: string | undefined = undefined
export let id: string | undefined = undefined
export let name: string | undefined = undefined
export let size: 'small' | 'medium' = 'small'
</script>
<div class="container">
<input
class={size}
type="text"
{id}
{name}
bind:value
on:blur
on:change
on:keyup
on:input
maxlength={1}
inputmode="numeric"
autocomplete="off"
placeholder=""
spellcheck="false"
autocapitalize="on"
/>
</div>
<style lang="scss">
.container {
position: relative;
display: flex;
flex-direction: column;
padding: 0;
background-color: var(--theme-button-default);
border: 1px solid var(--theme-button-border);
caret-color: var(--theme-caret-color);
&:focus-within {
background-color: var(--theme-button-focused);
border-color: var(--theme-list-divider-color);
}
input {
text-align: center;
margin: 0;
padding: 0;
background-color: transparent;
border: none;
border-radius: 0.75rem;
&.small {
width: 1.5rem;
height: 2rem;
font-size: 1rem;
}
&.medium {
width: 3.125rem;
height: 4.375rem;
font-size: 2rem;
}
}
}
</style>
@@ -0,0 +1,66 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'
import { Timestamp } from '@hcengineering/core'
export let time: Timestamp
const dispatch = createEventDispatcher()
let displayTime = time
let notified = false
let intervalId: any | undefined = undefined
function applyTimer (time: number): void {
if (intervalId !== undefined) {
clearInterval(intervalId)
}
displayTime = Math.max(0, time - Date.now())
notified = false
intervalId = setInterval(() => {
displayTime = Math.max(0, time - Date.now())
if (displayTime === 0 && !notified) {
notified = true
dispatch('timeout')
}
}, 1000)
}
export function restart (time: number): void {
applyTimer(time)
}
$: applyTimer(time)
onDestroy(() => {
if (intervalId !== undefined) {
clearInterval(intervalId)
}
})
function getDisplayTime (time: number): string {
const options: Intl.DateTimeFormatOptions = { minute: 'numeric', second: 'numeric' }
return new Date(time).toLocaleString('default', options)
}
</script>
{#if displayTime > 0}
{getDisplayTime(displayTime)}
{/if}
+2
View File
@@ -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'
+15 -1
View File
@@ -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"
}
}
+15 -1
View File
@@ -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"
}
}
+15 -1
View File
@@ -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"
}
}
+15 -1
View File
@@ -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"
}
}
+15 -1
View File
@@ -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"
}
}
+15 -1
View File
@@ -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"
}
}
@@ -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')
}
})
@@ -0,0 +1,45 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Label } from '@hcengineering/ui'
import { NavLink } from '@hcengineering/presentation'
import { getHref } from '../utils'
import { BottomAction } from '../index'
export let action: BottomAction
</script>
<div>
{#if action.caption}
<span><Label label={action.caption} /></span>
{/if}
{#if action.page}
<NavLink href={getHref(action.page)}><Label label={action.i18n} /></NavLink>
{:else}
<a href="." on:click|preventDefault={action.func}><Label label={action.i18n} /></a>
{/if}
</div>
<style lang="scss">
span {
color: var(--theme-darker-color);
}
a {
font-weight: 400;
color: var(--theme-content-color);
}
</style>
@@ -14,14 +14,13 @@
// limitations under the License.
-->
<script lang="ts">
import { Status, Severity, OK, setMetadata } from '@hcengineering/platform'
import { Status, Severity, OK } from '@hcengineering/platform'
import Form from './Form.svelte'
import { createWorkspace, getAccount, goTo } from '../utils'
import { fetchMetadataLocalStorage, getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import { createWorkspace, getAccount, goTo, setLoginInfo } from '../utils'
import { getCurrentLocation, navigate } from '@hcengineering/ui'
import login from '../plugin'
import { workbenchId } from '@hcengineering/workbench'
import presentation from '@hcengineering/presentation'
import { onMount } from 'svelte'
import { LoginInfo } from '@hcengineering/login'
@@ -61,13 +60,8 @@
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setLoginInfo(result)
navigate({ path: [workbenchId, result.workspace] })
}
}
@@ -16,23 +16,16 @@
<script lang="ts">
import type { IntlString } from '@hcengineering/platform'
import { OK, Severity, Status, translate } from '@hcengineering/platform'
import {
Button,
Label,
StylishEdit,
deviceOptionsStore as deviceInfo,
getCurrentLocation,
navigate,
themeStore
} from '@hcengineering/ui'
import { Button, Label, StylishEdit, deviceOptionsStore as deviceInfo, themeStore } from '@hcengineering/ui'
import StatusControl from './StatusControl.svelte'
import { NavLink } from '@hcengineering/presentation'
import { onMount } from 'svelte'
import { BottomAction, getHref } from '..'
import { BottomAction } from '..'
import login from '../plugin'
import { makeSequential } from '../mutex'
import Providers from './Providers.svelte'
import Tabs from './Tabs.svelte'
import BottomActionComponent from './BottomAction.svelte'
interface Field {
id?: string
@@ -122,12 +115,7 @@
object[field] = (object[field] as string).trim()
}
const goTab = (path: string) => {
const loc = getCurrentLocation()
loc.path[1] = path
loc.path.length = 2
navigate(loc)
}
let loginState: 'login' | 'signup' | 'none' = 'none'
$: loginState = caption === login.string.LogIn ? 'login' : caption === login.string.SignUp ? 'signup' : 'none'
</script>
@@ -150,28 +138,7 @@
}}
>
{#if loginState !== 'none'}
<div class="flex-row-center caption">
<a
class="title"
class:selected={loginState === 'signup'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'signup') goTab('signup')
}}
>
<Label label={login.string.SignUp} />
</a>
<a
class="title"
class:selected={loginState === 'login'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'login') goTab('login')
}}
>
<Label label={login.string.LogIn} />
</a>
</div>
<Tabs {loginState} />
{:else}
{#if subtitle !== undefined}
<div class="fs-title">
@@ -234,14 +201,7 @@
{#if bottomActions.length}
<div class="footer">
{#each bottomActions as action}
<div>
<span><Label label={action.caption} /></span>
{#if action.page}
<NavLink href={getHref(action.page)}><Label label={action.i18n} /></NavLink>
{:else}
<a href="." on:click|preventDefault={action.func}><Label label={action.i18n} /></a>
{/if}
</div>
<BottomActionComponent {action} />
{/each}
</div>
{/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;
@@ -13,20 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, setMetadata, Severity, Status } from '@hcengineering/platform'
import {
fetchMetadataLocalStorage,
getCurrentLocation,
Location,
navigate,
setMetadataLocalStorage
} from '@hcengineering/ui'
import { OK, Severity, Status } from '@hcengineering/platform'
import { Location, getCurrentLocation, navigate } from '@hcengineering/ui'
import { checkJoined, join, signUpJoin } from '../utils'
import { checkJoined, join, setLoginInfo, signUpJoin } from '../utils'
import Form from './Form.svelte'
import { Analytics } from '@hcengineering/analytics'
import presentation from '@hcengineering/presentation'
import { workbenchId } from '@hcengineering/workbench'
import { onMount } from 'svelte'
import { BottomAction } from '..'
@@ -84,13 +77,7 @@
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setLoginInfo(result)
if (location.query?.navigateUrl != null) {
try {
@@ -137,13 +124,7 @@
const [, result] = await checkJoined(location.query.inviteId)
status = OK
if (result !== undefined) {
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setLoginInfo(result)
if (location.query?.navigateUrl != null) {
try {
@@ -51,6 +51,7 @@
import loginBackWebp from '../../img/login_back.webp'
import loginBack2xWebp from '../../img/login_back_2x.webp'
import login from '../plugin'
import OnboardForm from './OnboardForm.svelte'
export let page: Pages = 'signup'
@@ -80,6 +81,10 @@
}
async function chooseToken (): Promise<void> {
if (page === 'auth') {
// token handled by auth page
return
}
if (getMetadata(presentation.metadata.Token) == null) {
const lastToken = fetchMetadataLocalStorage(login.metadata.LastToken)
if (lastToken != null) {
@@ -133,6 +138,8 @@
<LoginForm {navigateUrl} />
{:else if page === 'signup'}
<SignupForm />
{:else if page === 'onboard'}
<OnboardForm />
{:else if page === 'createWorkspace'}
<CreateWorkspaceForm />
{:else if page === 'password'}
@@ -14,95 +14,49 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, setMetadata, Severity, Status } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { getCurrentLocation, Location, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import { doLogin, getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
import Form from './Form.svelte'
import { LoginInfo } from '@hcengineering/login'
import { recoveryAction } from '../actions'
import { type BottomAction, LoginMethods } from '../index'
import LoginPasswordForm from './LoginPasswordForm.svelte'
import LoginOtpForm from './LoginOtpForm.svelte'
import BottomActionComponent from './BottomAction.svelte'
import login from '../plugin'
export let navigateUrl: string | undefined = undefined
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email },
{
id: 'current-password',
name: 'password',
i18n: login.string.Password,
password: true
}
]
let method: LoginMethods = LoginMethods.Otp
const object = {
username: '',
password: ''
function changeMethod (event: CustomEvent<LoginMethods>): void {
method = event.detail
}
async function doLoginNavigate (
result: LoginInfo | undefined,
updateStatus: (status: Status<any>) => void
): Promise<void> {
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)
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)
const loginWithPasswordAction: BottomAction = {
i18n: login.string.LoginWithPassword,
func: () => {
method = LoginMethods.Password
}
}
let status = OK
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
await doLoginNavigate(result, (st) => {
status = st
})
const loginWithCodeAction: BottomAction = {
i18n: login.string.LoginWithCode,
func: () => {
method = LoginMethods.Otp
}
}
</script>
<Form
caption={login.string.LogIn}
{status}
{fields}
{object}
{action}
bottomActions={[recoveryAction]}
ignoreInitialValidation
withProviders
/>
{#if method === LoginMethods.Otp}
<LoginOtpForm {navigateUrl} on:change={changeMethod} />
<div class="action">
<BottomActionComponent action={loginWithPasswordAction} />
</div>
{:else}
<LoginPasswordForm {navigateUrl} on:change={changeMethod} />
<div class="action">
<BottomActionComponent action={loginWithCodeAction} />
</div>
{/if}
<style lang="scss">
.action {
margin-left: 5rem;
}
</style>
@@ -0,0 +1,67 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import OtpForm from './OtpForm.svelte'
import login from '../plugin'
import Form from './Form.svelte'
import { OtpLoginSteps, sendOtp } from '../index'
export let navigateUrl: string | undefined = undefined
const fields = [{ id: 'email', name: 'username', i18n: login.string.Email }]
const formData = {
username: '' as string
}
let status = OK
let step = OtpLoginSteps.Email
let otpRetryOn = 0
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [otpStatus, result] = await sendOtp(formData.username)
status = otpStatus
if (result !== undefined && result.sent && otpStatus === OK) {
step = OtpLoginSteps.Otp
otpRetryOn = result.retryOn
}
}
}
function handleStep (event: CustomEvent<OtpLoginSteps>): void {
step = event.detail
}
</script>
{#if step === OtpLoginSteps.Email}
<Form
caption={login.string.LogIn}
{status}
{fields}
object={formData}
{action}
ignoreInitialValidation
withProviders
/>
{/if}
{#if step === OtpLoginSteps.Otp && formData.username !== ''}
<OtpForm email={formData.username} {navigateUrl} retryOn={otpRetryOn} on:step={handleStep} />
{/if}
@@ -0,0 +1,68 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import { doLogin, doLoginNavigate } from '../utils'
import Form from './Form.svelte'
import { recoveryAction } from '../actions'
import login from '../plugin'
export let navigateUrl: string | undefined = undefined
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email },
{
id: 'current-password',
name: 'password',
i18n: login.string.Password,
password: true
}
]
const object = {
username: '',
password: ''
}
let status = OK
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
}
}
</script>
<Form
caption={login.string.LogIn}
{status}
{fields}
{object}
{action}
bottomActions={[recoveryAction]}
ignoreInitialValidation
withProviders
/>
@@ -0,0 +1,91 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { LoginInfo, WorkspaceLoginInfo } from '@hcengineering/login'
import { OK } from '@hcengineering/platform'
import { onMount } from 'svelte'
import { OnboardSteps } from '../index'
import login from '../plugin'
import { ensureConfirmed, getAccount, getWorkspaces, goTo, navigateToWorkspace } from '../utils'
import Form from './Form.svelte'
import OnboardUserForm from './OnboardUserForm.svelte'
import OnboardWorkspaceForm from './OnboardWorkspaceForm.svelte'
const steps = Object.values(OnboardSteps)
let account: LoginInfo | undefined = undefined
let step: OnboardSteps = OnboardSteps.Workspace
function handleStepEvent (event: CustomEvent<LoginInfo | WorkspaceLoginInfo | undefined>): void {
if (event.detail != null) {
account = event.detail
}
handleNext(step)
}
function handleNext (current: OnboardSteps): void {
const next = steps.indexOf(current) + 1
if (next < steps.length) {
step = steps[next]
}
}
function isWorkspaceLoginInfo (info: WorkspaceLoginInfo | LoginInfo): info is WorkspaceLoginInfo {
return (info as WorkspaceLoginInfo).workspace !== undefined
}
onMount(async () => {
account = await getAccount()
if (account != null) {
await ensureConfirmed(account)
} else {
goTo('login')
}
const workspaces = await getWorkspaces()
if (workspaces.length > 0) {
goTo('selectWorkspace')
}
})
const action = {
i18n: login.string.StartUsingHuly,
func: async () => {
if (account !== undefined && isWorkspaceLoginInfo(account)) {
navigateToWorkspace(account.workspace, account)
}
}
}
</script>
{#if account}
{#if step === OnboardSteps.Workspace}
<OnboardWorkspaceForm {account} on:step={handleStepEvent} />
{:else if step === OnboardSteps.User}
<OnboardUserForm {account} on:step={handleStepEvent} />
{:else if step === OnboardSteps.Finish}
<Form
status={OK}
caption={login.string.SignUpCompleted}
subtitle={account.email}
fields={[]}
object={{}}
{action}
/>
{/if}
{/if}
@@ -0,0 +1,67 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { LoginInfo } from '@hcengineering/login'
import { OK, Severity, Status, unknownError } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import login from '../plugin'
import { changeUsername } from '../utils'
import Form from './Form.svelte'
export let account: LoginInfo
const dispatch = createEventDispatcher()
const fields = [
{ id: 'given-name', name: 'first', i18n: login.string.FirstName, short: true },
{ id: 'family-name', name: 'last', i18n: login.string.LastName, short: true }
]
const object = { first: '', last: '' }
let status: Status<any> = OK
const action = {
i18n: login.string.Next,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
try {
await changeUsername(object.first, object.last)
status = OK
dispatch('step')
} catch (err) {
status = unknownError(err)
}
}
}
</script>
<Form
caption={login.string.FillInProfile}
subtitle={account.email}
{status}
{fields}
{object}
{action}
bottomActions={[
{
i18n: login.string.Skip,
func: () => {
dispatch('step')
}
}
]}
/>
@@ -0,0 +1,52 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { LoginInfo } from '@hcengineering/login'
import { Status, Severity, OK } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import Form from './Form.svelte'
import { createWorkspace, setLoginInfo } from '../utils'
import login from '../plugin'
export let account: LoginInfo
const dispatch = createEventDispatcher()
const fields = [{ id: 'workspace', name: 'workspace', i18n: login.string.Workspace }]
const object = {
workspace: ''
}
let status: Status<any> = OK
const action = {
i18n: login.string.CreateWorkspace,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await createWorkspace(object.workspace)
status = loginStatus
if (result !== undefined) {
setLoginInfo(result)
dispatch('step', result)
}
}
}
</script>
<Form caption={login.string.CreateWorkspace} subtitle={account.email} {status} {fields} {object} {action} />
@@ -0,0 +1,326 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { deviceOptionsStore as deviceInfo, Label, TimeLeft, CodeInput } from '@hcengineering/ui'
import { OK, Severity, Status } from '@hcengineering/platform'
import { createEventDispatcher, onDestroy } from 'svelte'
import { Timestamp } from '@hcengineering/core'
import Tabs from './Tabs.svelte'
import { BottomAction, doLoginNavigate, loginWithOtp, OtpLoginSteps, sendOtp } from '../index'
import login from '../plugin'
import BottomActionComponent from './BottomAction.svelte'
import StatusControl from './StatusControl.svelte'
export let navigateUrl: string | undefined = undefined
export let email: string
export let retryOn: Timestamp
const dispatch = createEventDispatcher()
const fields = [
{ id: 'otp1', name: 'otp1', optional: false },
{ id: 'otp2', name: 'otp2', optional: false },
{ id: 'otp3', name: 'otp3', optional: false },
{ id: 'otp4', name: 'otp4', optional: false },
{ id: 'otp5', name: 'otp5', optional: false },
{ id: 'otp6', name: 'otp6', optional: false }
]
let status = OK
const otpData: Record<string, string> = {
otp1: '',
otp2: '',
otp3: '',
otp4: '',
otp5: '',
otp6: ''
}
let formElement: HTMLFormElement | undefined
function trim (field: string): void {
otpData[field] = otpData[field].trim()
}
async function validateOtp (): Promise<void> {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const otp = otpData.otp1 + otpData.otp2 + otpData.otp3 + otpData.otp4 + otpData.otp5 + otpData.otp6
const [loginStatus, result] = await loginWithOtp(email, otp)
status = loginStatus
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
}
function onInput (e: Event): void {
if (e.target == null) return
const target = e.target as HTMLInputElement
const { value } = target
if (value == null || value === '') return
const index = fields.findIndex(({ id }) => id === target.id)
if (index === -1) return
if (Object.values(otpData).every((v) => v !== '')) {
void validateOtp()
return
}
const nextField = fields[index + 1]
if (nextField === undefined) return
const nextInput = formElement?.querySelector(`input[name="${nextField.name}"]`) as HTMLInputElement
if (nextInput != null) {
nextInput.focus()
}
}
function clearOtpData (): void {
Object.keys(otpData).forEach((key) => {
otpData[key] = ''
})
const input = formElement?.querySelector(`input[name="${fields[0].name}"]`) as HTMLInputElement
if (input != null) {
input.focus()
}
}
function onKeydown (e: KeyboardEvent): void {
const key = e.key.toLowerCase()
const target = e.target as HTMLInputElement
if (key !== 'backspace' && key !== 'delete') return
const index = fields.findIndex(({ id }) => id === target.id)
if (index === -1) return
const { value } = target
status = OK
target.value = ''
otpData[fields[index].name] = ''
if (value === '') {
const prevField = fields[index - 1]
if (prevField === undefined) return
const prevInput = formElement?.querySelector(`input[name="${prevField.name}"]`) as HTMLInputElement
if (prevInput != null) {
prevInput.focus()
}
}
}
function onPaste (e: ClipboardEvent): void {
e.preventDefault()
if (e.clipboardData == null) return
const text = e.clipboardData.getData('text')
const digits = text.split('').filter((it) => it !== ' ')
if (digits.length !== fields.length) return
let focusName: string | undefined = undefined
for (const field of fields) {
const digit = digits.shift()
if (digit === undefined) break
otpData[field.name] = digit
focusName = field.name
}
if (focusName !== undefined && focusName !== '' && formElement) {
const input = formElement.querySelector(`input[name="${focusName}"]`) as HTMLInputElement | undefined
input?.focus()
}
if (Object.values(otpData).every((v) => v !== '')) {
void validateOtp()
}
}
$: if (formElement != null) {
formElement.addEventListener('input', onInput)
formElement.addEventListener('keydown', onKeydown)
formElement.addEventListener('paste', onPaste)
const firstInput = formElement.querySelector(`input[name="${fields[0].name}"]`) as HTMLInputElement | undefined
firstInput?.focus()
}
onDestroy(() => {
if (formElement !== undefined) {
formElement.removeEventListener('input', onInput)
formElement.removeEventListener('keydown', onKeydown)
formElement.removeEventListener('paste', onPaste)
}
})
async function resendCode (): Promise<void> {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [otpStatus, result] = await sendOtp(email)
status = otpStatus
if (result !== undefined && result.sent && otpStatus === OK) {
retryOn = result.retryOn
clearOtpData()
if (timer !== undefined) timer.restart(retryOn)
canResend = false
}
}
let canResend = false
const changeEmailAction: BottomAction = {
caption: login.string.WrongEmail,
i18n: login.string.ChangeEmail,
func: () => {
dispatch('step', OtpLoginSteps.Email)
}
}
const resendCodeAction: BottomAction = {
caption: login.string.HaventReceivedCode,
i18n: login.string.ResendCode,
func: () => {
void resendCode()
}
}
let timer: TimeLeft | undefined
</script>
<form
bind:this={formElement}
class="container"
style:padding={$deviceInfo.docWidth <= 480 ? '.25rem 1.25rem' : '4rem 5rem'}
style:min-height={$deviceInfo.docHeight > 720 ? '42rem' : '0'}
>
<div class="header">
<Tabs loginState="login" />
<div class="description">
<Label label={login.string.SentTo} />
<span class="email ml-1">
{email}
</span>
</div>
</div>
<div class="form">
{#each fields as field, index (field.name)}
{#if index === 3}
<div class="separator" />
{/if}
<div class={'form-row'}>
<CodeInput
id={field.id}
name={field.name}
size="medium"
bind:value={otpData[field.name]}
on:blur={() => {
trim(field.name)
}}
/>
</div>
{/each}
</div>
<div class="footer mt-6">
<span class="flex-row-top">
<Label label={login.string.CanFindCode} />
<span class="time ml-2">
<TimeLeft
bind:this={timer}
time={retryOn}
on:timeout={() => {
canResend = true
}}
/>
</span>
</span>
<BottomActionComponent action={changeEmailAction} />
{#if canResend}
<BottomActionComponent action={resendCodeAction} />
{/if}
</div>
<div class="status mt-2">
<StatusControl {status} />
</div>
</form>
<style lang="scss">
.separator {
display: block;
width: 0.75rem;
height: 1px;
background-color: var(--theme-button-border);
flex-shrink: 0;
}
.header {
display: flex;
flex-direction: column;
gap: 0.5rem;
.description {
font-size: 1rem;
color: var(--theme-darker-color);
}
.email {
color: var(--theme-caption-color);
}
}
.status {
height: 2.375rem;
}
.footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 4.625rem;
color: var(--theme-darker-color);
}
.container {
overflow: hidden;
display: flex;
flex-direction: column;
.form {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
align-items: center;
}
}
.time {
color: var(--theme-caption-color);
}
</style>
@@ -0,0 +1,82 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getCurrentLocation, Label, navigate } from '@hcengineering/ui'
import login from '../plugin'
export let loginState: 'login' | 'signup' | 'none' = 'none'
const goTab = (path: string) => {
const loc = getCurrentLocation()
loc.path[1] = path
loc.path.length = 2
navigate(loc)
}
</script>
<div class="flex-row-center caption">
<a
class="title"
class:selected={loginState === 'signup'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'signup') goTab('signup')
}}
>
<Label label={login.string.SignUp} />
</a>
<a
class="title"
class:selected={loginState === 'login'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'login') goTab('login')
}}
>
<Label label={login.string.LogIn} />
</a>
</div>
<style>
.title {
font-weight: 500;
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;
}
}
</style>
+19 -2
View File
@@ -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'
+15 -1
View File
@@ -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
}
})
+197 -10
View File
@@ -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<string, string> = 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<void> {
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<void> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
@@ -802,20 +872,16 @@ export function getHref (path: Pages): string {
return host + url
}
export async function afterConfirm (): Promise<void> {
export async function afterConfirm (wspage: 'onboard' | 'createWorkspace' = 'createWorkspace'): Promise<void> {
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<string, string> = 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<string[]> {
}
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<void> {
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<void> {
if (!account.confirmed) {
const loc = getCurrentLocation()
loc.path[1] = 'confirmationSend'
loc.path.length = 2
navigate(loc)
}
}
+6 -1
View File
@@ -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,
+1
View File
@@ -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",
+3
View File
@@ -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) {
+65
View File
@@ -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<any, any>,
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'
}
@@ -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')
})
@@ -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<void> {
+20 -9
View File
@@ -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> = 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, () => {
+4 -1
View File
@@ -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": "<p>You were invited to {ws}. To join, please click the link below: <a href={link}>Join</a></p><p>If the invite link above does not work, paste the following link into your browser's URL bar: {link}</p><p>The link is valid for {expHours} hours.</p>",
"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 didnt request this email, theres nothing to worry about — you can safely ignore it.",
"OtpHTML": "<h3>Confirm your email address to access {app}!</h3><p>Your confirmation code is below - enter it in the window where you've started signing in for {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>If you didnt request this email, theres nothing to worry about — you can safely ignore it.</p>",
"OtpSubject": "{app} confirmation code: {code}"
}
}
+4 -1
View File
@@ -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": "<p>Te invitaron a {ws}. Para unirte, haz clic en el enlace a continuación: <a href={link}>Unirse</a></p><p>Si el enlace de invitación anterior no funciona, pega el siguiente enlace en la barra de URL de tu navegador web: {link}</p><p>Enlace válido por {expHours} horas.</p>",
"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": "<h3>¡Confirma tu dirección de correo electrónico para acceder a {app}!</h3><p>Tu código de confirmación está a continuación: ingrésalo en la ventana donde iniciaste sesión en {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Si no solicitaste este correo electrónico, no hay de qué preocuparse; puedes ignorarlo con seguridad.</p>",
"OtpSubject": "Código de confirmación de {app}: {code}"
}
}
+4 -1
View File
@@ -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": "<p>Vous avez été invité à {ws}. Pour rejoindre, veuillez cliquer sur le lien ci-dessous : <a href={link}>Rejoindre</a></p><p>Si le lien d'invitation ci-dessus ne fonctionne pas, collez le lien suivant dans la barre d'adresse de votre navigateur web : {link}</p><p>Le lien est valable pendant {expHours} heures.</p>",
"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": "<h3>Confirmez votre adresse e-mail pour accéder à {app}!</h3><p>Votre code de confirmation est ci-dessous - entrez-le dans la fenêtre où vous avez commencé à vous connecter pour {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Si vous n'avez pas demandé cet e-mail, ne vous inquiétez pas - vous pouvez l'ignorer en toute sécurité.</p>",
"OtpSubject": "Code de confirmation {app} : {code}"
}
}
+4 -1
View File
@@ -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": "<p>Foi convidado para {ws}. Para se juntar, clique no link abaixo: <a href={link}>Juntar-se</a></p><p>Se o link de convite acima não funcionar, cole o seguinte link na barra de URL do seu navegador web: {link}</p><p>Link válido por {expHours} horas.</p>",
"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": "<h3>Confirme o seu endereço de email para aceder a {app}!</h3><p>O seu código de confirmação está abaixo - introduza-o na janela onde começou a iniciar sessão para {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Se não solicitou este email, não há motivo para preocupação - pode ignorá-lo com segurança.</p>",
"OtpSubject": "Código de confirmação {app}: {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "Восстановление пароля",
"InviteText": "Вы были приглашены в {ws}. Чтобы присоединиться, пожалуйста, скопируйте ссылку ниже в адресную строку вашего браузера: {link}. Ссылка действительна в течение {expHours} ч.",
"InviteHTML": "<p>Вы были приглашены в {ws}. Чтобы присоединиться, пожалуйста, скопируйте ссылку ниже в адресную строку вашего браузера: <a href={link}>Присоединиться</a></p><p>Если ссылка выше не работает, скопируйте следующую ссылку в адресную строку вашего браузера: {link}</p><p>Ссылка действительна в течение {expHours} ч.</p>",
"InviteSubjectRU": "Приглашение в {ws}"
"InviteSubjectRU": "Приглашение в {ws}",
"OtpText": "Подтвердите адрес электронной почты для доступа к {app}!\n\nВаш код подтверждения ниже - введите его в окне, где вы начали входить в {app}.\n\n{code}\n\nЕсли вы не запрашивали это письмо, не беспокойтесь - вы можете спокойно проигнорировать его.",
"OtpHTML": "<h3>Подтвердите адрес электронной почты для доступа к {app}!</h3><p>Ваш код подтверждения ниже - введите его в окне, где вы начали входить в {app}.<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>Если вы не запрашивали это письмо, не беспокойтесь - вы можете спокойно проигнорировать его.</p>",
"OtpSubject": "Код подтверждения {app}: {code}"
}
}
+4 -1
View File
@@ -8,6 +8,9 @@
"RecoverySubject": "密码恢复",
"InviteText": "您被邀请加入 {ws}。要加入,请将以下链接粘贴到您的浏览器地址栏中:{link}。链接有效期为 {expHours} 小时。",
"InviteHTML": "<p>您被邀请加入 {ws}。要加入,请点击以下链接:<a href={link}>加入</a></p><p>如果上面的邀请链接不起作用,请将以下链接粘贴到您的浏览器地址栏中:{link}</p><p>链接有效期为 {expHours} 小时。</p>",
"InviteSubject": "邀请加入 {ws}"
"InviteSubject": "邀请加入 {ws}",
"OtpText": "确认您的电子邮件地址以访问 {app}!\n\n您的确认码如下 - 请在您开始登录 {app} 的窗口中输入。\n\n{code}\n\n如果您没有请求此邮件,不用担心 — 您可以放心地忽略它。",
"OtpHTML": "<h3>确认您的电子邮件地址以访问 {app}</h3><p>您的确认码如下 - 请在您开始登录 {app} 的窗口中输入。<p/><br/><b style=\"font-size:36px\">{code}</b><br/><br/><p>如果您没有请求此邮件,不用担心 — 您可以放心地忽略它。</p>",
"OtpSubject": "{app} 确认码:{code}"
}
}
+3 -1
View File
@@ -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",
+208 -2
View File
@@ -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<void> {
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<string> {
let otp = otpGenerator.generate(6, {
upperCaseAlphabets: false,
lowerCaseAlphabets: false,
specialChars: false
})
let exist = await db.collection<OtpRecord>(OTP_COLLECTION).findOne({ otp })
while (exist != null) {
otp = otpGenerator.generate(6, {
lowerCaseAlphabets: false
})
exist = await db.collection<OtpRecord>(OTP_COLLECTION).findOne({ otp })
}
return otp
}
export async function sendOtp (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string
): Promise<OtpInfo> {
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<OtpRecord>(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<OtpRecord>(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<boolean> {
const now = Date.now()
const otpData = (await db.collection<OtpRecord>(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<LoginInfo> {
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<OtpRecord>(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<OtpInfo> {
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<void> {
await db.collection<OtpRecord>(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<void> {
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)
}
}
+7 -2
View File
@@ -13,7 +13,9 @@ export const accountPlugin = plugin(accountId, {
FrontURL: '' as Metadata<string>,
SES_URL: '' as Metadata<string>,
ProductName: '' as Metadata<string>,
Transactors: '' as Metadata<string>
Transactors: '' as Metadata<string>,
OtpTimeToLiveSec: '' as Metadata<number>,
OtpRetryDelaySec: '' as Metadata<number>
},
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
}
})
+3
View File
@@ -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()
+2
View File
@@ -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<void> {
await this.loginWithPassword().click()
await this.inputEmail().fill(email)
await this.inputPassword().fill(password)
expect(await this.buttonLogin().isEnabled()).toBe(true)