mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-13 19:00:09 +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
|
inviteId: string
|
||||||
) => Promise<WorkspaceLoginInfo>
|
) => Promise<WorkspaceLoginInfo>
|
||||||
join: (email: string, password: string, 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>
|
checkJoin: (inviteId: string) => Promise<WorkspaceLoginInfo>
|
||||||
|
checkAutoJoin: (inviteId: string, firstName: string, lastName?: string) => Promise<WorkspaceLoginInfo>
|
||||||
getWorkspaceInfo: (updateLastVisit?: boolean) => Promise<WorkspaceInfoWithStatus>
|
getWorkspaceInfo: (updateLastVisit?: boolean) => Promise<WorkspaceInfoWithStatus>
|
||||||
getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise<WorkspaceInfoWithStatus[]>
|
getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise<WorkspaceInfoWithStatus[]>
|
||||||
getRegionInfo: () => Promise<RegionInfo[]>
|
getRegionInfo: () => Promise<RegionInfo[]>
|
||||||
@ -346,9 +347,9 @@ class AccountClientImpl implements AccountClient {
|
|||||||
return await this.rpc(request)
|
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 = {
|
const request = {
|
||||||
method: 'createInviteLink' as const,
|
method: 'createInvite' as const,
|
||||||
params: { exp, emailMask, limit, role }
|
params: { exp, emailMask, limit, role }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,6 +365,15 @@ class AccountClientImpl implements AccountClient {
|
|||||||
return await this.rpc(request)
|
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[]> {
|
async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise<WorkspaceInfoWithStatus[]> {
|
||||||
const request = {
|
const request = {
|
||||||
method: 'getWorkspacesInfo' as const,
|
method: 'getWorkspacesInfo' as const,
|
||||||
|
@ -26,6 +26,11 @@ export interface WorkspaceLoginInfo extends LoginInfo {
|
|||||||
role: AccountRole
|
role: AccountRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceInviteInfo {
|
||||||
|
workspace: WorkspaceUuid
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface OtpInfo {
|
export interface OtpInfo {
|
||||||
sent: boolean
|
sent: boolean
|
||||||
retryOn: Timestamp
|
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}
|
label={field.i18n}
|
||||||
name={field.id}
|
name={field.id}
|
||||||
password={field.password}
|
password={field.password}
|
||||||
|
disabled={field.disabled}
|
||||||
bind:value={object[field.name]}
|
bind:value={object[field.name]}
|
||||||
on:input={() => validate($themeStore.language)}
|
on:input={() => validate($themeStore.language)}
|
||||||
on:blur={() => {
|
on:blur={() => {
|
||||||
|
@ -114,6 +114,7 @@
|
|||||||
async function check (): Promise<void> {
|
async function check (): Promise<void> {
|
||||||
if (location.query?.inviteId === undefined || location.query?.inviteId === null) return
|
if (location.query?.inviteId === undefined || location.query?.inviteId === null) return
|
||||||
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
|
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
|
||||||
|
|
||||||
const [, result] = await checkJoined(location.query.inviteId)
|
const [, result] = await checkJoined(location.query.inviteId)
|
||||||
status = OK
|
status = OK
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
|
@ -34,6 +34,7 @@
|
|||||||
import ConfirmationSend from './ConfirmationSend.svelte'
|
import ConfirmationSend from './ConfirmationSend.svelte'
|
||||||
import CreateWorkspaceForm from './CreateWorkspaceForm.svelte'
|
import CreateWorkspaceForm from './CreateWorkspaceForm.svelte'
|
||||||
import Join from './Join.svelte'
|
import Join from './Join.svelte'
|
||||||
|
import AutoJoin from './AutoJoin.svelte'
|
||||||
import LoginForm from './LoginForm.svelte'
|
import LoginForm from './LoginForm.svelte'
|
||||||
import PasswordRequest from './PasswordRequest.svelte'
|
import PasswordRequest from './PasswordRequest.svelte'
|
||||||
import PasswordRestore from './PasswordRestore.svelte'
|
import PasswordRestore from './PasswordRestore.svelte'
|
||||||
@ -61,12 +62,17 @@
|
|||||||
function updatePageLoc (loc: Location): void {
|
function updatePageLoc (loc: Location): void {
|
||||||
const token = getMetadata(presentation.metadata.Token)
|
const token = getMetadata(presentation.metadata.Token)
|
||||||
page = (loc.path[1] as Pages) ?? (token != null ? 'selectWorkspace' : 'login')
|
page = (loc.path[1] as Pages) ?? (token != null ? 'selectWorkspace' : 'login')
|
||||||
|
if (page === 'join' && loc.query?.autoJoin !== undefined) {
|
||||||
|
page = 'autoJoin'
|
||||||
|
}
|
||||||
|
|
||||||
const allowedUnauthPages: Pages[] = [
|
const allowedUnauthPages: Pages[] = [
|
||||||
'login',
|
'login',
|
||||||
'signup',
|
'signup',
|
||||||
'password',
|
'password',
|
||||||
'recovery',
|
'recovery',
|
||||||
'join',
|
'join',
|
||||||
|
'autoJoin',
|
||||||
'confirm',
|
'confirm',
|
||||||
'confirmationSend',
|
'confirmationSend',
|
||||||
'auth'
|
'auth'
|
||||||
@ -153,6 +159,8 @@
|
|||||||
<SelectWorkspace {navigateUrl} />
|
<SelectWorkspace {navigateUrl} />
|
||||||
{:else if page === 'join'}
|
{:else if page === 'join'}
|
||||||
<Join />
|
<Join />
|
||||||
|
{:else if page === 'autoJoin'}
|
||||||
|
<AutoJoin />
|
||||||
{:else if page === 'confirm'}
|
{:else if page === 'confirm'}
|
||||||
<Confirmation />
|
<Confirmation />
|
||||||
{:else if page === 'confirmationSend'}
|
{:else if page === 'confirmationSend'}
|
||||||
|
@ -22,6 +22,7 @@ export interface Field {
|
|||||||
password?: boolean
|
password?: boolean
|
||||||
optional?: boolean
|
optional?: boolean
|
||||||
short?: boolean
|
short?: boolean
|
||||||
|
disabled?: boolean
|
||||||
rules?: Array<{
|
rules?: Array<{
|
||||||
rule: RegExp | ((value: string) => boolean)
|
rule: RegExp | ((value: string) => boolean)
|
||||||
notMatch: boolean
|
notMatch: boolean
|
||||||
|
@ -13,7 +13,14 @@
|
|||||||
// limitations under the License.
|
// 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 { getClient as getAccountClientRaw } from '@hcengineering/account-client'
|
||||||
import { Analytics } from '@hcengineering/analytics'
|
import { Analytics } from '@hcengineering/analytics'
|
||||||
import {
|
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 (
|
export async function getInviteLink (
|
||||||
expHours: number,
|
expHours: number,
|
||||||
mask: string,
|
mask: string,
|
||||||
@ -548,7 +576,7 @@ export async function getInviteLinkId (
|
|||||||
return ''
|
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')
|
Analytics.handleEvent('Get invite link')
|
||||||
|
|
||||||
@ -900,8 +928,10 @@ export async function doLoginNavigate (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWorkspaceLoginInfo (info: WorkspaceLoginInfo | LoginInfo | null): info is WorkspaceLoginInfo {
|
export function isWorkspaceLoginInfo (
|
||||||
return (info as any)?.workspace !== undefined
|
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 {
|
export function getAccountDisplayName (loginInfo: LoginInfo | null): string {
|
||||||
|
@ -35,6 +35,7 @@ export const pages = [
|
|||||||
'selectWorkspace',
|
'selectWorkspace',
|
||||||
'admin',
|
'admin',
|
||||||
'join',
|
'join',
|
||||||
|
'autoJoin',
|
||||||
'confirm',
|
'confirm',
|
||||||
'confirmationSend',
|
'confirmationSend',
|
||||||
'auth',
|
'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][] {
|
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.
|
// 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,
|
Workspace,
|
||||||
WorkspaceEvent,
|
WorkspaceEvent,
|
||||||
WorkspaceInfoWithStatus,
|
WorkspaceInfoWithStatus,
|
||||||
|
WorkspaceInviteInfo,
|
||||||
WorkspaceLoginInfo,
|
WorkspaceLoginInfo,
|
||||||
WorkspaceOperation,
|
WorkspaceOperation,
|
||||||
WorkspaceStatus
|
WorkspaceStatus
|
||||||
@ -392,20 +393,22 @@ export async function createWorkspace (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInviteLink (
|
export async function createInvite (
|
||||||
ctx: MeasureContext,
|
ctx: MeasureContext,
|
||||||
db: AccountDB,
|
db: AccountDB,
|
||||||
branding: Branding | null,
|
branding: Branding | null,
|
||||||
token: string,
|
token: string,
|
||||||
params: {
|
params: {
|
||||||
exp: number
|
exp: number
|
||||||
emailMask: string
|
emailMask?: string
|
||||||
|
email?: string
|
||||||
limit: number
|
limit: number
|
||||||
role?: AccountRole
|
role: AccountRole
|
||||||
|
autoJoin?: boolean
|
||||||
}
|
}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { exp, emailMask, limit, role } = params
|
const { exp, emailMask, email, limit, role, autoJoin } = params
|
||||||
const { account, workspace: workspaceUuid } = decodeTokenVerbose(ctx, token)
|
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
const currentAccount = await db.account.findOne({ uuid: account })
|
const currentAccount = await db.account.findOne({ uuid: account })
|
||||||
if (currentAccount == null) {
|
if (currentAccount == null) {
|
||||||
@ -417,14 +420,23 @@ export async function createInviteLink (
|
|||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
|
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({
|
return await db.invite.insertOne({
|
||||||
workspaceUuid,
|
workspaceUuid,
|
||||||
expiresOn: exp < 0 ? -1 : Date.now() + exp,
|
expiresOn: exp < 0 ? -1 : Date.now() + exp,
|
||||||
|
email,
|
||||||
emailPattern: emailMask,
|
emailPattern: emailMask,
|
||||||
remainingUses: limit,
|
remainingUses: limit,
|
||||||
role
|
role,
|
||||||
|
autoJoin
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,9 +457,10 @@ export async function sendInvite (
|
|||||||
params: {
|
params: {
|
||||||
email: string
|
email: string
|
||||||
role: AccountRole
|
role: AccountRole
|
||||||
|
expHours?: number
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, role } = params
|
const { email, role, expHours } = params
|
||||||
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
|
const { account, workspace: workspaceUuid, extra } = decodeTokenVerbose(ctx, token)
|
||||||
|
|
||||||
const currentAccount = await db.account.findOne({ uuid: account })
|
const currentAccount = await db.account.findOne({ uuid: account })
|
||||||
@ -463,19 +476,83 @@ export async function sendInvite (
|
|||||||
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
|
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
|
||||||
verifyAllowedRole(callerRole, role, extra)
|
verifyAllowedRole(callerRole, role, extra)
|
||||||
|
|
||||||
checkRateLimit(account, workspaceUuid)
|
const inviteLink = await createInviteLink(ctx, db, branding, token, params)
|
||||||
|
const inviteEmail = await getInviteEmail(branding, email, inviteLink, workspace, expHours ?? 48, false)
|
||||||
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)
|
|
||||||
|
|
||||||
await sendEmail(inviteEmail, ctx)
|
await sendEmail(inviteEmail, ctx)
|
||||||
|
|
||||||
ctx.info('Invite has been sent', { to: inviteEmail.to, workspaceUuid: workspace.uuid, workspaceName: workspace.name })
|
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 {
|
function checkRateLimit (email: string, workspaceName: string): void {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lastInvites = invitesSend.get(email)
|
const lastInvites = invitesSend.get(email)
|
||||||
@ -512,7 +589,7 @@ export async function resendInvite (
|
|||||||
email: string,
|
email: string,
|
||||||
role: AccountRole
|
role: AccountRole
|
||||||
): Promise<void> {
|
): 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 })
|
const currentAccount = await db.account.findOne({ uuid: account })
|
||||||
if (currentAccount == null) {
|
if (currentAccount == null) {
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account }))
|
||||||
@ -525,19 +602,24 @@ export async function resendInvite (
|
|||||||
|
|
||||||
checkRateLimit(account, workspaceUuid)
|
checkRateLimit(account, workspaceUuid)
|
||||||
|
|
||||||
|
const callerRole = await db.getWorkspaceRole(account, workspace.uuid)
|
||||||
|
verifyAllowedRole(callerRole, role, extra)
|
||||||
|
|
||||||
const expHours = 48
|
const expHours = 48
|
||||||
const newExp = Date.now() + expHours * 60 * 60 * 1000
|
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
|
let inviteId: string
|
||||||
if (invite != null) {
|
if (invite != null) {
|
||||||
inviteId = invite.id
|
inviteId = invite.id
|
||||||
await db.invite.updateOne({ id: invite.id }, { expiresOn: newExp, remainingUses: 1, role })
|
await db.invite.updateOne({ id: invite.id }, { expiresOn: newExp, remainingUses: 1, role })
|
||||||
} else {
|
} 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)
|
await sendEmail(inviteEmail, ctx)
|
||||||
|
|
||||||
ctx.info('Invite has been resent', {
|
ctx.info('Invite has been resent', {
|
||||||
@ -602,13 +684,13 @@ export async function checkJoin (
|
|||||||
params: { inviteId: string }
|
params: { inviteId: string }
|
||||||
): Promise<WorkspaceLoginInfo> {
|
): Promise<WorkspaceLoginInfo> {
|
||||||
const { inviteId } = params
|
const { inviteId } = params
|
||||||
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
|
|
||||||
|
|
||||||
const invite = await getWorkspaceInvite(db, inviteId)
|
const invite = await getWorkspaceInvite(db, inviteId)
|
||||||
if (invite == null) {
|
if (invite == null) {
|
||||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
|
||||||
const emailSocialId = await db.socialId.findOne({
|
const emailSocialId = await db.socialId.findOne({
|
||||||
type: SocialIdType.EMAIL,
|
type: SocialIdType.EMAIL,
|
||||||
personUuid: accountUuid,
|
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.
|
* Given an invite and sign up information, creates an account and assigns it to the workspace.
|
||||||
*/
|
*/
|
||||||
@ -1811,12 +1978,14 @@ export type AccountMethods =
|
|||||||
| 'signUpOtp'
|
| 'signUpOtp'
|
||||||
| 'validateOtp'
|
| 'validateOtp'
|
||||||
| 'createWorkspace'
|
| 'createWorkspace'
|
||||||
|
| 'createInvite'
|
||||||
| 'createInviteLink'
|
| 'createInviteLink'
|
||||||
| 'sendInvite'
|
| 'sendInvite'
|
||||||
| 'resendInvite'
|
| 'resendInvite'
|
||||||
| 'selectWorkspace'
|
| 'selectWorkspace'
|
||||||
| 'join'
|
| 'join'
|
||||||
| 'checkJoin'
|
| 'checkJoin'
|
||||||
|
| 'checkAutoJoin'
|
||||||
| 'signUpJoin'
|
| 'signUpJoin'
|
||||||
| 'confirm'
|
| 'confirm'
|
||||||
| 'changePassword'
|
| 'changePassword'
|
||||||
@ -1861,12 +2030,14 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
|
|||||||
...(hasSignUp ? { signUpOtp: wrap(signUpOtp) } : {}),
|
...(hasSignUp ? { signUpOtp: wrap(signUpOtp) } : {}),
|
||||||
validateOtp: wrap(validateOtp),
|
validateOtp: wrap(validateOtp),
|
||||||
createWorkspace: wrap(createWorkspace),
|
createWorkspace: wrap(createWorkspace),
|
||||||
|
createInvite: wrap(createInvite),
|
||||||
createInviteLink: wrap(createInviteLink),
|
createInviteLink: wrap(createInviteLink),
|
||||||
sendInvite: wrap(sendInvite),
|
sendInvite: wrap(sendInvite),
|
||||||
resendInvite: wrap(resendInvite),
|
resendInvite: wrap(resendInvite),
|
||||||
selectWorkspace: wrap(selectWorkspace),
|
selectWorkspace: wrap(selectWorkspace),
|
||||||
join: wrap(join),
|
join: wrap(join),
|
||||||
checkJoin: wrap(checkJoin),
|
checkJoin: wrap(checkJoin),
|
||||||
|
checkAutoJoin: wrap(checkAutoJoin),
|
||||||
signUpJoin: wrap(signUpJoin),
|
signUpJoin: wrap(signUpJoin),
|
||||||
confirm: wrap(confirm),
|
confirm: wrap(confirm),
|
||||||
changePassword: wrap(changePassword),
|
changePassword: wrap(changePassword),
|
||||||
|
@ -116,8 +116,10 @@ export interface WorkspaceInvite {
|
|||||||
workspaceUuid: WorkspaceUuid
|
workspaceUuid: WorkspaceUuid
|
||||||
expiresOn: Timestamp
|
expiresOn: Timestamp
|
||||||
emailPattern?: string
|
emailPattern?: string
|
||||||
|
email?: string
|
||||||
remainingUses?: number
|
remainingUses?: number
|
||||||
role: AccountRole
|
role: AccountRole
|
||||||
|
autoJoin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========= S U P P L E M E N T A R Y ========= */
|
/* ========= S U P P L E M E N T A R Y ========= */
|
||||||
@ -247,3 +249,8 @@ export interface RegionInfo {
|
|||||||
region: string
|
region: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceInviteInfo {
|
||||||
|
workspace: WorkspaceUuid
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
@ -459,7 +459,7 @@ export async function signUpByEmail (
|
|||||||
db: AccountDB,
|
db: AccountDB,
|
||||||
branding: Branding | null,
|
branding: Branding | null,
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string | null,
|
||||||
firstName: string,
|
firstName: string,
|
||||||
lastName: string,
|
lastName: string,
|
||||||
confirmed = false
|
confirmed = false
|
||||||
@ -494,7 +494,9 @@ export async function signUpByEmail (
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createAccount(db, account, confirmed)
|
await createAccount(db, account, confirmed)
|
||||||
|
if (password != null) {
|
||||||
await setPassword(ctx, db, branding, account, password)
|
await setPassword(ctx, db, branding, account, password)
|
||||||
|
}
|
||||||
|
|
||||||
return { account, socialId }
|
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, {}))
|
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
|
return invite.workspaceUuid
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1236,13 +1244,11 @@ export function sanitizeEmail (email: string): string {
|
|||||||
export async function getInviteEmail (
|
export async function getInviteEmail (
|
||||||
branding: Branding | null,
|
branding: Branding | null,
|
||||||
email: string,
|
email: string,
|
||||||
inviteId: string,
|
link: string,
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
expHours: number,
|
expHours: number,
|
||||||
resend = false
|
resend = false
|
||||||
): Promise<EmailInfo> {
|
): Promise<EmailInfo> {
|
||||||
const front = getFrontUrl(branding)
|
|
||||||
const link = concatLink(front, `/login/join?inviteId=${inviteId}`)
|
|
||||||
const ws = sanitizeEmail(workspace.name !== '' ? workspace.name : workspace.url)
|
const ws = sanitizeEmail(workspace.name !== '' ? workspace.name : workspace.url)
|
||||||
const lang = branding?.language
|
const lang = branding?.language
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user