UBERF-9636: Meeting links (#8334)

This commit is contained in:
Alexey Zinoviev 2025-03-25 10:15:09 +04:00 committed by GitHub
parent d74f1bf847
commit edda71fef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 856 additions and 33 deletions

View File

@ -64,8 +64,9 @@ export interface AccountClient {
inviteId: string
) => Promise<WorkspaceLoginInfo>
join: (email: string, password: string, inviteId: string) => Promise<WorkspaceLoginInfo>
createInviteLink: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise<string>
createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise<string>
checkJoin: (inviteId: string) => Promise<WorkspaceLoginInfo>
checkAutoJoin: (inviteId: string, firstName: string, lastName?: string) => Promise<WorkspaceLoginInfo>
getWorkspaceInfo: (updateLastVisit?: boolean) => Promise<WorkspaceInfoWithStatus>
getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise<WorkspaceInfoWithStatus[]>
getRegionInfo: () => Promise<RegionInfo[]>
@ -346,9 +347,9 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async createInviteLink (exp: number, emailMask: string, limit: number, role: AccountRole): Promise<string> {
async createInvite (exp: number, emailMask: string, limit: number, role: AccountRole): Promise<string> {
const request = {
method: 'createInviteLink' as const,
method: 'createInvite' as const,
params: { exp, emailMask, limit, role }
}
@ -364,6 +365,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async checkAutoJoin (inviteId: string, firstName: string, lastName?: string): Promise<WorkspaceLoginInfo> {
const request = {
method: 'checkAutoJoin' as const,
params: { inviteId, firstName, lastName }
}
return await this.rpc(request)
}
async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise<WorkspaceInfoWithStatus[]> {
const request = {
method: 'getWorkspacesInfo' as const,

View File

@ -26,6 +26,11 @@ export interface WorkspaceLoginInfo extends LoginInfo {
role: AccountRole
}
export interface WorkspaceInviteInfo {
workspace: WorkspaceUuid
email?: string
}
export interface OtpInfo {
sent: boolean
retryOn: Timestamp

View File

@ -0,0 +1,108 @@
<!--
// Copyright © 2025 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 { onMount } from 'svelte'
import { Analytics } from '@hcengineering/analytics'
import { workbenchId } from '@hcengineering/workbench'
import { OK, Severity, Status } from '@hcengineering/platform'
import { Location, getCurrentLocation, navigate } from '@hcengineering/ui'
import { loginId } from '@hcengineering/login'
import Form from './Form.svelte'
import { setLoginInfo, checkAutoJoin, isWorkspaceLoginInfo } from '../utils'
import { loginAction, recoveryAction } from '../actions'
import login from '../plugin'
const location = getCurrentLocation()
Analytics.handleEvent('auto_join_invite_link_activated')
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email, disabled: true },
{
id: 'current-password',
name: 'password',
i18n: login.string.Password,
password: true
}
]
$: object = {
username: '',
password: ''
}
let status = OK
$: action = {
i18n: login.string.Join,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
await check()
}
}
onMount(() => {
void check()
})
async function check (): Promise<void> {
if (location.query?.inviteId == null || location.query?.firstName == null) return
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [, result] = await checkAutoJoin(
location.query.inviteId,
location.query.firstName,
location.query.lastName ?? ''
)
status = OK
if (result != null) {
if (isWorkspaceLoginInfo(result)) {
setLoginInfo(result)
if (location.query?.navigateUrl != null) {
try {
const loc = JSON.parse(decodeURIComponent(location.query.navigateUrl)) as Location
if (loc.path[1] === result.workspaceUrl) {
navigate(loc)
return
}
} catch (err: any) {
// Json parse error could be ignored
}
}
navigate({ path: [workbenchId, result.workspaceUrl] })
} else {
if (result.email == null) {
console.error('No email in auto join info')
navigate({ path: [loginId, 'login'] })
return
}
object.username = result.email
}
}
}
</script>
<Form
caption={login.string.Join}
{status}
{fields}
{object}
{action}
bottomActions={[loginAction, recoveryAction]}
withProviders
/>

View File

@ -154,6 +154,7 @@
label={field.i18n}
name={field.id}
password={field.password}
disabled={field.disabled}
bind:value={object[field.name]}
on:input={() => validate($themeStore.language)}
on:blur={() => {

View File

@ -114,6 +114,7 @@
async function check (): Promise<void> {
if (location.query?.inviteId === undefined || location.query?.inviteId === null) return
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [, result] = await checkJoined(location.query.inviteId)
status = OK
if (result != null) {

View File

@ -34,6 +34,7 @@
import ConfirmationSend from './ConfirmationSend.svelte'
import CreateWorkspaceForm from './CreateWorkspaceForm.svelte'
import Join from './Join.svelte'
import AutoJoin from './AutoJoin.svelte'
import LoginForm from './LoginForm.svelte'
import PasswordRequest from './PasswordRequest.svelte'
import PasswordRestore from './PasswordRestore.svelte'
@ -61,12 +62,17 @@
function updatePageLoc (loc: Location): void {
const token = getMetadata(presentation.metadata.Token)
page = (loc.path[1] as Pages) ?? (token != null ? 'selectWorkspace' : 'login')
if (page === 'join' && loc.query?.autoJoin !== undefined) {
page = 'autoJoin'
}
const allowedUnauthPages: Pages[] = [
'login',
'signup',
'password',
'recovery',
'join',
'autoJoin',
'confirm',
'confirmationSend',
'auth'
@ -153,6 +159,8 @@
<SelectWorkspace {navigateUrl} />
{:else if page === 'join'}
<Join />
{:else if page === 'autoJoin'}
<AutoJoin />
{:else if page === 'confirm'}
<Confirmation />
{:else if page === 'confirmationSend'}

View File

@ -22,6 +22,7 @@ export interface Field {
password?: boolean
optional?: boolean
short?: boolean
disabled?: boolean
rules?: Array<{
rule: RegExp | ((value: string) => boolean)
notMatch: boolean

View File

@ -13,7 +13,14 @@
// limitations under the License.
//
import type { AccountClient, LoginInfo, OtpInfo, RegionInfo, WorkspaceLoginInfo } from '@hcengineering/account-client'
import type {
AccountClient,
LoginInfo,
OtpInfo,
RegionInfo,
WorkspaceLoginInfo,
WorkspaceInviteInfo
} from '@hcengineering/account-client'
import { getClient as getAccountClientRaw } from '@hcengineering/account-client'
import { Analytics } from '@hcengineering/analytics'
import {
@ -503,6 +510,27 @@ export async function checkJoined (inviteId: string): Promise<[Status, Workspace
}
}
export async function checkAutoJoin (
inviteId: string,
firstName: string,
lastName?: string
): Promise<[Status, WorkspaceInviteInfo | WorkspaceLoginInfo | null]> {
const token = getMetadata(presentation.metadata.Token)
try {
const autoJoinResult = await getAccountClient(token).checkAutoJoin(inviteId, firstName, lastName)
return [OK, autoJoinResult]
} catch (err: any) {
if (err instanceof PlatformError) {
return [err.status, null]
} else {
Analytics.handleError(err)
return [unknownError(err), null]
}
}
}
export async function getInviteLink (
expHours: number,
mask: string,
@ -548,7 +576,7 @@ export async function getInviteLinkId (
return ''
}
const inviteLink = await getAccountClient(token).createInviteLink(exp, emailMask, limit, role)
const inviteLink = await getAccountClient(token).createInvite(exp, emailMask, limit, role)
Analytics.handleEvent('Get invite link')
@ -900,8 +928,10 @@ export async function doLoginNavigate (
}
}
export function isWorkspaceLoginInfo (info: WorkspaceLoginInfo | LoginInfo | null): info is WorkspaceLoginInfo {
return (info as any)?.workspace !== undefined
export function isWorkspaceLoginInfo (
info: WorkspaceLoginInfo | LoginInfo | WorkspaceInviteInfo | null
): info is WorkspaceLoginInfo {
return (info as any)?.workspace !== undefined && (info as any)?.token !== undefined
}
export function getAccountDisplayName (loginInfo: LoginInfo | null): string {

View File

@ -35,6 +35,7 @@ export const pages = [
'selectWorkspace',
'admin',
'join',
'autoJoin',
'confirm',
'confirmationSend',
'auth',

View File

@ -0,0 +1,457 @@
//
// Copyright © 2025 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.
//
// src/__tests__/operations.test.ts
import { AccountRole, MeasureContext, PersonUuid, WorkspaceUuid } from '@hcengineering/core'
import platform, { PlatformError, Status, Severity, getMetadata } from '@hcengineering/platform'
import { decodeTokenVerbose } from '@hcengineering/server-token'
import { AccountDB } from '../types'
import { createInvite, createInviteLink, sendInvite, resendInvite } from '../operations'
import { accountPlugin } from '../plugin'
// Mock platform
jest.mock('@hcengineering/platform', () => {
const actual = jest.requireActual('@hcengineering/platform')
return {
...actual,
...actual.default,
getMetadata: jest.fn(),
translate: jest.fn((id, params) => `${id} << ${JSON.stringify(params)}`)
}
})
// Mock server-token
jest.mock('@hcengineering/server-token', () => ({
decodeTokenVerbose: jest.fn(),
generateToken: jest.fn()
}))
describe('invite operations', () => {
const mockCtx = {
error: jest.fn(),
info: jest.fn()
} as unknown as MeasureContext
const mockBranding = null
const mockDb = {
account: {
findOne: jest.fn()
},
workspace: {
findOne: jest.fn()
},
invite: {
insertOne: jest.fn(),
findOne: jest.fn(),
updateOne: jest.fn()
},
getWorkspaceRole: jest.fn(),
person: {
findOne: jest.fn()
}
} as unknown as AccountDB
const mockToken = 'test-token'
const mockAccount = { uuid: 'account-uuid' as PersonUuid }
const mockWorkspace = {
uuid: 'workspace-uuid' as WorkspaceUuid,
name: 'Test Workspace',
url: 'test-workspace'
}
beforeEach(() => {
jest.clearAllMocks()
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: {}
})
})
describe('createInvite', () => {
test('should create invite for authorized maintainer', async () => {
const inviteId = 'new-invite-id'
const beforeTest = Date.now()
const expectedExpiration = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const result = await createInvite(mockCtx, mockDb, mockBranding, mockToken, {
exp: expectedExpiration,
email: 'test@example.com',
limit: 1,
role: AccountRole.User
})
const afterTest = Date.now()
expect(result).toBe(inviteId)
expect(mockDb.invite.insertOne).toHaveBeenCalledWith({
workspaceUuid: mockWorkspace.uuid,
expiresOn: expect.any(Number),
email: 'test@example.com',
remainingUses: 1,
role: AccountRole.User,
autoJoin: undefined
})
// Get the actual expiresOn value from the call
const actualCall = (mockDb.invite.insertOne as jest.Mock).mock.calls[0][0]
expect(actualCall.expiresOn).toBeGreaterThanOrEqual(beforeTest + expectedExpiration)
expect(actualCall.expiresOn).toBeLessThanOrEqual(afterTest + expectedExpiration)
})
test('should throw error if caller role is insufficient', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.User)
await expect(
createInvite(mockCtx, mockDb, mockBranding, mockToken, {
exp: 24 * 60 * 60 * 1000,
email: 'test@example.com',
limit: 1,
role: AccountRole.Owner
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
})
})
describe('createInviteLink', () => {
const frontUrl = 'https://app.example.com'
beforeEach(() => {
;(getMetadata as jest.Mock).mockImplementation((key) => {
if (key === accountPlugin.metadata.FrontURL) return frontUrl
return undefined
})
})
test('should create basic invite link', async () => {
const inviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User
})
expect(result).toBe(`${frontUrl}/login/join?inviteId=${inviteId}`)
})
test('should create link with auto-join parameters', async () => {
const inviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'schedule' }
})
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true,
firstName: 'John',
lastName: 'Doe'
})
expect(result).toBe(`${frontUrl}/login/join?inviteId=${inviteId}&autoJoin&firstName=John&lastName=Doe`)
})
test('should create link with redirect parameter', async () => {
const inviteId = 'new-invite-id'
const navigateUrl = '/workspace/calendar'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
navigateUrl
})
expect(result).toBe(`${frontUrl}/login/join?inviteId=${inviteId}&navigateUrl=${encodeURIComponent(navigateUrl)}`)
})
test('should create link with all parameters', async () => {
const inviteId = 'new-invite-id'
const navigateUrl = '/workspace/settings?tab=members'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'schedule' }
})
const result = await createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true,
firstName: 'John',
lastName: 'Doe',
navigateUrl
})
expect(result).toBe(
`${frontUrl}/login/join?inviteId=${inviteId}&autoJoin&firstName=John&lastName=Doe&navigateUrl=${encodeURIComponent(navigateUrl)}`
)
})
// Negative scenarios
test('should throw error for auto-join without firstName', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'schedule' }
})
await expect(
createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))
})
test('should throw error for auto-join without schedule service', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: mockAccount.uuid,
workspace: mockWorkspace.uuid,
extra: { service: 'other' }
})
await expect(
createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User,
autoJoin: true,
firstName: 'John'
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
})
test('should throw error for insufficient role', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Guest)
await expect(
createInviteLink(mockCtx, mockDb, mockBranding, mockToken, {
email: 'test@example.com',
role: AccountRole.User
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
})
})
describe('sendInvite', () => {
const mockEmail = 'test@example.com'
const sesUrl = 'https://ses.example.com'
const sesAuth = 'test-auth-token'
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({ ok: true })
;(getMetadata as jest.Mock).mockImplementation((key) => {
switch (key) {
case accountPlugin.metadata.MAIL_URL:
return sesUrl
case accountPlugin.metadata.MAIL_AUTH_TOKEN:
return sesAuth
case accountPlugin.metadata.FrontURL:
return 'https://app.example.com'
default:
return undefined
}
})
})
test('should send invite email with correct parameters', async () => {
const inviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
await sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User,
expHours: 48
})
expect(global.fetch).toHaveBeenCalledWith(`${sesUrl}/send`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${sesAuth}`
},
body: expect.stringContaining(mockEmail)
})
// Verify invite was created with correct parameters
expect(mockDb.invite.insertOne).toHaveBeenCalledWith(
expect.objectContaining({
email: mockEmail,
remainingUses: 1,
role: AccountRole.User,
expiresOn: expect.any(Number)
})
)
})
test('should throw error if caller has insufficient role', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Guest)
await expect(
sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User
})
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
expect(global.fetch).not.toHaveBeenCalled()
})
test('should throw error if workspace not found', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(null)
await expect(
sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User
})
).rejects.toThrow(
new PlatformError(
new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: mockWorkspace.uuid })
)
)
expect(global.fetch).not.toHaveBeenCalled()
})
test('should throw error if account not found', async () => {
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(null)
await expect(
sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User
})
).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: mockAccount.uuid }))
)
expect(global.fetch).not.toHaveBeenCalled()
})
test('should use custom expiration hours', async () => {
const inviteId = 'new-invite-id'
const customExpHours = 72
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(inviteId)
const beforeTest = Date.now()
await sendInvite(mockCtx, mockDb, mockBranding, mockToken, {
email: mockEmail,
role: AccountRole.User,
expHours: customExpHours
})
const afterTest = Date.now()
const actualCall = (mockDb.invite.insertOne as jest.Mock).mock.calls[0][0]
const expectedExpiration = customExpHours * 60 * 60 * 1000
// Check if expiration time is within the expected range
const minExpected = beforeTest + expectedExpiration
const maxExpected = afterTest + expectedExpiration
expect(actualCall.expiresOn).toBeGreaterThanOrEqual(minExpected - 1) // Allow 1ms tolerance
expect(actualCall.expiresOn).toBeLessThanOrEqual(maxExpected)
})
})
describe('resendInvite', () => {
const mockEmail = 'test@example.com'
test('should resend existing invite', async () => {
const existingInvite = {
id: 'existing-invite-id',
workspaceUuid: mockWorkspace.uuid,
email: mockEmail
}
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.findOne as jest.Mock).mockResolvedValue(existingInvite)
global.fetch = jest.fn().mockResolvedValue({ ok: true })
await resendInvite(mockCtx, mockDb, mockBranding, mockToken, mockEmail, AccountRole.User)
expect(mockDb.invite.updateOne).toHaveBeenCalledWith(
{ id: existingInvite.id },
expect.objectContaining({
expiresOn: expect.any(Number),
remainingUses: 1,
role: AccountRole.User
})
)
expect(global.fetch).toHaveBeenCalled()
})
test('should create new invite if none exists', async () => {
const newInviteId = 'new-invite-id'
;(mockDb.account.findOne as jest.Mock).mockResolvedValue(mockAccount)
;(mockDb.workspace.findOne as jest.Mock).mockResolvedValue(mockWorkspace)
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Maintainer)
;(mockDb.invite.findOne as jest.Mock).mockResolvedValue(null)
;(mockDb.invite.insertOne as jest.Mock).mockResolvedValue(newInviteId)
global.fetch = jest.fn().mockResolvedValue({ ok: true })
await resendInvite(mockCtx, mockDb, mockBranding, mockToken, mockEmail, AccountRole.User)
expect(mockDb.invite.insertOne).toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalled()
})
})
})

View File

@ -631,7 +631,13 @@ export class PostgresAccountDB implements AccountDB {
}
protected getMigrations (): [string, string][] {
return [this.getV1Migration(), this.getV2Migration1(), this.getV2Migration2(), this.getV2Migration3()]
return [
this.getV1Migration(),
this.getV2Migration1(),
this.getV2Migration2(),
this.getV2Migration3(),
this.getV3Migration()
]
}
// NOTE: NEVER MODIFY EXISTING MIGRATIONS. IF YOU NEED TO ADJUST THE SCHEMA, ADD A NEW MIGRATION.
@ -830,4 +836,15 @@ export class PostgresAccountDB implements AccountDB {
`
]
}
private getV3Migration (): [string, string] {
return [
'account_db_v3_add_invite_auto_join',
`
ALTER TABLE ${this.ns}.invite
ADD COLUMN IF NOT EXISTS email STRING,
ADD COLUMN IF NOT EXISTS auto_join BOOL DEFAULT FALSE;
`
]
}
}

View File

@ -57,6 +57,7 @@ import type {
Workspace,
WorkspaceEvent,
WorkspaceInfoWithStatus,
WorkspaceInviteInfo,
WorkspaceLoginInfo,
WorkspaceOperation,
WorkspaceStatus
@ -392,20 +393,22 @@ export async function createWorkspace (
}
}
export async function createInviteLink (
export async function createInvite (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
exp: number
emailMask: string
emailMask?: string
email?: string
limit: number
role?: AccountRole
role: AccountRole
autoJoin?: boolean
}
): Promise<string> {
const { exp, emailMask, limit, role } = params
const { account, workspace: workspaceUuid } = decodeTokenVerbose(ctx, token)
const { exp, emailMask, email, limit, role, autoJoin } = params
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
@ -417,14 +420,23 @@ export async function createInviteLink (
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
ctx.info('Creating invite link', { workspace, workspaceName: workspace.name, emailMask, limit })
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
if (autoJoin === true) {
verifyAllowedServices(['schedule'], extra)
}
ctx.info('Creating invite', { workspace, workspaceName: workspace.name, email, emailMask, limit, autoJoin })
return await db.invite.insertOne({
workspaceUuid,
expiresOn: exp < 0 ? -1 : Date.now() + exp,
email,
emailPattern: emailMask,
remainingUses: limit,
role
role,
autoJoin
})
}
@ -445,9 +457,10 @@ export async function sendInvite (
params: {
email: string
role: AccountRole
expHours?: number
}
): Promise<void> {
const { email, role } = params
const { email, role, expHours } = params
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
@ -463,19 +476,83 @@ export async function sendInvite (
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
checkRateLimit(account, workspaceUuid)
const expHours = 48
const exp = expHours * 60 * 60 * 1000
const inviteId = await createInviteLink(ctx, db, branding, token, { exp, emailMask: email, limit: 1, role })
const inviteEmail = await getInviteEmail(branding, email, inviteId, workspace, expHours)
const inviteLink = await createInviteLink(ctx, db, branding, token, params)
const inviteEmail = await getInviteEmail(branding, email, inviteLink, workspace, expHours ?? 48, false)
await sendEmail(inviteEmail, ctx)
ctx.info('Invite has been sent', { to: inviteEmail.to, workspaceUuid: workspace.uuid, workspaceName: workspace.name })
}
export async function createInviteLink (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
email: string
role: AccountRole
autoJoin?: boolean
firstName?: string
lastName?: string
navigateUrl?: string
expHours?: number
}
): Promise<string> {
const { email, role, autoJoin, firstName, lastName, navigateUrl, expHours } = params
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
}
const workspace = await db.workspace.findOne({ uuid: workspaceUuid })
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
if (autoJoin === true) {
verifyAllowedServices(['schedule'], extra)
if (firstName == null || firstName === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
}
const normalizedEmail = cleanEmail(email)
const expiringInHrs = expHours ?? 48
const exp = expiringInHrs * 60 * 60 * 1000
const inviteId = await createInvite(ctx, db, branding, token, {
exp,
email: normalizedEmail,
limit: 1,
role,
autoJoin
})
let path = `/login/join?inviteId=${inviteId}`
if (autoJoin === true) {
path += `&autoJoin&firstName=${encodeURIComponent((firstName ?? '').trim())}`
if (lastName != null) {
path += `&lastName=${encodeURIComponent(lastName.trim())}`
}
}
if (navigateUrl != null) {
path += `&navigateUrl=${encodeURIComponent(navigateUrl.trim())}`
}
const front = getFrontUrl(branding)
const link = concatLink(front, path)
ctx.info(`Created invite link: ${link}`)
return link
}
function checkRateLimit (email: string, workspaceName: string): void {
const now = Date.now()
const lastInvites = invitesSend.get(email)
@ -512,7 +589,7 @@ export async function resendInvite (
email: string,
role: AccountRole
): Promise<void> {
const { account, workspace: workspaceUuid } = decodeTokenVerbose(ctx, token)
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
const currentAccount = await db.account.findOne({ uuid: account })
if (currentAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
@ -525,19 +602,24 @@ export async function resendInvite (
checkRateLimit(account, workspaceUuid)
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
verifyAllowedRole(callerRole, role, extra)
const expHours = 48
const newExp = Date.now() + expHours * 60 * 60 * 1000
const invite = await db.invite.findOne({ workspaceUuid, emailPattern: email })
const invite = await db.invite.findOne({ workspaceUuid, email })
let inviteId: string
if (invite != null) {
inviteId = invite.id
await db.invite.updateOne({ id: invite.id }, { expiresOn: newExp, remainingUses: 1, role })
} else {
inviteId = await createInviteLink(ctx, db, branding, token, { exp: newExp, emailMask: email, limit: 1, role })
inviteId = await createInvite(ctx, db, branding, token, { exp: newExp, email, limit: 1, role })
}
const front = getFrontUrl(branding)
const link = concatLink(front, `/login/join?inviteId=${inviteId}`)
const inviteEmail = await getInviteEmail(branding, email, inviteId, workspace, expHours, true)
const inviteEmail = await getInviteEmail(branding, email, link, workspace, expHours, true)
await sendEmail(inviteEmail, ctx)
ctx.info('Invite has been resent', {
@ -602,13 +684,13 @@ export async function checkJoin (
params: { inviteId: string }
): Promise<WorkspaceLoginInfo> {
const { inviteId } = params
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
const emailSocialId = await db.socialId.findOne({
type: SocialIdType.EMAIL,
personUuid: accountUuid,
@ -635,6 +717,91 @@ export async function checkJoin (
}
}
export async function checkAutoJoin (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { inviteId: string, firstName: string, lastName?: string }
): Promise<WorkspaceLoginInfo | WorkspaceInviteInfo> {
const { inviteId, firstName, lastName } = params
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (invite.autoJoin !== true) {
ctx.error('Not an auto-join invite', invite)
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (invite.role !== AccountRole.Guest) {
ctx.error('Auto-join not for guest role is forbidden', invite)
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const normalizedEmail = invite.email != null ? cleanEmail(invite.email) : ''
const workspaceUuid = invite.workspaceUuid
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace === null) {
ctx.error('Workspace not found in auto-joining workflow', { workspaceUuid, email: normalizedEmail, inviteId })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
if (normalizedEmail == null || normalizedEmail === '') {
ctx.error('Malformed auto-join invite', invite)
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const emailSocialId = await db.socialId.findOne({
type: SocialIdType.EMAIL,
value: normalizedEmail
})
// If it's an existing account we should check for saved token or ask for login to prevent accidental access through shared link
if (emailSocialId != null) {
const targetAccount = await getAccount(db, emailSocialId.personUuid)
if (targetAccount != null) {
if (token == null) {
// Login required
return {
workspace: workspace.uuid,
email: normalizedEmail
}
}
const { account: callerAccount } = decodeTokenVerbose(ctx, token)
if (callerAccount !== targetAccount.uuid) {
// Login with target email required
return {
workspace: workspace.uuid,
email: normalizedEmail
}
}
const targetRole = await getWorkspaceRole(db, targetAccount.uuid, workspace.uuid)
if (targetRole == null || getRolePower(targetRole) < getRolePower(invite.role)) {
await db.updateWorkspaceRole(targetAccount.uuid, workspace.uuid, invite.role)
}
return await selectWorkspace(ctx, db, branding, token, { workspaceUrl: workspace.url, kind: 'external' })
}
}
// No account yet, create a new one automatically
if (firstName == null || firstName === '') {
ctx.error('First name is required for auto-join', { firstName })
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const { account } = await signUpByEmail(ctx, db, branding, normalizedEmail, null, firstName, lastName ?? '', true)
return await doJoinByInvite(ctx, db, branding, generateToken(account, workspaceUuid), account, workspace, invite)
}
/**
* Given an invite and sign up information, creates an account and assigns it to the workspace.
*/
@ -1811,12 +1978,14 @@ export type AccountMethods =
| 'signUpOtp'
| 'validateOtp'
| 'createWorkspace'
| 'createInvite'
| 'createInviteLink'
| 'sendInvite'
| 'resendInvite'
| 'selectWorkspace'
| 'join'
| 'checkJoin'
| 'checkAutoJoin'
| 'signUpJoin'
| 'confirm'
| 'changePassword'
@ -1861,12 +2030,14 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
...(hasSignUp ? { signUpOtp: wrap(signUpOtp) } : {}),
validateOtp: wrap(validateOtp),
createWorkspace: wrap(createWorkspace),
createInvite: wrap(createInvite),
createInviteLink: wrap(createInviteLink),
sendInvite: wrap(sendInvite),
resendInvite: wrap(resendInvite),
selectWorkspace: wrap(selectWorkspace),
join: wrap(join),
checkJoin: wrap(checkJoin),
checkAutoJoin: wrap(checkAutoJoin),
signUpJoin: wrap(signUpJoin),
confirm: wrap(confirm),
changePassword: wrap(changePassword),

View File

@ -116,8 +116,10 @@ export interface WorkspaceInvite {
workspaceUuid: WorkspaceUuid
expiresOn: Timestamp
emailPattern?: string
email?: string
remainingUses?: number
role: AccountRole
autoJoin?: boolean
}
/* ========= S U P P L E M E N T A R Y ========= */
@ -247,3 +249,8 @@ export interface RegionInfo {
region: string
name: string
}
export interface WorkspaceInviteInfo {
workspace: WorkspaceUuid
email?: string
}

View File

@ -459,7 +459,7 @@ export async function signUpByEmail (
db: AccountDB,
branding: Branding | null,
email: string,
password: string,
password: string | null,
firstName: string,
lastName: string,
confirmed = false
@ -494,7 +494,9 @@ export async function signUpByEmail (
}
await createAccount(db, account, confirmed)
await setPassword(ctx, db, branding, account, password)
if (password != null) {
await setPassword(ctx, db, branding, account, password)
}
return { account, socialId }
}
@ -754,6 +756,12 @@ export async function checkInvite (ctx: MeasureContext, invite: WorkspaceInvite,
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (invite.email != null && invite.email.trim().length > 0 && invite.email !== email) {
ctx.error("Invite doesn't allow this email address", { email, ...invite })
Analytics.handleError(new Error(`Invite link email check failed ${invite.id} ${email} ${invite.email}`))
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return invite.workspaceUuid
}
@ -1236,13 +1244,11 @@ export function sanitizeEmail (email: string): string {
export async function getInviteEmail (
branding: Branding | null,
email: string,
inviteId: string,
link: string,
workspace: Workspace,
expHours: number,
resend = false
): Promise<EmailInfo> {
const front = getFrontUrl(branding)
const link = concatLink(front, `/login/join?inviteId=${inviteId}`)
const ws = sanitizeEmail(workspace.name !== '' ? workspace.name : workspace.url)
const lang = branding?.language