Add login and signup by code (#6097)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2024-07-30 14:52:10 +07:00
committed by Andrey Sobolev
parent 59bb49c186
commit 8a45a3fdde
53 changed files with 1721 additions and 236 deletions
+15 -1
View File
@@ -42,6 +42,20 @@
"ConfirmationSent": "A message has been sent to your email containing a link to confirm your address.",
"ConfirmationSent2": "Please follow the link to complete your sign up.",
"Slogan": "A unique place to manage all of your work\nWelcome to the Platform",
"ContinueWith": "Continue with {provider}"
"ContinueWith": "Continue with {provider}",
"HaventReceivedCode": "Haven't received the code?",
"ResendCode": "Resend code",
"WrongEmail": "Wrong email?",
"ChangeEmail": "Change email",
"SentTo": "We've sent a code to ",
"CanFindCode": "Can't find your code? Check your spam folder.",
"LoginWithPassword": "Login with password",
"LoginWithCode": "Login with code",
"FillInProfile": "Fill in your profile",
"SetUpPassword": "Set up your password",
"Next": "Next",
"Skip": "Skip",
"SignUpCompleted": "Sign up completed",
"StartUsingHuly": "Start using Huly"
}
}
+15 -1
View File
@@ -42,6 +42,20 @@
"ConfirmationSent": "Se ha enviado un mensaje a su correo electrónico con un enlace para confirmar su dirección.",
"ConfirmationSent2": "Por favor, siga el enlace para completar su registro.",
"Slogan": "Un lugar único para gestionar todo tu trabajo\nBienvenido/a a la Plataforma",
"ContinueWith": "Continuar com {provider}"
"ContinueWith": "Continuar com {provider}",
"HaventReceivedCode": "¿No has recibido el código?",
"ResendCode": "Reenviar código",
"WrongEmail": "¿Correo electrónico incorrecto?",
"ChangeEmail": "Cambiar correo electrónico",
"SentTo": "Hemos enviado un código a ",
"CanFindCode": "¿No encuentras tu código? Revisa tu",
"LoginWithPassword": "Iniciar sesión con contraseña",
"LoginWithCode": "Iniciar sesión con código",
"FillInProfile": "Rellena tu perfil",
"SetUpPassword": "Establecer tu contraseña",
"Next": "Siguiente",
"Skip": "Saltar",
"SignUpCompleted": "Registro completado",
"StartUsingHuly": "Comienza a usar Huly"
}
}
+15 -1
View File
@@ -42,6 +42,20 @@
"ConfirmationSent": "Un message a été envoyé à votre adresse e-mail contenant un lien pour confirmer votre adresse.",
"ConfirmationSent2": "Veuillez suivre le lien pour compléter votre inscription.",
"Slogan": "Un lieu unique pour gérer tout votre travail\nBienvenue sur la plateforme",
"ContinueWith": "Continuer avec {provider}"
"ContinueWith": "Continuer avec {provider}",
"HaventReceivedCode": "Vous n'avez pas reçu le code ?",
"ResendCode": "Renvoyer le code",
"WrongEmail": "Mauvaise adresse e-mail ?",
"ChangeEmail": "Changer l'adresse e-mail",
"SentTo": "Nous avons envoyé un code à ",
"CanFindCode": "Vous ne trouvez pas votre code ? Vérifiez votre",
"LoginWithPassword": "Connexion avec mot de passe",
"LoginWithCode": "Connexion avec code",
"FillInProfile": "Remplissez votre profil",
"SetUpPassword": "Définir votre mot de passe",
"Next": "Suivant",
"Skip": "Passer",
"SignUpCompleted": "Inscription terminée",
"StartUsingHuly": "Commencez à utiliser Huly"
}
}
+15 -1
View File
@@ -42,6 +42,20 @@
"ConfirmationSent": "Foi enviada uma mensagem para o seu email contendo um link para confirmar o seu endereço.",
"ConfirmationSent2": "Por favor, siga o link para concluir o seu registo.",
"Slogan": "Um local único para gerir todo o seu trabalho\nBem-vindo à Plataforma",
"ContinueWith": "Continuar com {provider}"
"ContinueWith": "Continuar com {provider}",
"HaventReceivedCode": "Não recebeu o código?",
"ResendCode": "Reenviar código",
"WrongEmail": "Email errado?",
"ChangeEmail": "Alterar email",
"SentTo": "Enviamos um código para ",
"CanFindCode": "Não encontra o seu código? Verifique a sua pasta de spam.",
"LoginWithPassword": "Iniciar sessão com palavra-passe",
"LoginWithCode": "Iniciar sessão com código",
"FillInProfile": "Preencha o seu perfil",
"SetUpPassword": "Definir a sua palavra-passe",
"Next": "Seguinte",
"Skip": "Saltar",
"SignUpCompleted": "Registo concluído",
"StartUsingHuly": "Começar a usar Huly"
}
}
+15 -1
View File
@@ -42,6 +42,20 @@
"ConfirmationSent": "На Вашу почту отправлено сообщение, c ссылкой для подтверждения email.",
"ConfirmationSent2": "Пожалуйста, перейдите по ссылке для завершения регистрации.",
"Slogan": "Уникальное место для организации всей вашей работы\nДобро пожаловать в Платформу",
"ContinueWith": "Войти через {provider}"
"ContinueWith": "Войти через {provider}",
"HaventReceivedCode": "Не получили код?",
"ResendCode": "Отправить код повторно",
"WrongEmail": "Неверный email?",
"ChangeEmail": "Изменить email",
"SentTo": "Мы отправили код на ",
"CanFindCode": "Не нашли код? Проверьте папку со спамом.",
"LoginWithPassword": "Войти с паролем",
"LoginWithCode": "Войти с кодом",
"FillInProfile": "Заполните профиль",
"SetUpPassword": "Установите пароль",
"Next": "Дальше",
"Skip": "Пропустить",
"SignUpCompleted": "Регистрация завершена",
"StartUsingHuly": "Начать использовать Huly"
}
}
+15 -1
View File
@@ -42,6 +42,20 @@
"ConfirmationSent": "已发送确认邮件到您的邮箱,包含一个确认地址的链接。",
"ConfirmationSent2": "请点击链接完成注册。",
"Slogan": "一个独特的地方来管理你所有的工作\n欢迎来到平台",
"ContinueWith": "继续使用 {provider}"
"ContinueWith": "继续使用 {provider}",
"HaventReceivedCode": "没有收到验证码?",
"ResendCode": "重新发送验证码",
"WrongEmail": "错误的电子邮件?",
"ChangeEmail": "更改电子邮件",
"SentTo": "我们已发送验证码到 ",
"CanFindCode": "找不到验证码?请检查您的垃圾邮件文件夹。",
"LoginWithPassword": "使用密码登录",
"LoginWithCode": "使用代码登录",
"FillInProfile": "填写您的个人资料",
"SetUpPassword": "设置您的密码",
"Next": "下一个",
"Skip": "跳过",
"SignUpCompleted": "注册完成",
"StartUsingHuly": "开始使用 Huly"
}
}
@@ -5,7 +5,7 @@
import { Loading, setMetadataLocalStorage } from '@hcengineering/ui'
import { onMount } from 'svelte'
import login from '../plugin'
import { afterConfirm, getSessionLoginInfo, navigateToWorkspace } from '../utils'
import { afterConfirm, getSessionLoginInfo, goTo, navigateToWorkspace } from '../utils'
onMount(async () => {
const result = await getSessionLoginInfo()
@@ -18,7 +18,9 @@
setMetadataLocalStorage(login.metadata.LastToken, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
await afterConfirm()
await afterConfirm('onboard')
} else {
goTo('login')
}
})
@@ -0,0 +1,45 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Label } from '@hcengineering/ui'
import { NavLink } from '@hcengineering/presentation'
import { getHref } from '../utils'
import { BottomAction } from '../index'
export let action: BottomAction
</script>
<div>
{#if action.caption}
<span><Label label={action.caption} /></span>
{/if}
{#if action.page}
<NavLink href={getHref(action.page)}><Label label={action.i18n} /></NavLink>
{:else}
<a href="." on:click|preventDefault={action.func}><Label label={action.i18n} /></a>
{/if}
</div>
<style lang="scss">
span {
color: var(--theme-darker-color);
}
a {
font-weight: 400;
color: var(--theme-content-color);
}
</style>
@@ -14,14 +14,13 @@
// limitations under the License.
-->
<script lang="ts">
import { Status, Severity, OK, setMetadata } from '@hcengineering/platform'
import { Status, Severity, OK } from '@hcengineering/platform'
import Form from './Form.svelte'
import { createWorkspace, getAccount, goTo } from '../utils'
import { fetchMetadataLocalStorage, getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import { createWorkspace, getAccount, goTo, setLoginInfo } from '../utils'
import { getCurrentLocation, navigate } from '@hcengineering/ui'
import login from '../plugin'
import { workbenchId } from '@hcengineering/workbench'
import presentation from '@hcengineering/presentation'
import { onMount } from 'svelte'
import { LoginInfo } from '@hcengineering/login'
@@ -61,13 +60,8 @@
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setLoginInfo(result)
navigate({ path: [workbenchId, result.workspace] })
}
}
@@ -16,23 +16,16 @@
<script lang="ts">
import type { IntlString } from '@hcengineering/platform'
import { OK, Severity, Status, translate } from '@hcengineering/platform'
import {
Button,
Label,
StylishEdit,
deviceOptionsStore as deviceInfo,
getCurrentLocation,
navigate,
themeStore
} from '@hcengineering/ui'
import { Button, Label, StylishEdit, deviceOptionsStore as deviceInfo, themeStore } from '@hcengineering/ui'
import StatusControl from './StatusControl.svelte'
import { NavLink } from '@hcengineering/presentation'
import { onMount } from 'svelte'
import { BottomAction, getHref } from '..'
import { BottomAction } from '..'
import login from '../plugin'
import { makeSequential } from '../mutex'
import Providers from './Providers.svelte'
import Tabs from './Tabs.svelte'
import BottomActionComponent from './BottomAction.svelte'
interface Field {
id?: string
@@ -122,12 +115,7 @@
object[field] = (object[field] as string).trim()
}
const goTab = (path: string) => {
const loc = getCurrentLocation()
loc.path[1] = path
loc.path.length = 2
navigate(loc)
}
let loginState: 'login' | 'signup' | 'none' = 'none'
$: loginState = caption === login.string.LogIn ? 'login' : caption === login.string.SignUp ? 'signup' : 'none'
</script>
@@ -150,28 +138,7 @@
}}
>
{#if loginState !== 'none'}
<div class="flex-row-center caption">
<a
class="title"
class:selected={loginState === 'signup'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'signup') goTab('signup')
}}
>
<Label label={login.string.SignUp} />
</a>
<a
class="title"
class:selected={loginState === 'login'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'login') goTab('login')
}}
>
<Label label={login.string.LogIn} />
</a>
</div>
<Tabs {loginState} />
{:else}
{#if subtitle !== undefined}
<div class="fs-title">
@@ -234,14 +201,7 @@
{#if bottomActions.length}
<div class="footer">
{#each bottomActions as action}
<div>
<span><Label label={action.caption} /></span>
{#if action.page}
<NavLink href={getHref(action.page)}><Label label={action.i18n} /></NavLink>
{:else}
<a href="." on:click|preventDefault={action.func}><Label label={action.i18n} /></a>
{/if}
</div>
<BottomActionComponent {action} />
{/each}
</div>
{/if}
@@ -258,28 +218,6 @@
font-size: 1.25rem;
color: var(--theme-caption-color);
}
.caption a {
padding-bottom: 0.375rem;
border-bottom: 2px solid var(--theme-caption-color);
&:not(.selected) {
color: var(--theme-dark-color);
border-bottom-color: transparent;
&:hover {
color: var(--theme-caption-color);
}
}
&.selected {
cursor: default;
}
&:first-child {
margin-right: 1.75rem;
}
&:hover {
text-decoration: none;
}
}
.status {
padding-top: 1rem;
grid-column-start: 1;
@@ -13,20 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, setMetadata, Severity, Status } from '@hcengineering/platform'
import {
fetchMetadataLocalStorage,
getCurrentLocation,
Location,
navigate,
setMetadataLocalStorage
} from '@hcengineering/ui'
import { OK, Severity, Status } from '@hcengineering/platform'
import { Location, getCurrentLocation, navigate } from '@hcengineering/ui'
import { checkJoined, join, signUpJoin } from '../utils'
import { checkJoined, join, setLoginInfo, signUpJoin } from '../utils'
import Form from './Form.svelte'
import { Analytics } from '@hcengineering/analytics'
import presentation from '@hcengineering/presentation'
import { workbenchId } from '@hcengineering/workbench'
import { onMount } from 'svelte'
import { BottomAction } from '..'
@@ -84,13 +77,7 @@
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setLoginInfo(result)
if (location.query?.navigateUrl != null) {
try {
@@ -137,13 +124,7 @@
const [, result] = await checkJoined(location.query.inviteId)
status = OK
if (result !== undefined) {
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
setLoginInfo(result)
if (location.query?.navigateUrl != null) {
try {
@@ -51,6 +51,7 @@
import loginBackWebp from '../../img/login_back.webp'
import loginBack2xWebp from '../../img/login_back_2x.webp'
import login from '../plugin'
import OnboardForm from './OnboardForm.svelte'
export let page: Pages = 'signup'
@@ -80,6 +81,10 @@
}
async function chooseToken (): Promise<void> {
if (page === 'auth') {
// token handled by auth page
return
}
if (getMetadata(presentation.metadata.Token) == null) {
const lastToken = fetchMetadataLocalStorage(login.metadata.LastToken)
if (lastToken != null) {
@@ -133,6 +138,8 @@
<LoginForm {navigateUrl} />
{:else if page === 'signup'}
<SignupForm />
{:else if page === 'onboard'}
<OnboardForm />
{:else if page === 'createWorkspace'}
<CreateWorkspaceForm />
{:else if page === 'password'}
@@ -14,95 +14,49 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, setMetadata, Severity, Status } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { getCurrentLocation, Location, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import { doLogin, getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
import Form from './Form.svelte'
import { LoginInfo } from '@hcengineering/login'
import { recoveryAction } from '../actions'
import { type BottomAction, LoginMethods } from '../index'
import LoginPasswordForm from './LoginPasswordForm.svelte'
import LoginOtpForm from './LoginOtpForm.svelte'
import BottomActionComponent from './BottomAction.svelte'
import login from '../plugin'
export let navigateUrl: string | undefined = undefined
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email },
{
id: 'current-password',
name: 'password',
i18n: login.string.Password,
password: true
}
]
let method: LoginMethods = LoginMethods.Otp
const object = {
username: '',
password: ''
function changeMethod (event: CustomEvent<LoginMethods>): void {
method = event.detail
}
async function doLoginNavigate (
result: LoginInfo | undefined,
updateStatus: (status: Status<any>) => void
): Promise<void> {
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
if (navigateUrl !== undefined) {
try {
const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location
const workspace = loc.path[1]
if (workspace !== undefined) {
const workspaces = await getWorkspaces()
if (workspaces.find((p) => p.workspace === workspace) !== undefined) {
updateStatus(new Status(Severity.INFO, login.status.ConnectingToServer, {}))
const [loginStatus, result] = await selectWorkspace(workspace)
updateStatus(loginStatus)
navigateToWorkspace(workspace, result, navigateUrl)
return
}
}
} catch (err: any) {
// Json parse error could be ignored
}
}
const loc = getCurrentLocation()
loc.path[1] = result.confirmed ? 'selectWorkspace' : 'confirmationSend'
loc.path.length = 2
if (navigateUrl !== undefined) {
loc.query = { ...loc.query, navigateUrl }
}
navigate(loc)
const loginWithPasswordAction: BottomAction = {
i18n: login.string.LoginWithPassword,
func: () => {
method = LoginMethods.Password
}
}
let status = OK
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
await doLoginNavigate(result, (st) => {
status = st
})
const loginWithCodeAction: BottomAction = {
i18n: login.string.LoginWithCode,
func: () => {
method = LoginMethods.Otp
}
}
</script>
<Form
caption={login.string.LogIn}
{status}
{fields}
{object}
{action}
bottomActions={[recoveryAction]}
ignoreInitialValidation
withProviders
/>
{#if method === LoginMethods.Otp}
<LoginOtpForm {navigateUrl} on:change={changeMethod} />
<div class="action">
<BottomActionComponent action={loginWithPasswordAction} />
</div>
{:else}
<LoginPasswordForm {navigateUrl} on:change={changeMethod} />
<div class="action">
<BottomActionComponent action={loginWithCodeAction} />
</div>
{/if}
<style lang="scss">
.action {
margin-left: 5rem;
}
</style>
@@ -0,0 +1,67 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import OtpForm from './OtpForm.svelte'
import login from '../plugin'
import Form from './Form.svelte'
import { OtpLoginSteps, sendOtp } from '../index'
export let navigateUrl: string | undefined = undefined
const fields = [{ id: 'email', name: 'username', i18n: login.string.Email }]
const formData = {
username: '' as string
}
let status = OK
let step = OtpLoginSteps.Email
let otpRetryOn = 0
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [otpStatus, result] = await sendOtp(formData.username)
status = otpStatus
if (result !== undefined && result.sent && otpStatus === OK) {
step = OtpLoginSteps.Otp
otpRetryOn = result.retryOn
}
}
}
function handleStep (event: CustomEvent<OtpLoginSteps>): void {
step = event.detail
}
</script>
{#if step === OtpLoginSteps.Email}
<Form
caption={login.string.LogIn}
{status}
{fields}
object={formData}
{action}
ignoreInitialValidation
withProviders
/>
{/if}
{#if step === OtpLoginSteps.Otp && formData.username !== ''}
<OtpForm email={formData.username} {navigateUrl} retryOn={otpRetryOn} on:step={handleStep} />
{/if}
@@ -0,0 +1,68 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import { doLogin, doLoginNavigate } from '../utils'
import Form from './Form.svelte'
import { recoveryAction } from '../actions'
import login from '../plugin'
export let navigateUrl: string | undefined = undefined
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email },
{
id: 'current-password',
name: 'password',
i18n: login.string.Password,
password: true
}
]
const object = {
username: '',
password: ''
}
let status = OK
const action = {
i18n: login.string.LogIn,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
}
}
</script>
<Form
caption={login.string.LogIn}
{status}
{fields}
{object}
{action}
bottomActions={[recoveryAction]}
ignoreInitialValidation
withProviders
/>
@@ -0,0 +1,91 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { LoginInfo, WorkspaceLoginInfo } from '@hcengineering/login'
import { OK } from '@hcengineering/platform'
import { onMount } from 'svelte'
import { OnboardSteps } from '../index'
import login from '../plugin'
import { ensureConfirmed, getAccount, getWorkspaces, goTo, navigateToWorkspace } from '../utils'
import Form from './Form.svelte'
import OnboardUserForm from './OnboardUserForm.svelte'
import OnboardWorkspaceForm from './OnboardWorkspaceForm.svelte'
const steps = Object.values(OnboardSteps)
let account: LoginInfo | undefined = undefined
let step: OnboardSteps = OnboardSteps.Workspace
function handleStepEvent (event: CustomEvent<LoginInfo | WorkspaceLoginInfo | undefined>): void {
if (event.detail != null) {
account = event.detail
}
handleNext(step)
}
function handleNext (current: OnboardSteps): void {
const next = steps.indexOf(current) + 1
if (next < steps.length) {
step = steps[next]
}
}
function isWorkspaceLoginInfo (info: WorkspaceLoginInfo | LoginInfo): info is WorkspaceLoginInfo {
return (info as WorkspaceLoginInfo).workspace !== undefined
}
onMount(async () => {
account = await getAccount()
if (account != null) {
await ensureConfirmed(account)
} else {
goTo('login')
}
const workspaces = await getWorkspaces()
if (workspaces.length > 0) {
goTo('selectWorkspace')
}
})
const action = {
i18n: login.string.StartUsingHuly,
func: async () => {
if (account !== undefined && isWorkspaceLoginInfo(account)) {
navigateToWorkspace(account.workspace, account)
}
}
}
</script>
{#if account}
{#if step === OnboardSteps.Workspace}
<OnboardWorkspaceForm {account} on:step={handleStepEvent} />
{:else if step === OnboardSteps.User}
<OnboardUserForm {account} on:step={handleStepEvent} />
{:else if step === OnboardSteps.Finish}
<Form
status={OK}
caption={login.string.SignUpCompleted}
subtitle={account.email}
fields={[]}
object={{}}
{action}
/>
{/if}
{/if}
@@ -0,0 +1,67 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { LoginInfo } from '@hcengineering/login'
import { OK, Severity, Status, unknownError } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import login from '../plugin'
import { changeUsername } from '../utils'
import Form from './Form.svelte'
export let account: LoginInfo
const dispatch = createEventDispatcher()
const fields = [
{ id: 'given-name', name: 'first', i18n: login.string.FirstName, short: true },
{ id: 'family-name', name: 'last', i18n: login.string.LastName, short: true }
]
const object = { first: '', last: '' }
let status: Status<any> = OK
const action = {
i18n: login.string.Next,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
try {
await changeUsername(object.first, object.last)
status = OK
dispatch('step')
} catch (err) {
status = unknownError(err)
}
}
}
</script>
<Form
caption={login.string.FillInProfile}
subtitle={account.email}
{status}
{fields}
{object}
{action}
bottomActions={[
{
i18n: login.string.Skip,
func: () => {
dispatch('step')
}
}
]}
/>
@@ -0,0 +1,52 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { LoginInfo } from '@hcengineering/login'
import { Status, Severity, OK } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import Form from './Form.svelte'
import { createWorkspace, setLoginInfo } from '../utils'
import login from '../plugin'
export let account: LoginInfo
const dispatch = createEventDispatcher()
const fields = [{ id: 'workspace', name: 'workspace', i18n: login.string.Workspace }]
const object = {
workspace: ''
}
let status: Status<any> = OK
const action = {
i18n: login.string.CreateWorkspace,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await createWorkspace(object.workspace)
status = loginStatus
if (result !== undefined) {
setLoginInfo(result)
dispatch('step', result)
}
}
}
</script>
<Form caption={login.string.CreateWorkspace} subtitle={account.email} {status} {fields} {object} {action} />
@@ -0,0 +1,326 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { deviceOptionsStore as deviceInfo, Label, TimeLeft, CodeInput } from '@hcengineering/ui'
import { OK, Severity, Status } from '@hcengineering/platform'
import { createEventDispatcher, onDestroy } from 'svelte'
import { Timestamp } from '@hcengineering/core'
import Tabs from './Tabs.svelte'
import { BottomAction, doLoginNavigate, loginWithOtp, OtpLoginSteps, sendOtp } from '../index'
import login from '../plugin'
import BottomActionComponent from './BottomAction.svelte'
import StatusControl from './StatusControl.svelte'
export let navigateUrl: string | undefined = undefined
export let email: string
export let retryOn: Timestamp
const dispatch = createEventDispatcher()
const fields = [
{ id: 'otp1', name: 'otp1', optional: false },
{ id: 'otp2', name: 'otp2', optional: false },
{ id: 'otp3', name: 'otp3', optional: false },
{ id: 'otp4', name: 'otp4', optional: false },
{ id: 'otp5', name: 'otp5', optional: false },
{ id: 'otp6', name: 'otp6', optional: false }
]
let status = OK
const otpData: Record<string, string> = {
otp1: '',
otp2: '',
otp3: '',
otp4: '',
otp5: '',
otp6: ''
}
let formElement: HTMLFormElement | undefined
function trim (field: string): void {
otpData[field] = otpData[field].trim()
}
async function validateOtp (): Promise<void> {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const otp = otpData.otp1 + otpData.otp2 + otpData.otp3 + otpData.otp4 + otpData.otp5 + otpData.otp6
const [loginStatus, result] = await loginWithOtp(email, otp)
status = loginStatus
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
}
function onInput (e: Event): void {
if (e.target == null) return
const target = e.target as HTMLInputElement
const { value } = target
if (value == null || value === '') return
const index = fields.findIndex(({ id }) => id === target.id)
if (index === -1) return
if (Object.values(otpData).every((v) => v !== '')) {
void validateOtp()
return
}
const nextField = fields[index + 1]
if (nextField === undefined) return
const nextInput = formElement?.querySelector(`input[name="${nextField.name}"]`) as HTMLInputElement
if (nextInput != null) {
nextInput.focus()
}
}
function clearOtpData (): void {
Object.keys(otpData).forEach((key) => {
otpData[key] = ''
})
const input = formElement?.querySelector(`input[name="${fields[0].name}"]`) as HTMLInputElement
if (input != null) {
input.focus()
}
}
function onKeydown (e: KeyboardEvent): void {
const key = e.key.toLowerCase()
const target = e.target as HTMLInputElement
if (key !== 'backspace' && key !== 'delete') return
const index = fields.findIndex(({ id }) => id === target.id)
if (index === -1) return
const { value } = target
status = OK
target.value = ''
otpData[fields[index].name] = ''
if (value === '') {
const prevField = fields[index - 1]
if (prevField === undefined) return
const prevInput = formElement?.querySelector(`input[name="${prevField.name}"]`) as HTMLInputElement
if (prevInput != null) {
prevInput.focus()
}
}
}
function onPaste (e: ClipboardEvent): void {
e.preventDefault()
if (e.clipboardData == null) return
const text = e.clipboardData.getData('text')
const digits = text.split('').filter((it) => it !== ' ')
if (digits.length !== fields.length) return
let focusName: string | undefined = undefined
for (const field of fields) {
const digit = digits.shift()
if (digit === undefined) break
otpData[field.name] = digit
focusName = field.name
}
if (focusName !== undefined && focusName !== '' && formElement) {
const input = formElement.querySelector(`input[name="${focusName}"]`) as HTMLInputElement | undefined
input?.focus()
}
if (Object.values(otpData).every((v) => v !== '')) {
void validateOtp()
}
}
$: if (formElement != null) {
formElement.addEventListener('input', onInput)
formElement.addEventListener('keydown', onKeydown)
formElement.addEventListener('paste', onPaste)
const firstInput = formElement.querySelector(`input[name="${fields[0].name}"]`) as HTMLInputElement | undefined
firstInput?.focus()
}
onDestroy(() => {
if (formElement !== undefined) {
formElement.removeEventListener('input', onInput)
formElement.removeEventListener('keydown', onKeydown)
formElement.removeEventListener('paste', onPaste)
}
})
async function resendCode (): Promise<void> {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [otpStatus, result] = await sendOtp(email)
status = otpStatus
if (result !== undefined && result.sent && otpStatus === OK) {
retryOn = result.retryOn
clearOtpData()
if (timer !== undefined) timer.restart(retryOn)
canResend = false
}
}
let canResend = false
const changeEmailAction: BottomAction = {
caption: login.string.WrongEmail,
i18n: login.string.ChangeEmail,
func: () => {
dispatch('step', OtpLoginSteps.Email)
}
}
const resendCodeAction: BottomAction = {
caption: login.string.HaventReceivedCode,
i18n: login.string.ResendCode,
func: () => {
void resendCode()
}
}
let timer: TimeLeft | undefined
</script>
<form
bind:this={formElement}
class="container"
style:padding={$deviceInfo.docWidth <= 480 ? '.25rem 1.25rem' : '4rem 5rem'}
style:min-height={$deviceInfo.docHeight > 720 ? '42rem' : '0'}
>
<div class="header">
<Tabs loginState="login" />
<div class="description">
<Label label={login.string.SentTo} />
<span class="email ml-1">
{email}
</span>
</div>
</div>
<div class="form">
{#each fields as field, index (field.name)}
{#if index === 3}
<div class="separator" />
{/if}
<div class={'form-row'}>
<CodeInput
id={field.id}
name={field.name}
size="medium"
bind:value={otpData[field.name]}
on:blur={() => {
trim(field.name)
}}
/>
</div>
{/each}
</div>
<div class="footer mt-6">
<span class="flex-row-top">
<Label label={login.string.CanFindCode} />
<span class="time ml-2">
<TimeLeft
bind:this={timer}
time={retryOn}
on:timeout={() => {
canResend = true
}}
/>
</span>
</span>
<BottomActionComponent action={changeEmailAction} />
{#if canResend}
<BottomActionComponent action={resendCodeAction} />
{/if}
</div>
<div class="status mt-2">
<StatusControl {status} />
</div>
</form>
<style lang="scss">
.separator {
display: block;
width: 0.75rem;
height: 1px;
background-color: var(--theme-button-border);
flex-shrink: 0;
}
.header {
display: flex;
flex-direction: column;
gap: 0.5rem;
.description {
font-size: 1rem;
color: var(--theme-darker-color);
}
.email {
color: var(--theme-caption-color);
}
}
.status {
height: 2.375rem;
}
.footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 4.625rem;
color: var(--theme-darker-color);
}
.container {
overflow: hidden;
display: flex;
flex-direction: column;
.form {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
align-items: center;
}
}
.time {
color: var(--theme-caption-color);
}
</style>
@@ -0,0 +1,82 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getCurrentLocation, Label, navigate } from '@hcengineering/ui'
import login from '../plugin'
export let loginState: 'login' | 'signup' | 'none' = 'none'
const goTab = (path: string) => {
const loc = getCurrentLocation()
loc.path[1] = path
loc.path.length = 2
navigate(loc)
}
</script>
<div class="flex-row-center caption">
<a
class="title"
class:selected={loginState === 'signup'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'signup') goTab('signup')
}}
>
<Label label={login.string.SignUp} />
</a>
<a
class="title"
class:selected={loginState === 'login'}
href="."
on:click|preventDefault={() => {
if (loginState !== 'login') goTab('login')
}}
>
<Label label={login.string.LogIn} />
</a>
</div>
<style>
.title {
font-weight: 500;
font-size: 1.25rem;
color: var(--theme-caption-color);
}
.caption a {
padding-bottom: 0.375rem;
border-bottom: 2px solid var(--theme-caption-color);
&:not(.selected) {
color: var(--theme-dark-color);
border-bottom-color: transparent;
&:hover {
color: var(--theme-caption-color);
}
}
&.selected {
cursor: default;
}
&:first-child {
margin-right: 1.75rem;
}
&:hover {
text-decoration: none;
}
}
</style>
+19 -2
View File
@@ -61,16 +61,33 @@ export const pages = [
'join',
'confirm',
'confirmationSend',
'auth'
'auth',
'login-password',
'onboard'
] as const
export enum OnboardSteps {
Workspace = 'workspace',
User = 'user',
Finish = 'finish'
}
export enum OtpLoginSteps {
Email = 'email',
Otp = 'otp'
}
export enum LoginMethods {
Password = 'password',
Otp = 'otp'
}
export type Pages = (typeof pages)[number]
export interface BottomAction {
i18n: IntlString
page?: Pages
func: () => void
caption: IntlString
caption?: IntlString
}
export * from './utils'
+15 -1
View File
@@ -59,6 +59,20 @@ export default mergeIds(loginId, login, {
ConfirmationSent: '' as IntlString,
ConfirmationSent2: '' as IntlString,
Slogan: '' as IntlString,
ContinueWith: '' as IntlString
ContinueWith: '' as IntlString,
HaventReceivedCode: '' as IntlString,
ResendCode: '' as IntlString,
WrongEmail: '' as IntlString,
ChangeEmail: '' as IntlString,
SentTo: '' as IntlString,
CanFindCode: '' as IntlString,
LoginWithCode: '' as IntlString,
LoginWithPassword: '' as IntlString,
FillInProfile: '' as IntlString,
SetUpPassword: '' as IntlString,
Next: '' as IntlString,
Skip: '' as IntlString,
SignUpCompleted: '' as IntlString,
StartUsingHuly: '' as IntlString
}
})
+197 -10
View File
@@ -14,8 +14,8 @@
//
import { Analytics } from '@hcengineering/analytics'
import { AccountRole, concatLink, type Doc, type Ref } from '@hcengineering/core'
import login, { loginId, type LoginInfo, type Workspace, type WorkspaceLoginInfo } from '@hcengineering/login'
import { AccountRole, type Doc, type Ref, concatLink } from '@hcengineering/core'
import { loginId, type LoginInfo, type OtpInfo, type Workspace, type WorkspaceLoginInfo } from '@hcengineering/login'
import {
OK,
PlatformError,
@@ -24,7 +24,8 @@ import {
translate,
unknownError,
unknownStatus,
type Status
Status,
Severity
} from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import {
@@ -37,6 +38,8 @@ import {
type Location
} from '@hcengineering/ui'
import { workbenchId } from '@hcengineering/workbench'
import login from './plugin'
import { type Pages } from './index'
/**
@@ -117,6 +120,40 @@ export async function signUp (
}
}
export async function signUpOtp (email: string): Promise<[Status, OtpInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
const request = {
method: 'signUpOtp',
params: [email]
}
try {
const response = await fetch(accountsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
const result = await response.json()
if (result.error == null) {
Analytics.handleEvent('signUpOtp')
Analytics.setUser(email)
} else {
await handleStatusError('Sign up error', result.error)
}
return [result.error ?? OK, result.result]
} catch (err: any) {
Analytics.handleError(err)
return [unknownError(err), undefined]
}
}
export async function createWorkspace (
workspaceName: string
): Promise<[Status, (LoginInfo & { workspace: string }) | undefined]> {
@@ -376,6 +413,9 @@ export function setLoginInfo (loginInfo: WorkspaceLoginInfo): void {
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[loginInfo.workspace] = loginInfo.token
setMetadata(presentation.metadata.Token, loginInfo.token)
setMetadataLocalStorage(login.metadata.LastToken, loginInfo.token)
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setMetadataLocalStorage(login.metadata.LoginEndpoint, loginInfo.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, loginInfo.email)
@@ -627,6 +667,36 @@ export async function changePassword (oldPassword: string, password: string): Pr
}
}
export async function changeUsername (first: string, last: string): Promise<void> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
const token = getMetadata(presentation.metadata.Token) as string
const request = {
method: 'changeUsername',
params: [first, last]
}
const response = await fetch(accountsUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
const resp = await response.json()
if (resp.error !== undefined) {
const err = new PlatformError(resp.error)
Analytics.handleError(err)
throw err
}
}
export async function leaveWorkspace (email: string): Promise<void> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
@@ -802,20 +872,16 @@ export function getHref (path: Pages): string {
return host + url
}
export async function afterConfirm (): Promise<void> {
export async function afterConfirm (wspage: 'onboard' | 'createWorkspace' = 'createWorkspace'): Promise<void> {
const joinedWS = await getWorkspaces()
if (joinedWS.length === 0) {
goTo('createWorkspace')
goTo(wspage)
} else if (joinedWS.length === 1) {
const result = (await selectWorkspace(joinedWS[0].workspace, null))[1]
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
const tokens: Record<string, string> = fetchMetadataLocalStorage(login.metadata.LoginTokens) ?? {}
tokens[result.workspace] = result.token
setMetadataLocalStorage(login.metadata.LoginTokens, tokens)
setLoginInfo(result)
navigateToWorkspace(joinedWS[0].workspace, result)
}
@@ -866,3 +932,124 @@ export async function getProviders (): Promise<string[]> {
}
return []
}
export async function sendOtp (email: string): Promise<[Status, OtpInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
const request = {
method: 'sendOtp',
params: [email]
}
try {
const response = await fetch(accountsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
const result = await response.json()
if (result.error == null) {
Analytics.handleEvent('sendOtp')
Analytics.setUser(email)
} else {
await handleStatusError('Send otp error', result.error)
}
return [result.error ?? OK, result.result]
} catch (err: any) {
console.error('Send otp error', err)
Analytics.handleError(err)
return [unknownError(err), undefined]
}
}
export async function loginWithOtp (email: string, otp: string): Promise<[Status, LoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
const request = {
method: 'validateOtp',
params: [email, otp]
}
try {
const response = await fetch(accountsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
const result = await response.json()
if (result.error == null) {
Analytics.handleEvent('loginWithOtp')
Analytics.setUser(email)
} else {
await handleStatusError('Login with otp error', result.error)
}
return [result.error ?? OK, result.result]
} catch (err: any) {
console.error('Login with otp error', err)
Analytics.handleError(err)
return [unknownError(err), undefined]
}
}
export async function doLoginNavigate (
result: LoginInfo | undefined,
updateStatus: (status: Status) => void,
navigateUrl?: string
): Promise<void> {
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LastToken, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
if (navigateUrl !== undefined) {
try {
const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location
const workspace = loc.path[1]
if (workspace !== undefined) {
const workspaces = await getWorkspaces()
if (workspaces.find((p) => p.workspace === workspace) !== undefined) {
updateStatus(new Status(Severity.INFO, login.status.ConnectingToServer, {}))
const [loginStatus, result] = await selectWorkspace(workspace, undefined)
updateStatus(loginStatus)
navigateToWorkspace(workspace, result, navigateUrl)
return
}
}
} catch (err: any) {
// Json parse error could be ignored
}
}
const loc = getCurrentLocation()
loc.path[1] = result.confirmed ? 'selectWorkspace' : 'confirmationSend'
loc.path.length = 2
if (navigateUrl !== undefined) {
loc.query = { ...loc.query, navigateUrl }
}
navigate(loc)
}
}
export async function ensureConfirmed (account: LoginInfo): Promise<void> {
if (!account.confirmed) {
const loc = getCurrentLocation()
loc.path[1] = 'confirmationSend'
loc.path.length = 2
navigate(loc)
}
}
+6 -1
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { AccountRole, Doc, Ref } from '@hcengineering/core'
import { AccountRole, Doc, Ref, Timestamp } from '@hcengineering/core'
import type { Asset, IntlString, Metadata, Plugin, Resource, Status } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui'
@@ -56,6 +56,11 @@ export interface LoginInfo {
email: string
}
export interface OtpInfo {
sent: boolean
retryOn: Timestamp
}
export default plugin(loginId, {
metadata: {
AccountsUrl: '' as Asset,