fix(auth): fix approval signups and auto-join invites (#3467)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 16:41:33 +02:00
1 parent e403fb6cce
commit d5ca5d8293
64 files changed
+622 -72

No files matched your search

@@ -3,6 +3,7 @@
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
@@ -768,7 +769,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
operationId: 'update_admin_pending_registration',
summary: 'Approve or reject a pending registration',
description:
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -777,7 +778,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await updatePendingRegistrationUser(ctx, userId, decision);
const pendingRegistration = (await instanceConfigRepository.getPendingRegistrations()).find(
(entry) => entry.user_id === userId,
);
await updatePendingRegistrationUser(ctx, userId, decision, pendingRegistration?.invite_code ?? null);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
},
@@ -875,6 +879,7 @@ async function updatePendingRegistrationUser(
ctx: Context<HonoEnv>,
userId: string,
decision: 'approve' | 'reject',
inviteCode: string | null,
): Promise<void> {
const userRepository = ctx.get('userRepository');
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
@@ -896,6 +901,11 @@ async function updatePendingRegistrationUser(
}
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
if (decision === 'approve' && wasPendingApproval) {
await maybeAutoJoinInvite(ctx.get('inviteService'), {
userId: user.id,
inviteCode,
requestCache: ctx.get('requestCache'),
});
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
}
await recordAdminWrite(ctx, {
@@ -6,7 +6,8 @@ import {
createUniqueUsername,
setUserACLs,
} from '@app/api/auth/tests/AuthTestUtils';
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {Config} from '@app/api/Config';
import {createChannelInvite, createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -21,6 +22,18 @@ interface PendingRegistrationResponse {
user_id: string;
}
function invitedRegistrationBody(prefix: string, inviteCode?: string): Record<string, unknown> {
return {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Invited person',
password: 'invited-since-1999',
date_of_birth: '2000-01-01',
consent: true,
invite_code: inviteCode,
};
}
describe('pending registration approval and the stock community', () => {
let harness: ApiTestHarness;
@@ -102,6 +115,198 @@ describe('pending registration approval and the stock community', () => {
.execute();
});
it('joins the invited community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedapproval', invite.code))
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
});
it('lists the invite code on a pending registration', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedlisted', invite.code))
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations).toEqual([
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
]);
});
it('does not join the invited community on rejection', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedrejected', invite.code))
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'rejected'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('joins the auto-join community on approval of a registration without an invite', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('autojoinapproval'))
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations).toEqual([
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
]);
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
it('does not join the auto-join community when the pending entry has no invite', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('noinviteapproval'))
.execute();
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('approves and joins the single community when the invite is gone', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild: stockGuild} = await setupTestGuildWithMembers(harness, 0);
const invitedGuild = await createGuild(harness, owner.token, 'Invited Guild');
const invite = await createChannelInvite(harness, owner.token, invitedGuild.system_channel_id!);
await getInstanceConfigRepository().setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: stockGuild.id,
});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitegone', invite.code))
.execute();
await createBuilder(harness, owner.token)
.delete(`/invites/${invite.code}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${stockGuild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${invitedGuild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('lets the review ACL alone list and decide registrations', async () => {
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,