diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 9c8d720737..15b65e0eba 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -64,8 +64,9 @@ export interface AccountClient { inviteId: string ) => Promise join: (email: string, password: string, inviteId: string) => Promise - createInviteLink: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise + createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise checkJoin: (inviteId: string) => Promise + checkAutoJoin: (inviteId: string, firstName: string, lastName?: string) => Promise getWorkspaceInfo: (updateLastVisit?: boolean) => Promise getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise getRegionInfo: () => Promise @@ -346,9 +347,9 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } - async createInviteLink (exp: number, emailMask: string, limit: number, role: AccountRole): Promise { + async createInvite (exp: number, emailMask: string, limit: number, role: AccountRole): Promise { 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 { + const request = { + method: 'checkAutoJoin' as const, + params: { inviteId, firstName, lastName } + } + + return await this.rpc(request) + } + async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise { const request = { method: 'getWorkspacesInfo' as const, diff --git a/packages/account-client/src/types.ts b/packages/account-client/src/types.ts index 89b36740f2..07db2a9e1c 100644 --- a/packages/account-client/src/types.ts +++ b/packages/account-client/src/types.ts @@ -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 diff --git a/plugins/login-resources/src/components/AutoJoin.svelte b/plugins/login-resources/src/components/AutoJoin.svelte new file mode 100644 index 0000000000..1b554f7805 --- /dev/null +++ b/plugins/login-resources/src/components/AutoJoin.svelte @@ -0,0 +1,108 @@ + + + +
diff --git a/plugins/login-resources/src/components/Form.svelte b/plugins/login-resources/src/components/Form.svelte index 2972bdb746..d1ffd41098 100644 --- a/plugins/login-resources/src/components/Form.svelte +++ b/plugins/login-resources/src/components/Form.svelte @@ -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={() => { diff --git a/plugins/login-resources/src/components/Join.svelte b/plugins/login-resources/src/components/Join.svelte index 60052bb562..ae70502b38 100644 --- a/plugins/login-resources/src/components/Join.svelte +++ b/plugins/login-resources/src/components/Join.svelte @@ -114,6 +114,7 @@ async function check (): Promise { 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) { diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index 71a8e8cedd..e0ca0ef4a3 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -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 @@ {:else if page === 'join'} + {:else if page === 'autoJoin'} + {:else if page === 'confirm'} {:else if page === 'confirmationSend'} diff --git a/plugins/login-resources/src/types.ts b/plugins/login-resources/src/types.ts index 135d08da7e..b4a6ddd6bb 100644 --- a/plugins/login-resources/src/types.ts +++ b/plugins/login-resources/src/types.ts @@ -22,6 +22,7 @@ export interface Field { password?: boolean optional?: boolean short?: boolean + disabled?: boolean rules?: Array<{ rule: RegExp | ((value: string) => boolean) notMatch: boolean diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index f2a4508c71..bc566e22fb 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -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 { diff --git a/plugins/login/src/index.ts b/plugins/login/src/index.ts index 745ee14f0c..f09d35322e 100644 --- a/plugins/login/src/index.ts +++ b/plugins/login/src/index.ts @@ -35,6 +35,7 @@ export const pages = [ 'selectWorkspace', 'admin', 'join', + 'autoJoin', 'confirm', 'confirmationSend', 'auth', diff --git a/server/account/src/__tests__/operations.test.ts b/server/account/src/__tests__/operations.test.ts new file mode 100644 index 0000000000..6460fd6fbe --- /dev/null +++ b/server/account/src/__tests__/operations.test.ts @@ -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() + }) + }) +}) diff --git a/server/account/src/collections/postgres.ts b/server/account/src/collections/postgres.ts index 6de7834580..57a007a09d 100644 --- a/server/account/src/collections/postgres.ts +++ b/server/account/src/collections/postgres.ts @@ -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; + ` + ] + } } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index df605f443d..c09cdccb28 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -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 { - 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 { - 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 { + 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 { - 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 { 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 { + 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 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 { - 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