UBER-221 Confirm registration (#3254)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-05-29 12:20:59 +06:00 committed by GitHub
parent 89ecba6b9a
commit 0d70ba8363
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 363 additions and 49 deletions

View File

@ -17,6 +17,7 @@
import {
ACCOUNT_DB,
assignWorkspace,
confirmEmail,
createAccount,
createWorkspace,
dropAccount,
@ -371,6 +372,16 @@ export function devTool (
)
})
program
.command('confirm-email <email>')
.description('confirm user email')
.action(async (email: string, cmd) => {
const { mongodbUri } = prepareTools()
return await withDatabase(mongodbUri, async (db) => {
await confirmEmail(db, email)
})
})
program
.command('diff-workspace <workspace>')
.description('restore workspace transactions and minio resources from previous dump.')

View File

@ -16,7 +16,7 @@
import { popupstore as modal } from '../popups'
import PopupInstance from './PopupInstance.svelte'
export let contentPanel: HTMLElement
export let contentPanel: HTMLElement | undefined = undefined
const instances: PopupInstance[] = []

View File

@ -27,7 +27,7 @@
export let zIndex: number
export let top: boolean
export let close: () => void
export let contentPanel: HTMLElement
export let contentPanel: HTMLElement | undefined
let modalHTML: HTMLElement
let componentInstance: any
@ -68,7 +68,11 @@
_close(undefined)
}
const fitPopup = (modalHTML: HTMLElement, element: PopupAlignment | undefined, contentPanel: HTMLElement): void => {
const fitPopup = (
modalHTML: HTMLElement,
element: PopupAlignment | undefined,
contentPanel: HTMLElement | undefined
): void => {
if ((fullSize || docSize) && (element === 'float' || element === 'centered')) {
options = fitPopupElement(modalHTML, 'full', contentPanel)
options.props.maxHeight = '100vh'

View File

@ -39,6 +39,8 @@
"InviteLimit": "Invite limit:",
"GetLink": "Get invite link",
"NoLimit": "No limit",
"AlreadyJoined": "Already joined?"
"AlreadyJoined": "Already joined?",
"ConfirmationSent": "A message has been sent to your email containing a link to confirm the your address.",
"ConfirmationSent2": "Please follow the link to complete your sign up."
}
}

View File

@ -39,6 +39,8 @@
"InviteLimit": "Предел использований:",
"GetLink": "Получить ссылку",
"NoLimit": "Без предела использований",
"AlreadyJoined": "Уже подключены?"
"AlreadyJoined": "Уже подключены?",
"ConfirmationSent": "На Вашу почту отправлено сообщение, c ссылкой для подтверждения email.",
"ConfirmationSent2": "Пожалуйста, перейдите по ссылке для завершения регистрации."
}
}

View File

@ -0,0 +1,50 @@
<!--
// Copyright © 2023 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, setMetadata, Severity, Status } from '@hcengineering/platform'
import { getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import login from '../plugin'
import { confirm } from '../utils'
import presentation from '@hcengineering/presentation'
import { onMount } from 'svelte'
let status: Status<any> = OK
async function check () {
const location = getCurrentLocation()
if (location.query?.id === undefined || location.query?.id === null) return
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await confirm(location.query?.id)
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
const loc = getCurrentLocation()
loc.query = undefined
loc.path[1] = 'selectWorkspace'
loc.path.length = 2
navigate(loc)
}
}
onMount(() => {
check()
})
</script>

View File

@ -0,0 +1,27 @@
<!--
// Copyright © 2023 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 login from '../plugin'
</script>
<div class="flex-center h-full p-10">
<div class="flex-col-center text-center">
<h4>
<Label label={login.string.ConfirmationSent} />
</h4>
<Label label={login.string.ConfirmationSent2} />
</div>
</div>

View File

@ -17,11 +17,12 @@
import { Status, Severity, OK, setMetadata } from '@hcengineering/platform'
import Form from './Form.svelte'
import { createWorkspace } from '../utils'
import { createWorkspace, getAccount } from '../utils'
import { fetchMetadataLocalStorage, getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import login from '../plugin'
import { workbenchId } from '@hcengineering/workbench'
import presentation from '@hcengineering/presentation'
import { onMount } from 'svelte'
const fields = [
{
@ -38,6 +39,16 @@
let status: Status<any> = OK
onMount(async () => {
const account = await getAccount()
if (account?.confirmed !== true) {
const loc = getCurrentLocation()
loc.path[1] = 'confirmationSend'
loc.path.length = 2
navigate(loc)
}
})
const action = {
i18n: login.string.CreateWorkspace,
func: async () => {

View File

@ -27,6 +27,8 @@
import { getMetadata } from '@hcengineering/platform'
import PasswordRequest from './PasswordRequest.svelte'
import PasswordRestore from './PasswordRestore.svelte'
import Confirmation from './Confirmation.svelte'
import ConfirmationSend from './ConfirmationSend.svelte'
export let page: string = 'login'
@ -68,6 +70,10 @@
<SelectWorkspace {navigateUrl} />
{:else if page === 'join'}
<Join />
{:else if page === 'confirm'}
<Confirmation />
{:else if page === 'confirmationSend'}
<ConfirmationSend />
{/if}
</div>
<Intro landscape={$deviceInfo.docWidth <= 768} mini={$deviceInfo.docWidth <= 480} />

View File

@ -75,7 +75,7 @@
}
}
const loc = getCurrentLocation()
loc.path[1] = 'selectWorkspace'
loc.path[1] = result.confirmed ? 'selectWorkspace' : 'confirmationSend'
loc.path.length = 2
if (navigateUrl !== undefined) {
loc.query = { ...loc.query, navigateUrl }

View File

@ -26,14 +26,19 @@
setMetadataLocalStorage
} from '@hcengineering/ui'
import login from '../plugin'
import { getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
import { getAccount, getWorkspaces, navigateToWorkspace, selectWorkspace } from '../utils'
import StatusControl from './StatusControl.svelte'
import { Workspace } from '@hcengineering/login'
import { LoginInfo, Workspace } from '@hcengineering/login'
import { onMount } from 'svelte'
export let navigateUrl: string | undefined = undefined
let status = OK
let account: LoginInfo | undefined = undefined
onMount(async () => (account = await getAccount()))
async function select (workspace: string) {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
@ -88,7 +93,7 @@
{workspace.workspace}
</div>
{/each}
{#if !workspaces.length}
{#if !workspaces.length && account?.confirmed === true}
<div class="form-row send">
<Button label={login.string.CreateWorkspace} kind={'primary'} width="100%" on:click={createWorkspace} />
</div>

View File

@ -14,13 +14,12 @@
// limitations under the License.
-->
<script lang="ts">
import { Status, Severity, OK, setMetadata } from '@hcengineering/platform'
import { OK, Severity, Status } from '@hcengineering/platform'
import Form from './Form.svelte'
import { signUp } from '../utils'
import { getCurrentLocation, navigate } from '@hcengineering/ui'
import login from '../plugin'
import { getCurrentLocation, navigate, setMetadataLocalStorage } from '@hcengineering/ui'
import presentation from '@hcengineering/presentation'
import { signUp } from '../utils'
import Form from './Form.svelte'
const fields = [
{ id: 'given-name', name: 'first', i18n: login.string.FirstName, short: true },
@ -50,11 +49,8 @@
status = loginStatus
if (result !== undefined) {
setMetadata(presentation.metadata.Token, result.token)
setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint)
setMetadataLocalStorage(login.metadata.LoginEmail, result.email)
const loc = getCurrentLocation()
loc.path[1] = 'selectWorkspace'
loc.path[1] = 'confirmationSend'
loc.path.length = 2
navigate(loc)
}

View File

@ -56,6 +56,8 @@ export default mergeIds(loginId, login, {
RecoveryLinkSent: '' as IntlString,
UseWorkspaceInviteSettings: '' as IntlString,
GetLink: '' as IntlString,
AlreadyJoined: '' as IntlString
AlreadyJoined: '' as IntlString,
ConfirmationSent: '' as IntlString,
ConfirmationSent2: '' as IntlString
}
})

View File

@ -49,7 +49,7 @@ export async function doLogin (email: string, password: string): Promise<[Status
if (token !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token, endpoint, email }]
return [OK, { token, endpoint, email, confirmed: true }]
}
}
@ -91,7 +91,7 @@ export async function signUp (
if (token !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token, endpoint, email }]
return [OK, { token, endpoint, email, confirmed: true }]
}
}
@ -127,7 +127,7 @@ export async function createWorkspace (workspace: string): Promise<[Status, Logi
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token: overrideToken, endpoint, email }]
return [OK, { token: overrideToken, endpoint, email, confirmed: true }]
}
}
@ -213,6 +213,53 @@ export async function getWorkspaces (): Promise<Workspace[]> {
}
}
export async function getAccount (): Promise<LoginInfo | undefined> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
const overrideToken = getMetadata(login.metadata.OverrideLoginToken)
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
const email = fetchMetadataLocalStorage(login.metadata.LoginEmail) ?? ''
if (endpoint !== undefined) {
return { token: overrideToken, endpoint, email, confirmed: true }
}
}
const token = getMetadata(presentation.metadata.Token)
if (token === undefined) {
const loc = getCurrentLocation()
loc.path[1] = 'login'
loc.path.length = 2
navigate(loc)
return
}
const request = {
method: 'getAccountInfoByToken',
params: [] as any[]
}
try {
const response = await fetch(accountsUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
const result = await response.json()
if (result.error != null) {
throw new PlatformError(result.error)
}
return result.result
} catch (err) {}
}
export async function selectWorkspace (workspace: string): Promise<[Status, WorkspaceLoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
@ -225,7 +272,7 @@ export async function selectWorkspace (workspace: string): Promise<[Status, Work
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token: overrideToken, endpoint, email, workspace }]
return [OK, { token: overrideToken, endpoint, email, workspace, confirmed: true }]
}
}
@ -306,7 +353,7 @@ export async function checkJoined (inviteId: string): Promise<[Status, Workspace
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token: overrideToken, endpoint, email, workspace: DEV_WORKSPACE }]
return [OK, { token: overrideToken, endpoint, email, workspace: DEV_WORKSPACE, confirmed: true }]
}
}
@ -390,7 +437,7 @@ export async function join (
if (token !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token, endpoint, email, workspace: DEV_WORKSPACE }]
return [OK, { token, endpoint, email, workspace: DEV_WORKSPACE, confirmed: true }]
}
}
@ -431,7 +478,7 @@ export async function signUpJoin (
if (token !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token, endpoint, email, workspace: DEV_WORKSPACE }]
return [OK, { token, endpoint, email, workspace: DEV_WORKSPACE, confirmed: true }]
}
}
@ -617,6 +664,40 @@ export async function requestPassword (email: string): Promise<Status> {
}
}
export async function confirm (email: string): Promise<[Status, LoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
if (accountsUrl === undefined) {
throw new Error('accounts url not specified')
}
const overrideToken = getMetadata(login.metadata.OverrideLoginToken)
if (overrideToken !== undefined) {
const endpoint = getMetadata(login.metadata.OverrideEndpoint)
if (endpoint !== undefined) {
return [OK, { token: overrideToken, endpoint, email, confirmed: true }]
}
}
const request = {
method: 'confirm',
params: [email]
}
try {
const response = await fetch(accountsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
const result = await response.json()
return [result.error ?? OK, result.result]
} catch (err) {
return [unknownError(err), undefined]
}
}
export async function restorePassword (token: string, password: string): Promise<[Status, LoginInfo | undefined]> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)

View File

@ -42,6 +42,7 @@ export interface WorkspaceLoginInfo extends LoginInfo {
export interface LoginInfo {
token: string
endpoint: string
confirmed: boolean
email: string
}

View File

@ -112,6 +112,7 @@ export interface Account {
workspaces: ObjectId[]
// Defined for server admins only
admin?: boolean
confirmed?: boolean
}
/**
@ -222,6 +223,15 @@ async function getAccountInfo (db: Db, email: string, password: string): Promise
return toAccountInfo(account)
}
async function getAccountInfoByToken (db: Db, productId: string, token: string): Promise<AccountInfo> {
const { email } = decodeToken(token)
const account = await getAccount(db, email)
if (account === null) {
throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: email }))
}
return toAccountInfo(account)
}
/**
* @public
* @param db -
@ -236,19 +246,22 @@ export async function login (db: Db, productId: string, email: string, password:
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId('', productId), getAdminExtra(info))
confirmed: info.confirmed ?? true,
token: generateToken(email, getWorkspaceId('', productId), getExtra(info))
}
return result
}
/**
* Will add admin=='true' in case of user is server admin
* Will add extra props
*/
function getAdminExtra (
info: Account | AccountInfo | null,
rec?: Record<string, string>
): Record<string, string> | undefined {
return info?.admin === true ? { ...rec, admin: 'true' } : rec
function getExtra (info: Account | AccountInfo | null, rec?: Record<string, any>): Record<string, any> | undefined {
const res = rec ?? {}
if (info?.admin === true) {
res.admin = 'true'
}
res.confirmed = info?.confirmed ?? true
return res
}
/**
@ -270,7 +283,7 @@ export async function selectWorkspace (
return {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId(workspace, productId), getAdminExtra(accountInfo)),
token: generateToken(email, getWorkspaceId(workspace, productId), getExtra(accountInfo)),
workspace,
productId
}
@ -286,7 +299,7 @@ export async function selectWorkspace (
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId(workspace, productId), getAdminExtra(accountInfo)),
token: generateToken(email, getWorkspaceId(workspace, productId), getExtra(accountInfo)),
workspace,
productId
}
@ -349,6 +362,80 @@ export async function join (
return result
}
/**
* @public
*/
export async function confirmEmail (db: Db, email: string): Promise<Account> {
const account = await getAccount(db, email)
if (account === null) {
throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: accountId }))
}
await db.collection(ACCOUNT_COLLECTION).updateOne({ _id: account._id }, { $set: { confirmed: true } })
account.confirmed = true
return account
}
/**
* @public
*/
export async function confirm (db: Db, productId: string, token: string): Promise<LoginInfo> {
const decode = decodeToken(token)
const email = decode.extra?.confirm
if (email === undefined) {
throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: accountId }))
}
const account = await confirmEmail(db, email)
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId('', productId), getExtra(account))
}
return result
}
async function sendConfirmation (productId: string, account: Account): Promise<void> {
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
if (sesURL === undefined || sesURL === '') {
throw new Error('Please provide email service url')
}
const front = getMetadata(accountPlugin.metadata.FrontURL)
if (front === undefined || front === '') {
throw new Error('Please provide front url')
}
const token = generateToken(
'@confirm',
getWorkspaceId('', productId),
getExtra(account, {
confirm: account.email
})
)
const link = concatLink(front, `/login/confirm?id=${token}`)
const text = `To confirm your email, please paste the following link in your web browser's address bar: ${link}. If you did not make this request, please ignore this email.`
const html = `<p>To confirm your email, please click the link below: <a href=${link}>Confirm Your Email</a></p><p>
If the link above does not work, paste the following link in your web browser's address bar: ${link}
</p><p>If you did not make this request, please ignore this email.</p>`
const subject = 'Confirm Your Email'
const to = account.email
await fetch(concatLink(sesURL, '/send'), {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text,
html,
subject,
to
})
})
}
/**
* @public
*/
@ -363,7 +450,7 @@ export async function signUpJoin (
): Promise<WorkspaceLoginInfo> {
const invite = await getInvite(db, inviteId)
const workspace = await checkInvite(invite, email)
await createAccount(db, productId, email, password, first, last)
await createAcc(db, productId, email, password, first, last, invite?.emailMask === email)
await assignWorkspace(db, productId, email, workspace.name)
const token = (await login(db, productId, email, password)).token
@ -372,17 +459,15 @@ export async function signUpJoin (
return result
}
/**
* @public
*/
export async function createAccount (
async function createAcc (
db: Db,
productId: string,
email: string,
password: string,
first: string,
last: string
): Promise<LoginInfo> {
last: string,
confirmed: boolean = false
): Promise<Account> {
const salt = randomBytes(32)
const hash = hashWithSalt(password, salt)
@ -402,13 +487,37 @@ export async function createAccount (
salt,
first,
last,
confirmed,
workspaces: []
})
const newAccount = await getAccount(db, email)
if (newAccount === null) {
throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountAlreadyExists, { account: email }))
}
if (!confirmed) {
await sendConfirmation(productId, newAccount)
}
return newAccount
}
/**
* @public
*/
export async function createAccount (
db: Db,
productId: string,
email: string,
password: string,
first: string,
last: string
): Promise<LoginInfo> {
const account = await createAcc(db, productId, email, password, first, last, false)
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId('', productId), getAdminExtra(account))
token: generateToken(email, getWorkspaceId('', productId), getExtra(account))
}
return result
}
@ -493,7 +602,10 @@ export async function upgradeWorkspace (
export const createUserWorkspace =
(version: Data<Version>, txes: Tx[], migrationOperation: [string, MigrateOperation][]) =>
async (db: Db, productId: string, token: string, workspace: string): Promise<LoginInfo> => {
const { email } = decodeToken(token)
const { email, extra } = decodeToken(token)
if (extra?.confirmed === false) {
throw new PlatformError(new Status(Severity.ERROR, accountPlugin.status.AccountNotFound, { account: email }))
}
await createWorkspace(version, txes, migrationOperation, db, productId, workspace, '')
await assignWorkspace(db, productId, email, workspace)
await setRole(email, workspace, productId, AccountRole.Owner)
@ -501,7 +613,7 @@ export const createUserWorkspace =
const result = {
endpoint: getEndpoint(),
email,
token: generateToken(email, getWorkspaceId(workspace, productId), getAdminExtra(info)),
token: generateToken(email, getWorkspaceId(workspace, productId), getExtra(info)),
productId
}
return result
@ -736,7 +848,7 @@ export async function requestPassword (db: Db, productId: string, email: string)
const token = generateToken(
'@restore',
getWorkspaceId('', productId),
getAdminExtra(account, {
getExtra(account, {
restore: email
})
)
@ -1069,7 +1181,9 @@ export function getMethods (
changePassword: wrap(changePassword),
requestPassword: wrap(requestPassword),
restorePassword: wrap(restorePassword),
sendInvite: wrap(sendInvite)
sendInvite: wrap(sendInvite),
confirm: wrap(confirm),
getAccountInfoByToken: wrap(getAccountInfoByToken)
// updateAccount: wrap(updateAccount)
}
}

View File

@ -9,7 +9,7 @@ import serverPlugin from './plugin'
export interface Token {
email: string
workspace: WorkspaceId
extra?: Record<string, string>
extra?: Record<string, any>
}
const getSecret = (): string => {

View File

@ -11,6 +11,7 @@ export SERVER_SECRET=secret
node ../dev/tool/bundle.js create-workspace sanity-ws -o SanityTest
# Create user record in accounts
node ../dev/tool/bundle.js create-account user1 -f John -l Appleseed -p 1234
node ../dev/tool/bundle.js confirm-email user1
# Restore workspace contents in mongo/elastic

View File

@ -8,5 +8,6 @@ docker-compose -p sanity up -d --force-recreate --renew-anon-volumes
./tool.sh create-workspace sanity-ws -o SanityTest
# Create user record in accounts
./tool.sh create-account user1 -f John -l Appleseed -p 1234
./tool.sh confirm-email user1
./restore-workspace.sh