Skip to content
44 changes: 41 additions & 3 deletions apps/meteor/app/api/server/v1/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,46 @@ const invites = API.v1
{
authRequired: true,
response: {
200: ajv.compile<IInvite[]>({
200: ajv.compile<Omit<IInvite, 'inviteToken'>[]>({
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({
Expand Down Expand Up @@ -73,6 +108,9 @@ const invites = API.v1
_id: {
type: 'string',
},
inviteToken: {
type: 'string',
},
rid: {
type: 'string',
},
Expand Down Expand Up @@ -106,7 +144,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,
Expand Down
16 changes: 11 additions & 5 deletions apps/meteor/app/invites/server/functions/findOrCreateInvite.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,12 +12,12 @@ import { settings } from '../../../settings/server';
import { getURL } from '../../../utils/server/getURL';

function getInviteUrl(invite: Omit<IInvite, '_updatedAt'>) {
const { _id } = invite;
const { inviteToken } = invite;

const useDirectLink = settings.get<string>('Accounts_Registration_InviteUrlType') === 'direct';

return getURL(
`invite/${_id}`,
`invite/${inviteToken}`,
{
full: useDirectLink,
cloud: !useDirectLink,
Expand Down Expand Up @@ -89,13 +90,17 @@ export const findOrCreateInvite = async (userId: string, invite: Pick<IInvite, '
// Before anything, let's check if there's an existing invite with the same settings for the same channel and user and that has not yet expired.
const existing = await Invites.findOneByUserRoomMaxUsesAndExpiration(userId, invite.rid, maxUses, days);

// If an existing invite was found, return it's _id instead of creating a new one.
// If an existing invite was found, ensure it has an inviteToken and return it
if (existing) {
// Ensure the invite has an inviteToken (handles legacy invites atomically)
const inviteToken = await Invites.ensureInviteToken(existing._id);
existing.inviteToken = inviteToken;
existing.url = getInviteUrl(existing);
return existing;
}
Comment thread
julio-rocketchat marked this conversation as resolved.

const _id = Random.id(6);
const _id = crypto.randomBytes(8).toString('hex');
const inviteToken = crypto.randomUUID();

// insert invite
const createdAt = new Date();
Expand All @@ -107,6 +112,7 @@ export const findOrCreateInvite = async (userId: string, invite: Pick<IInvite, '

const createInvite: Omit<IInvite, '_updatedAt'> = {
_id,
inviteToken,
days,
maxUses,
rid: invite.rid,
Expand Down
20 changes: 19 additions & 1 deletion apps/meteor/app/invites/server/functions/listInvites.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IInvite } from '@rocket.chat/core-typings';
import { Invites } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

Expand All @@ -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;
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/client/views/admin/invites/InviteRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const isExpired = (expires: IInvite['expires']): boolean => {
return false;
};

type InviteRowProps = Omit<IInvite, 'createdAt' | 'expires' | '_updatedAt'> & {
type InviteRowProps = Omit<IInvite, 'createdAt' | 'expires' | '_updatedAt' | 'inviteToken'> & {
onRemove: (removeInvite: () => Promise<boolean>) => void;
_updatedAt: string;
createdAt: string;
Expand Down
5 changes: 4 additions & 1 deletion apps/meteor/client/views/admin/invites/InvitesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const InvitesPage = (): ReactElement => {
const headers = useMemo(
() => (
<>
<GenericTableHeaderCell w={notSmall ? '20%' : '80%'}>{t('Token')}</GenericTableHeaderCell>
<GenericTableHeaderCell w={notSmall ? '20%' : '80%'}>{t('Invite')}</GenericTableHeaderCell>
{notSmall && (
<>
<GenericTableHeaderCell w='35%'>{t('Created_at')}</GenericTableHeaderCell>
Expand Down Expand Up @@ -100,6 +100,7 @@ const InvitesPage = (): ReactElement => {
</GenericTableBody>
</GenericTable>
)}

{isSuccess && data && data.length > 0 && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
Expand All @@ -111,7 +112,9 @@ const InvitesPage = (): ReactElement => {
</GenericTableBody>
</GenericTable>
)}

{isSuccess && data && data.length === 0 && <GenericNoResults />}

{isError && (
<States>
<StatesIcon name='warning' variation='danger' />
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/tests/e2e/saml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/tests/end-to-end/api/abac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
46 changes: 41 additions & 5 deletions apps/meteor/tests/end-to-end/api/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]', () => {
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand Down Expand Up @@ -148,19 +154,34 @@ 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) => {
expect(res.body).to.have.property('success', true);
})
.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]', () => {
Expand All @@ -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) => {
Expand All @@ -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]', () => {
Expand Down
17 changes: 9 additions & 8 deletions apps/meteor/tests/end-to-end/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,7 @@ describe('[Users]', () => {
let user3Credentials: Credentials;
let group: IRoom;
let inviteToken: string;
let inviteId: string;

before(async () => {
const username = `deactivated_${Date.now()}${apiUsername}`;
Expand Down Expand Up @@ -1368,18 +1369,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(),
);
Expand Down
1 change: 1 addition & 0 deletions packages/core-typings/src/IInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IRocketChatRecord } from './IRocketChatRecord';

export interface IInvite extends IRocketChatRecord {
days: number;
inviteToken: string;
maxUses: number;
rid: string;
userId: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/model-typings/src/models/IInvitesModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { IBaseModel } from './IBaseModel';

export interface IInvitesModel extends IBaseModel<IInvite> {
findOneByUserRoomMaxUsesAndExpiration(userId: string, rid: string, maxUses: number, daysToExpire: number): Promise<IInvite | null>;
findOneByInviteToken(inviteToken: string): Promise<IInvite | null>;
increaseUsageById(_id: string, uses: number): Promise<UpdateResult>;
countUses(): Promise<number>;
ensureInviteToken(_id: string): Promise<string>;
}
Loading
Loading