mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-30 11:49:42 +02:00
Add login and signup by code (#6097)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Generated
+30
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"WorkspaceRateLimit": "Сервер перегружен, пожалуйста, подождите",
|
||||
"AccountAlreadyConfirmed": "Аккаунт уже подтвержден",
|
||||
"WorkspaceAlreadyExists": "Рабочее пространство уже существует",
|
||||
"ProductIdMismatch": "Продукт не соответствует"
|
||||
"ProductIdMismatch": "Продукт не соответствует",
|
||||
"InvalidOtp": "Неверный код"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"WorkspaceRateLimit": "服务器繁忙,请稍后再试",
|
||||
"AccountAlreadyConfirmed": "账户已确认",
|
||||
"WorkspaceAlreadyExists": "工作区已存在",
|
||||
"ProductIdMismatch": "产品不匹配"
|
||||
"ProductIdMismatch": "产品不匹配",
|
||||
"InvalidOtp": "无效的代码"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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 didn’t request this email, there’s 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 didn’t request this email, there’s nothing to worry about — you can safely ignore it.</p>",
|
||||
"OtpSubject": "{app} confirmation code: {code}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user