UBERF-10303: Always sign up with OTP (#8665)

This commit is contained in:
Alexey Zinoviev 2025-04-23 06:33:57 +04:00 committed by GitHub
parent 37c913f0fe
commit 26194a4dbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 116 additions and 73 deletions

View File

@ -251,6 +251,7 @@ export async function configurePlatform (): Promise<void> {
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL ?? '')
setMetadata(presentation.metadata.MailUrl, config.MAIL_URL)
setMetadata(recorder.metadata.StreamUrl, config.STREAM_URL ?? '')
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)

View File

@ -43,6 +43,7 @@ export interface Config {
PUBLIC_SCHEDULE_URL?: string
CALDAV_SERVER_URL?: string
EXPORT_URL?: string
MAIL_URL?: string
}
export interface Branding {

View File

@ -186,6 +186,7 @@ export interface Config {
PUBLIC_SCHEDULE_URL?: string
CALDAV_SERVER_URL?: string
EXPORT_URL?: string
MAIL_URL?: string
}
export interface Branding {
@ -428,6 +429,7 @@ export async function configurePlatform() {
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)
setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL)
setMetadata(presentation.metadata.MailUrl, config.MAIL_URL)
setMetadata(recorder.metadata.StreamUrl, config.STREAM_URL)
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR)

View File

@ -61,7 +61,7 @@ export interface AccountClient {
kind?: 'external' | 'internal' | 'byregion',
externalRegions?: string[]
) => Promise<WorkspaceLoginInfo>
validateOtp: (email: string, code: string) => Promise<LoginInfo>
validateOtp: (email: string, code: string, password?: string) => Promise<LoginInfo>
loginOtp: (email: string) => Promise<OtpInfo>
getLoginInfoByToken: () => Promise<LoginInfo | WorkspaceLoginInfo>
getLoginWithWorkspaceInfo: () => Promise<LoginInfoWithWorkspaces>
@ -98,6 +98,9 @@ export interface AccountClient {
getRegionInfo: () => Promise<RegionInfo[]>
createWorkspace: (name: string, region?: string) => Promise<WorkspaceLoginInfo>
signUpOtp: (email: string, first: string, last: string) => Promise<OtpInfo>
/**
* Deprecated. Only to be used for dev setups without mail service.
*/
signUp: (email: string, password: string, first: string, last: string) => Promise<LoginInfo>
login: (email: string, password: string) => Promise<LoginInfo>
getPerson: () => Promise<Person>
@ -283,10 +286,10 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async validateOtp (email: string, code: string): Promise<LoginInfo> {
async validateOtp (email: string, code: string, password?: string): Promise<LoginInfo> {
const request = {
method: 'validateOtp' as const,
params: { email, code }
params: { email, code, password }
}
return await this.rpc(request)

View File

@ -153,7 +153,8 @@ export default plugin(presentationId, {
PreviewConfig: '' as Metadata<PreviewConfig | undefined>,
ClientHook: '' as Metadata<ClientHook>,
SessionId: '' as Metadata<string>,
StatsUrl: '' as Metadata<string>
StatsUrl: '' as Metadata<string>,
MailUrl: '' as Metadata<string>
},
status: {
FileTooLarge: '' as StatusCode

View File

@ -70,6 +70,8 @@
"Hello": "Ahoj {name},",
"ProcessingInvite": "Zpracovávám pozvánku, čekejte prosím...",
"SignToProceed": "Přihlaste se, abyste mohli pokračovat",
"Proceed": "Pokračovat"
"Proceed": "Pokračovat",
"SetPasswordLater": "Nastavím heslo později",
"SetPasswordNow": "Nastavím heslo nyní"
}
}

View File

@ -70,6 +70,8 @@
"Hello": "Hallo {name},",
"ProcessingInvite": "Einladung wird bearbeitet, bitte warten...",
"SignToProceed": "Bitte melden Sie sich an, um fortzufahren",
"Proceed": "Fortfahren"
"Proceed": "Fortfahren",
"SetPasswordLater": "Ich werde später ein Passwort festlegen",
"SetPasswordNow": "Ich werde jetzt ein Passwort festlegen"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "Hello {name},",
"ProcessingInvite": "Processing invite, please wait...",
"SignToProceed": "Please sign in to proceed",
"Proceed": "Proceed"
"Proceed": "Proceed",
"SetPasswordLater": "I'll set a password later",
"SetPasswordNow": "I'll set a password now"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "Hola {name},",
"ProcessingInvite": "Procesando invitación, por favor espere...",
"SignToProceed": "Por favor inicie sesión para continuar",
"Proceed": "Continuar"
"Proceed": "Continuar",
"SetPasswordLater": "Estableceré una contraseña más tarde",
"SetPasswordNow": "Estableceré una contraseña ahora"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "Bonjour {name},",
"ProcessingInvite": "Invitation en cours, veuillez patienter...",
"SignToProceed": "Veuillez vous connecter pour continuer",
"Proceed": "Continuer"
"Proceed": "Continuer",
"SetPasswordLater": "Je définirai un mot de passe plus tard",
"SetPasswordNow": "Je définirai un mot de passe maintenant"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "Ciao {name},",
"ProcessingInvite": "Elaborazione invito, attendere prego...",
"SignToProceed": "Accedi per procedere",
"Proceed": "Procedere"
"Proceed": "Procedere",
"SetPasswordLater": "Imposterò una password più tardi",
"SetPasswordNow": "Imposterò una password ora"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "{name} さん、こんにちは",
"ProcessingInvite": "招待を処理中です。しばらくお待ちください...",
"SignToProceed": "続行するにはサインインしてください",
"Proceed": "続行"
"Proceed": "続行",
"SetPasswordLater": "後でパスワードを設定します",
"SetPasswordNow": "今すぐパスワードを設定します"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "Olá {name},",
"ProcessingInvite": "Processando convite, aguarde...",
"SignToProceed": "Por favor, inicie sessão para continuar",
"Proceed": "Continuar"
"Proceed": "Continuar",
"SetPasswordLater": "Vou definir uma senha mais tarde",
"SetPasswordNow": "Vou definir uma senha agora"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "Привет {name},",
"ProcessingInvite": "Обработка приглашения, пожалуйста, подождите...",
"SignToProceed": "Пожалуйста, войдите, чтобы продолжить",
"Proceed": "Продолжить"
"Proceed": "Продолжить",
"SetPasswordLater": "Я установлю пароль позже",
"SetPasswordNow": "Я установлю пароль сейчас"
}
}

View File

@ -69,6 +69,8 @@
"Hello": "你好 {name},",
"ProcessingInvite": "处理邀请,请稍候...",
"SignToProceed": "请登录以继续",
"Proceed": "继续"
"Proceed": "继续",
"SetPasswordLater": "稍后设置密码",
"SetPasswordNow": "现在设置密码"
}
}

View File

@ -15,6 +15,7 @@
export const LoginEvents = {
SignUpEmail: 'signup.viaEmail',
SignUpOtp: 'signup.viaOtp',
SignUpGoogle: 'signup.viaGoogle',
SignUpGithub: 'signup.viaGitHub',

View File

@ -55,6 +55,7 @@
export let page: Pages = 'signup'
const signUpDisabled = getMetadata(login.metadata.DisableSignUp) ?? false
const useOTP = getMetadata(presentation.metadata.MailUrl) != null && getMetadata(presentation.metadata.MailUrl) !== ''
let navigateUrl: string | undefined
onDestroy(location.subscribe(updatePageLoc))
@ -149,9 +150,9 @@
<Scroller padding={'1rem 0'}>
<div class="form-content">
{#if page === 'login'}
<LoginForm {navigateUrl} {signUpDisabled} />
<LoginForm {navigateUrl} {signUpDisabled} {useOTP} />
{:else if page === 'signup'}
<SignupForm {navigateUrl} {signUpDisabled} />
<SignupForm {navigateUrl} {signUpDisabled} {useOTP} />
{:else if page === 'createWorkspace'}
<CreateWorkspaceForm />
{:else if page === 'password'}

View File

@ -25,12 +25,13 @@
export let navigateUrl: string | undefined = undefined
export let signUpDisabled = false
export let useOTP = true
export let email: string | undefined = undefined
export let caption: IntlString | undefined = undefined
export let subtitle: string | undefined = undefined
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined
let method: LoginMethods = LoginMethods.Otp
let method: LoginMethods = useOTP ? LoginMethods.Otp : LoginMethods.Password
function changeMethod (event: CustomEvent<LoginMethods>): void {
method = event.detail

View File

@ -21,7 +21,7 @@
import { LoginInfo } from '@hcengineering/account-client'
import Tabs from './Tabs.svelte'
import { BottomAction, doLoginNavigate, validateOtpLogin, OtpLoginSteps, loginOtp } from '../index'
import { BottomAction, doLoginNavigate, doValidateOtp, OtpLoginSteps, loginOtp } from '../index'
import login from '../plugin'
import BottomActionComponent from './BottomAction.svelte'
import StatusControl from './StatusControl.svelte'
@ -32,6 +32,7 @@
export let signUpDisabled = false
export let loginState: 'login' | 'signup' | 'none' = 'none'
export let canChangeEmail = true
export let password: string | undefined = undefined
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined
const dispatch = createEventDispatcher()
@ -66,7 +67,7 @@
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)
const [loginStatus, result] = await doValidateOtp(loginState === 'signup', email, otp, password)
status = loginStatus
if (onLogin !== undefined) {

View File

@ -15,23 +15,24 @@
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { logIn } from '@hcengineering/workbench'
import BottomActionComponent from './BottomAction.svelte'
import login from '../plugin'
import { getPasswordValidationRules } from '../validations'
import { goTo, signUp } from '../utils'
import { goTo } from '../utils'
import Form from './Form.svelte'
import { BottomAction, LoginMethods, OtpLoginSteps, signUpOtp } from '../index'
import { OtpLoginSteps, signUp, signUpOtp } from '../index'
import type { Field } from '../types'
import OtpForm from './OtpForm.svelte'
export let signUpDisabled = false
export let navigateUrl: string | undefined = undefined
export let useOTP = true // False only for dev/tests
let method: LoginMethods = LoginMethods.Otp
let fields: Array<Field>
let form: Form
let withPassword = !useOTP
$: {
fields = [
@ -40,7 +41,7 @@
{ id: 'email', name: 'username', i18n: login.string.Email }
]
if (method === LoginMethods.Password) {
if (withPassword) {
fields.push({
id: 'new-password',
name: 'password',
@ -71,9 +72,17 @@
const action = {
i18n: login.string.SignUp,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
if (useOTP) {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
if (method === LoginMethods.Password) {
const [otpStatus, result] = await signUpOtp(object.username, object.first, object.last)
status = otpStatus
if (result?.sent === true && otpStatus === OK) {
step = OtpLoginSteps.Otp
otpRetryOn = result.retryOn
}
} else {
const [loginStatus, result] = await signUp(object.username, object.password, object.first, object.last)
status = loginStatus
@ -82,26 +91,17 @@
await logIn(result)
goTo('confirmationSend')
}
} else {
const [otpStatus, result] = await signUpOtp(object.username, object.first, object.last)
status = otpStatus
if (result?.sent === true && otpStatus === OK) {
step = OtpLoginSteps.Otp
otpRetryOn = result.retryOn
}
}
}
}
let changeMethodAction: BottomAction
$: changeMethodAction = {
i18n: method === LoginMethods.Password ? login.string.SignUpWithCode : login.string.SignUpWithPassword,
let withPasswordAction: BottomAction
$: withPasswordAction = {
i18n: withPassword ? login.string.SetPasswordLater : login.string.SetPasswordNow,
func: () => {
method = method === LoginMethods.Password ? LoginMethods.Otp : LoginMethods.Password
if (method === LoginMethods.Password) {
step = OtpLoginSteps.Email
}
withPassword = !withPassword
step = OtpLoginSteps.Email
setTimeout(() => {
if (form != null) {
form.invalidate()
@ -125,17 +125,27 @@
{signUpDisabled}
{navigateUrl}
loginState="signup"
password={object.password}
retryOn={otpRetryOn}
on:step={handleStep}
/>
{/if}
<div class="action">
<BottomActionComponent action={changeMethodAction} />
</div>
{#if useOTP}
<div class="action">
<BottomActionComponent action={withPasswordAction} />
</div>
{:else}
<div class="placeholder" />
{/if}
<style lang="scss">
.action {
margin-left: 5rem;
}
// TODO: Refactor me please
.placeholder {
height: 1.125rem;
}
</style>

View File

@ -73,6 +73,8 @@ export default mergeIds(loginId, login, {
Hello: '' as IntlString,
ProcessingInvite: '' as IntlString,
SignToProceed: '' as IntlString,
Proceed: '' as IntlString
Proceed: '' as IntlString,
SetPasswordLater: '' as IntlString,
SetPasswordNow: '' as IntlString
}
})

View File

@ -885,23 +885,29 @@ export async function loginOtp (email: string): Promise<[Status, OtpInfo | null]
}
}
export async function validateOtpLogin (email: string, code: string): Promise<[Status, LoginInfo | null]> {
export async function doValidateOtp (
isSignUp: boolean,
email: string,
code: string,
password?: string
): Promise<[Status, LoginInfo | null]> {
const telemetryEvent = isSignUp ? LoginEvents.SignUpOtp : LoginEvents.LoginOtp
try {
const loginInfo = await getAccountClient(null).validateOtp(email, code)
const loginInfo = await getAccountClient(null).validateOtp(email, code, password)
Analytics.handleEvent(LoginEvents.LoginOtp, { email, ok: true })
Analytics.handleEvent(telemetryEvent, { email, ok: true })
Analytics.setUser(email)
return [OK, loginInfo]
} catch (err: any) {
if (err instanceof PlatformError) {
Analytics.handleEvent(LoginEvents.LoginOtp, { email, ok: false })
Analytics.handleEvent(telemetryEvent, { email, ok: false })
await handleStatusError('Login with otp error', err.status)
return [err.status, null]
} else {
console.error('Login with otp error', err)
Analytics.handleEvent(LoginEvents.LoginOtp, { email, ok: false })
Analytics.handleEvent(telemetryEvent, { email, ok: false })
Analytics.handleError(err)
return [unknownError(err), null]
}

View File

@ -24,7 +24,6 @@ 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)

View File

@ -10,7 +10,6 @@ export class SignupPage {
readonly inputRepeatNewPassword: Locator
readonly buttonSignUp: Locator
readonly textError: Locator
readonly signUpPasswordBtn: Locator
constructor (page: Page) {
this.page = page
@ -21,15 +20,9 @@ export class SignupPage {
this.inputRepeatNewPassword = page.locator('input[name="new-password"]').nth(1)
this.buttonSignUp = page.locator('div.send button')
this.textError = page.locator('div.ERROR > span')
this.signUpPasswordBtn = page.locator('a', { hasText: 'Sign up with password' })
}
async signupPwd (userData: UserSignUp): Promise<void> {
const isOtp = await this.signUpPasswordBtn.isVisible()
if (isOtp) {
await this.signUpPasswordBtn.click()
}
await this.inputFirstName.fill(userData.firstName)
await this.inputLastName.fill(userData.lastName)
await this.inputEmail.fill(userData.email)

View File

@ -198,6 +198,8 @@ export async function loginOtp (
/**
* Given an email, password, first name, and last name, creates a new account and sends a confirmation email.
* The email confirmation is not required if the email service is not configured.
*
* ---------DEPRECATED. Only to be used for dev setups without mail service. Use signUpOtp instead.
*/
export async function signUp (
ctx: MeasureContext,
@ -286,9 +288,10 @@ export async function validateOtp (
params: {
email: string
code: string
password?: string
}
): Promise<LoginInfo> {
const { email, code } = params
const { email, code, password } = params
// Note: can support OTP based on any other social logins later
const normalizedEmail = cleanEmail(email)
@ -317,6 +320,9 @@ export async function validateOtp (
if (account == null) {
// This is a signup
await createAccount(db, emailSocialId.personUuid, true)
if (password != null) {
await setPassword(ctx, db, branding, emailSocialId.personUuid as AccountUuid, password)
}
ctx.info('OTP signup success', emailSocialId)
} else {

View File

@ -273,6 +273,7 @@ export function start (
pushPublicKey?: string
disableSignUp?: string
streamUrl?: string
mailUrl?: string
},
port: number,
extraConfig?: Record<string, string | undefined>
@ -346,6 +347,7 @@ export function start (
UPLOAD_CONFIG: config.uploadConfig,
PUSH_PUBLIC_KEY: config.pushPublicKey,
DISABLE_SIGNUP: config.disableSignUp,
MAIL_URL: config.mailUrl,
...(extraConfig ?? {})
}
res.status(200)

View File

@ -119,6 +119,8 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
const disableSignUp = process.env.DISABLE_SIGNUP
const mailUrl = process.env.MAIL_URL
const config = {
storageAdapter,
accountsUrl,
@ -139,7 +141,8 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
pushPublicKey,
disableSignUp,
linkPreviewUrl,
streamUrl
streamUrl,
mailUrl
}
console.log('Starting Front service with', config)
const shutdown = start(ctx, config, SERVER_PORT, extraConfig)

View File

@ -38,14 +38,12 @@ 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.checkIfLoginButtonIsDisabled()
await loginPage.loginWithPassword().click()
await loginPage.checkIfLoginButtonIsDisabled()
await loginPage.clickOnRecover()
await checkIfUrlContains(page, '/password')
await loginPage.checkIfPasswordRecoveryIsVisible()
await loginPage.clickOnRecoveryLogin()
await checkIfUrlContains(page, '/login')
await loginPage.loginWithPassword().click()
await loginPage.checkIfLoginButtonIsDisabled()
await loginPage.clickOnRecover()
await loginPage.clickOnRecoverySignUp()

View File

@ -12,7 +12,6 @@ export class LoginPage {
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' })
signUpWithPassword = (): Locator => this.page.locator('a', { hasText: 'Sign up with password' })
linkSignUp = (): Locator => this.page.locator('a.title', { hasText: 'Sign Up' })
invalidCredentialsMessage = (): Locator =>
this.page.getByText('Account not found or the provided credentials are incorrect')
@ -37,11 +36,8 @@ export class LoginPage {
await (await this.page.goto(`${PlatformURI}/login/admin`))?.finished()
}
async clickSignUp (usePassword: boolean = true): Promise<void> {
async clickSignUp (): Promise<void> {
await this.linkSignUp().click()
if (usePassword) {
await this.signUpWithPassword().click()
}
}
async clickOnRecover (): Promise<void> {
@ -57,7 +53,6 @@ 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)

View File

@ -10,7 +10,6 @@ export class SignUpPage extends CommonPage {
this.page = page
}
signUpPasswordBtn = (): Locator => this.page.locator('a', { hasText: 'Sign up with password' })
inputFirstName = (): Locator => this.page.locator('input[name="given-name"]')
inputLastName = (): Locator => this.page.locator('input[name="family-name"]')
inputEmail = (): Locator => this.page.locator('input[name="email"]')

View File

@ -89,9 +89,8 @@ test.describe('Workspace tests', () => {
const newWorkspaceName = `New Workspace Name - ${generateId(2)}`
await loginPage.goto()
await loginPage.clickSignUp(false)
await loginPage.clickSignUp()
await signUpPage.signUpPasswordBtn().click()
await signUpPage.checkInfo(page, 'Required field First name')
await signUpPage.enterFirstName(newUser.firstName)
await signUpPage.checkInfo(page, 'Required field Last name')

View File

@ -94,9 +94,8 @@ test.describe('Workspace tests', () => {
const newWorkspaceName = `New Workspace Name - ${generateId(2)}`
await loginPage.goto()
await loginPage.clickSignUp(false)
await loginPage.clickSignUp()
await signUpPage.signUpPasswordBtn().click()
await signUpPage.checkInfo(page, 'Required field First name')
await signUpPage.enterFirstName(newUser.firstName)
await signUpPage.checkInfo(page, 'Required field Last name')