diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts index 637da81892c03..97efed3fcb842 100644 --- a/apps/meteor/app/api/server/v1/invites.ts +++ b/apps/meteor/app/api/server/v1/invites.ts @@ -22,11 +22,46 @@ const invites = API.v1 { authRequired: true, response: { - 200: ajv.compile({ + 200: ajv.compile[]>({ additionalProperties: false, type: 'array', items: { - $ref: '#/components/schemas/IInvite', + additionalProperties: false, + type: 'object', + properties: { + _id: { + type: 'string', + }, + days: { + type: 'number', + }, + maxUses: { + type: 'number', + }, + rid: { + type: 'string', + }, + userId: { + type: 'string', + }, + createdAt: { + type: 'string', + }, + _updatedAt: { + type: 'string', + }, + expires: { + type: 'string', + nullable: true, + }, + uses: { + type: 'number', + }, + url: { + type: 'string', + }, + }, + required: ['_id', 'days', 'maxUses', 'rid', 'userId', 'createdAt', '_updatedAt', 'uses', 'url'], }, }), 401: ajv.compile({ @@ -72,6 +107,9 @@ const invites = API.v1 _id: { type: 'string', }, + inviteToken: { + type: 'string', + }, rid: { type: 'string', }, @@ -105,7 +143,7 @@ const invites = API.v1 description: 'Indicates if the request was successful.', }, }, - required: ['_id', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'], + required: ['_id', 'inviteToken', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'], }), 400: ajv.compile({ additionalProperties: false, diff --git a/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts b/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts index fa905cc345c6d..ab32d258d70c4 100644 --- a/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts +++ b/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts @@ -1,7 +1,8 @@ +import crypto from 'node:crypto'; + import { api } from '@rocket.chat/core-services'; import type { IInvite } from '@rocket.chat/core-typings'; import { Invites, Subscriptions, Rooms } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; @@ -11,12 +12,12 @@ import { settings } from '../../../settings/server'; import { getURL } from '../../../utils/server/getURL'; function getInviteUrl(invite: Omit) { - const { _id } = invite; + const { inviteToken } = invite; const useDirectLink = settings.get('Accounts_Registration_InviteUrlType') === 'direct'; return getURL( - `invite/${_id}`, + `invite/${inviteToken}`, { full: useDirectLink, cloud: !useDirectLink, @@ -89,13 +90,17 @@ export const findOrCreateInvite = async (userId: string, invite: Pick = { _id, + inviteToken, days, maxUses, rid: invite.rid, diff --git a/apps/meteor/app/invites/server/functions/listInvites.ts b/apps/meteor/app/invites/server/functions/listInvites.ts index 94999a98a542a..d5a596a50764b 100644 --- a/apps/meteor/app/invites/server/functions/listInvites.ts +++ b/apps/meteor/app/invites/server/functions/listInvites.ts @@ -1,3 +1,4 @@ +import type { IInvite } from '@rocket.chat/core-typings'; import { Invites } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -12,5 +13,22 @@ export const listInvites = async (userId: string) => { throw new Meteor.Error('not_authorized'); } - return Invites.find({}).toArray(); + const invites = await Invites.find({}).toArray(); + + // Ensure all invites have inviteToken (for legacy invites that might not have it) + for (const invite of invites) { + const inviteWithToken = invite as IInvite & { inviteToken?: string }; + if (!inviteWithToken.inviteToken) { + const inviteToken = crypto.randomUUID(); + // eslint-disable-next-line no-await-in-loop + await Invites.updateOne({ _id: invite._id }, { $set: { inviteToken } }); + inviteWithToken.inviteToken = inviteToken; + } + } + + // Remove inviteToken from the response + return invites.map((invite) => { + const { inviteToken, ...inviteWithoutToken } = invite as IInvite & { inviteToken?: string }; + return inviteWithoutToken; + }); }; diff --git a/apps/meteor/app/invites/server/functions/validateInviteToken.ts b/apps/meteor/app/invites/server/functions/validateInviteToken.ts index 22517643fd800..7911affb78d73 100644 --- a/apps/meteor/app/invites/server/functions/validateInviteToken.ts +++ b/apps/meteor/app/invites/server/functions/validateInviteToken.ts @@ -11,7 +11,8 @@ export const validateInviteToken = async (token: string) => { }); } - const inviteData = await Invites.findOneById(token); + const inviteData = await Invites.findOneByInviteToken(token); + if (!inviteData) { throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { method: 'validateInviteToken', diff --git a/apps/meteor/client/views/admin/invites/InviteRow.tsx b/apps/meteor/client/views/admin/invites/InviteRow.tsx index 31fe9f1f0166f..0f508ba0073d6 100644 --- a/apps/meteor/client/views/admin/invites/InviteRow.tsx +++ b/apps/meteor/client/views/admin/invites/InviteRow.tsx @@ -17,7 +17,7 @@ const isExpired = (expires: IInvite['expires']): boolean => { return false; }; -type InviteRowProps = Omit & { +type InviteRowProps = Omit & { onRemove: (removeInvite: () => Promise) => void; _updatedAt: string; createdAt: string; diff --git a/apps/meteor/client/views/admin/invites/InvitesPage.tsx b/apps/meteor/client/views/admin/invites/InvitesPage.tsx index efc94915cdf7a..aba47ae21567a 100644 --- a/apps/meteor/client/views/admin/invites/InvitesPage.tsx +++ b/apps/meteor/client/views/admin/invites/InvitesPage.tsx @@ -72,7 +72,7 @@ const InvitesPage = (): ReactElement => { const headers = useMemo( () => ( <> - {t('Token')} + {t('Invite')} {notSmall && ( <> {t('Created_at')} @@ -100,6 +100,7 @@ const InvitesPage = (): ReactElement => { )} + {isSuccess && data && data.length > 0 && ( {headers} @@ -111,7 +112,9 @@ const InvitesPage = (): ReactElement => { )} + {isSuccess && data && data.length === 0 && } + {isError && ( diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index 50d59d1b8b402..bcaf4f6fc934e 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -139,8 +139,8 @@ test.describe('SAML', () => { const inviteResponse = await api.post('/findOrCreateInvite', { rid: targetInviteGroupId, days: 1, maxUses: 0 }); expect(inviteResponse.status()).toBe(200); - const { _id } = await inviteResponse.json(); - inviteId = _id; + const { inviteToken } = await inviteResponse.json(); + inviteId = inviteToken; }); test.afterAll(async ({ api }) => { diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index bb4d9ee8d4799..488548de2d47f 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -1536,8 +1536,8 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I expect(res.body).to.have.property('rid', plainRoomId); expect(res.body).to.have.property('days', 1); expect(res.body).to.have.property('maxUses', 0); - plainRoomInviteToken = res.body._id; - createdInviteIds.push(plainRoomInviteToken); + plainRoomInviteToken = res.body.inviteToken; + createdInviteIds.push(res.body._id); }); }); diff --git a/apps/meteor/tests/end-to-end/api/invites.ts b/apps/meteor/tests/end-to-end/api/invites.ts index 85f0771f67d13..974bb1cfc4b34 100644 --- a/apps/meteor/tests/end-to-end/api/invites.ts +++ b/apps/meteor/tests/end-to-end/api/invites.ts @@ -6,6 +6,7 @@ import { getCredentials, api, request, credentials } from '../../data/api-data'; describe('Invites', () => { let testInviteID: IInvite['_id']; + let testInviteToken: IInvite['inviteToken']; before((done) => getCredentials(done)); describe('POST [/findOrCreateInvite]', () => { @@ -58,7 +59,10 @@ describe('Invites', () => { expect(res.body).to.have.property('maxUses', 10); expect(res.body).to.have.property('uses'); expect(res.body).to.have.property('_id'); + expect(res.body).to.have.property('inviteToken'); + expect(res.body.inviteToken).to.be.a('string'); testInviteID = res.body._id; + testInviteToken = res.body.inviteToken; }) .end(done); }); @@ -79,6 +83,7 @@ describe('Invites', () => { expect(res.body).to.have.property('maxUses', 10); expect(res.body).to.have.property('uses'); expect(res.body).to.have.property('_id', testInviteID); + expect(res.body).to.have.property('inviteToken', testInviteToken); }) .end(done); }); @@ -96,13 +101,14 @@ describe('Invites', () => { .end(done); }); - it('should return the existing invite for GENERAL', (done) => { + it('should return the existing invite for GENERAL without inviteToken', (done) => { void request .get(api('listInvites')) .set(credentials) .expect(200) .expect((res) => { expect(res.body[0]).to.have.property('_id', testInviteID); + expect(res.body[0]).to.not.have.property('inviteToken'); }) .end(done); }); @@ -148,12 +154,12 @@ describe('Invites', () => { .end(done); }); - it('should use the existing invite for GENERAL', (done) => { + it('should use the existing invite for GENERAL with inviteToken', (done) => { void request .post(api('useInviteToken')) .set(credentials) .send({ - token: testInviteID, + token: testInviteToken, }) .expect(200) .expect((res) => { @@ -161,6 +167,21 @@ describe('Invites', () => { }) .end(done); }); + + it('should fail when using _id as token', (done) => { + void request + .post(api('useInviteToken')) + .set(credentials) + .send({ + token: testInviteID, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-token'); + }) + .end(done); + }); }); describe('POST [/validateInviteToken]', () => { @@ -179,12 +200,12 @@ describe('Invites', () => { .end(done); }); - it('should succeed when valid token', (done) => { + it('should succeed when valid inviteToken', (done) => { void request .post(api('validateInviteToken')) .set(credentials) .send({ - token: testInviteID, + token: testInviteToken, }) .expect(200) .expect((res) => { @@ -193,6 +214,21 @@ describe('Invites', () => { }) .end(done); }); + + it('should fail when using _id as token', (done) => { + void request + .post(api('validateInviteToken')) + .set(credentials) + .send({ + token: testInviteID, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', false); + }) + .end(done); + }); }); describe('DELETE [/removeInvite]', () => { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 1618d79330207..e44520679bd79 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -1258,6 +1258,7 @@ describe('[Users]', () => { let user3Credentials: Credentials; let group: IRoom; let inviteToken: string; + let inviteId: string; before(async () => { const username = `deactivated_${Date.now()}${apiUsername}`; @@ -1328,18 +1329,18 @@ describe('[Users]', () => { }); before('Create invite link', async () => { - inviteToken = ( - await request.post(api('findOrCreateInvite')).set(credentials).send({ - rid: group._id, - days: 0, - maxUses: 0, - }) - ).body._id; + const response = await request.post(api('findOrCreateInvite')).set(credentials).send({ + rid: group._id, + days: 0, + maxUses: 0, + }); + inviteToken = response.body.inviteToken; + inviteId = response.body._id; }); after('Remove invite link', async () => request - .delete(api(`removeInvite/${inviteToken}`)) + .delete(api(`removeInvite/${inviteId}`)) .set(credentials) .send(), ); diff --git a/packages/core-typings/src/IInvite.ts b/packages/core-typings/src/IInvite.ts index f470ee0e6ecef..bf9a9c1fe340b 100644 --- a/packages/core-typings/src/IInvite.ts +++ b/packages/core-typings/src/IInvite.ts @@ -2,6 +2,7 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; export interface IInvite extends IRocketChatRecord { days: number; + inviteToken: string; maxUses: number; rid: string; userId: string; diff --git a/packages/model-typings/src/models/IInvitesModel.ts b/packages/model-typings/src/models/IInvitesModel.ts index 755efd9c7fee9..a238bd897f529 100644 --- a/packages/model-typings/src/models/IInvitesModel.ts +++ b/packages/model-typings/src/models/IInvitesModel.ts @@ -5,6 +5,8 @@ import type { IBaseModel } from './IBaseModel'; export interface IInvitesModel extends IBaseModel { findOneByUserRoomMaxUsesAndExpiration(userId: string, rid: string, maxUses: number, daysToExpire: number): Promise; + findOneByInviteToken(inviteToken: string): Promise; increaseUsageById(_id: string, uses: number): Promise; countUses(): Promise; + ensureInviteToken(_id: string): Promise; } diff --git a/packages/models/src/models/Invites.ts b/packages/models/src/models/Invites.ts index 982c76aa71506..2b635a6bde200 100644 --- a/packages/models/src/models/Invites.ts +++ b/packages/models/src/models/Invites.ts @@ -1,3 +1,5 @@ +import crypto from 'node:crypto'; + import type { IInvite, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IInvitesModel } from '@rocket.chat/model-typings'; import type { Collection, Db, UpdateResult } from 'mongodb'; @@ -20,6 +22,10 @@ export class InvitesRaw extends BaseRaw implements IInvitesModel { }); } + findOneByInviteToken(inviteToken: string): Promise { + return this.findOne({ inviteToken }); + } + increaseUsageById(_id: string, uses = 1): Promise { return this.updateOne( { _id }, @@ -36,4 +42,23 @@ export class InvitesRaw extends BaseRaw implements IInvitesModel { return result?.totalUses || 0; } + + async ensureInviteToken(_id: string): Promise { + const inviteToken = crypto.randomUUID(); + + const result = await this.updateOne({ _id, inviteToken: { $exists: false } }, { $set: { inviteToken } }); + + if (result.modifiedCount > 0) { + return inviteToken; + } + + const invite = await this.findOneById(_id, { projection: { inviteToken: 1 } }); + if (!invite) { + throw new Error(`Invite with _id ${_id} not found`); + } + if (!invite.inviteToken) { + throw new Error(`Invite with _id ${_id} exists but has no inviteToken`); + } + return invite.inviteToken; + } }