platform/plugins/login-resources/src/components/OtpForm.svelte
Alexey Zinoviev c6326b3c73
Some checks are pending
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions
UBERF-9636: Meeting links - more cases (#8369)
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
2025-03-28 01:05:53 +07:00

352 lines
8.9 KiB
Svelte

<!--
// 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 { LoginInfo } from '@hcengineering/account-client'
import Tabs from './Tabs.svelte'
import { BottomAction, doLoginNavigate, validateOtpLogin, OtpLoginSteps, loginOtp } 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
export let signUpDisabled = false
export let loginState: 'login' | 'signup' | 'none' = 'none'
export let canChangeEmail = true
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined
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 validateOtpLogin(email, otp)
status = loginStatus
if (onLogin !== undefined) {
void onLogin(result, status)
} else {
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
if (value.length === fields.length) {
pasteOtpCode(value)
} else {
const index = fields.findIndex(({ id }) => id === target.id)
if (index === -1) return
target.value = value[0]
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')
pasteOtpCode(text)
}
function pasteOtpCode (text: string): boolean {
const digits = text.split('').filter((it) => it !== ' ')
if (digits.length !== fields.length) {
return false
}
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()
}
return true
}
$: 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 loginOtp(email)
status = otpStatus
if (result?.sent === true && 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} {signUpDisabled} />
<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>
{#if canChangeEmail}
<BottomActionComponent action={changeEmailAction} />
{/if}
{#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>