mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-10 17:30:51 +00:00
UBERF-9636: Meeting links (#8334)
This commit is contained in:
parent
d74f1bf847
commit
edda71fef4
@ -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,
|
||||
|
@ -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
|
||||
|
108
plugins/login-resources/src/components/AutoJoin.svelte
Normal file
108
plugins/login-resources/src/components/AutoJoin.svelte
Normal 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
|
||||
/>
|
@ -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={() => {
|
||||
|
@ -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) {
|
||||
|
@ -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'}
|
||||
|
@ -22,6 +22,7 @@ export interface Field {
|
||||
password?: boolean
|
||||
optional?: boolean
|
||||
short?: boolean
|
||||
disabled?: boolean
|
||||
rules?: Array<{
|
||||
rule: RegExp | ((value: string) => boolean)
|
||||
notMatch: boolean
|
||||
|
@ -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 {
|
||||
|
@ -35,6 +35,7 @@ export const pages = [
|
||||
'selectWorkspace',
|
||||
'admin',
|
||||
'join',
|
||||
'autoJoin',
|
||||
'confirm',
|
||||
'confirmationSend',
|
||||
'auth',
|
||||
|
457
server/account/src/__tests__/operations.test.ts
Normal file
457
server/account/src/__tests__/operations.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
@ -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;
|
||||
`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user