feat(admin): move pending registrations to their own page (#1388)

This commit is contained in:
ArcticWarmth authored and GitHub committed 2026-10-14 13:45:27 +02:00
1 parent 97b2ac91ca
commit abcb5cb814
31 files changed
+839 -328

No files matched your search

@@ -39,6 +39,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
LIST_GUILD_STICKERS: 'list_guild_stickers',
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
LIST_REPORT_REASONS: 'list_report_reasons',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
@@ -34,6 +34,7 @@ import {
InstanceEmailSmtpTestRequest,
InstanceEmailSmtpTestResponse,
PendingRegistrationActionRequest,
PendingRegistrationListResponse,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
@@ -98,7 +99,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
experimentDelivery,
registrationConfig,
registrationUrls,
pendingRegistrations,
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
@@ -109,7 +109,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
]);
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
await Promise.all([
@@ -148,7 +147,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
registration: {
...registrationConfig,
urls: registrationUrls,
pending_registrations: pendingRegistrations,
},
self_hosted: Config.instance.selfHosted,
account_identity: {
@@ -320,10 +318,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
metadata: {
registration_url_count: response.registration.urls.length,
pending_registration_count: response.registration.pending_registrations.length,
},
metadata: {registration_url_count: response.registration.urls.length},
});
return ctx.json(response);
},
@@ -738,18 +733,43 @@ export function InstanceConfigAdminController(app: HonoApp) {
return ctx.json(await buildInstanceConfigResponse());
},
);
app.get(
'/admin/instance/pending-registrations',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
OpenAPI({
operationId: 'list_admin_pending_registrations',
summary: 'List pending registrations',
description:
'Lists every registration waiting for manual review, oldest first. Requires REGISTRATION_REVIEW permission.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const pendingRegistrations = await instanceConfigRepository.getPendingRegistrations();
await recordAdminRead(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.LIST_PENDING_REGISTRATIONS,
metadata: {pending_registration_count: pendingRegistrations.length},
});
return ctx.json({pending_registrations: pendingRegistrations});
},
);
app.patch(
'/admin/instance/pending-registrations/:user_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
Validator('param', UserIdParam),
Validator('json', PendingRegistrationActionRequest),
OpenAPI({
operationId: 'update_admin_pending_registration',
summary: 'Approve or reject a pending registration',
description:
'Decides a registration waiting for manual review. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.',
responseSchema: InstanceConfigResponse,
'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.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -759,7 +779,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await updatePendingRegistrationUser(ctx, userId, decision);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
},
);
}
@@ -8,9 +8,10 @@ import {
RequireEmailAccountIdentity,
RequireUsernameAccountIdentity,
} from '@app/api/middleware/AccountIdentityMiddleware';
import {requireAdminACL, requireGrantedAdminACLs} from '@app/api/middleware/AdminMiddleware';
import {hasAdminACL, requireAdminACL, requireGrantedAdminACLs} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator';
@@ -94,14 +95,15 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.',
'Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account, plus the number of pending registrations when the admin holds REGISTRATION_REVIEW. Requires AUTHENTICATE permission.',
}),
async (ctx) => {
const adminUser = ctx.get('user');
const cacheService = ctx.get('cacheService');
return ctx.json({
user: await mapUserToAdminResponse(adminUser, cacheService),
});
const canReviewRegistrations = hasAdminACL(ctx.get('adminUserAcls'), AdminACLs.REGISTRATION_REVIEW);
const [user, pendingRegistrations] = await Promise.all([
mapUserToAdminResponse(ctx.get('user'), ctx.get('cacheService')),
canReviewRegistrations ? getInstanceConfigRepository().getPendingRegistrations() : null,
]);
return ctx.json({user, pending_registration_count: pendingRegistrations?.length ?? null});
},
);
app.get(
@@ -13,7 +13,9 @@ import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {AdminUsersMeResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface PendingRegistrationResponse {
user_id: string;
@@ -37,7 +39,7 @@ describe('pending registration approval and the stock community', () => {
it('keeps a banned user out of the stock community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await getInstanceConfigRepository().setInstancePolicyConfig({
@@ -79,7 +81,7 @@ describe('pending registration approval and the stock community', () => {
it('does not add a user who was never pending to the stock community', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const outsider = await createTestAccount(harness);
@@ -99,4 +101,59 @@ describe('pending registration approval and the stock community', () => {
.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,
AdminACLs.REGISTRATION_REVIEW,
]);
const configAdmin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('reviewonly'),
username: createUniqueUsername('reviewonly'),
global_name: 'Review only',
password: 'reviewing-since-1999',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations.map((entry) => entry.user_id)).toEqual([pending.user_id]);
const reviewerMe = await createBuilder<AdminUsersMeResponse>(harness, reviewer.token)
.get('/admin/users/@me')
.execute();
expect(reviewerMe.pending_registration_count).toBe(1);
const configAdminMe = await createBuilder<AdminUsersMeResponse>(harness, configAdmin.token)
.get('/admin/users/@me')
.execute();
expect(configAdminMe.pending_registration_count).toBeNull();
await createBuilder(harness, configAdmin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
await createBuilder(harness, configAdmin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
const decided = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'rejected'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.pending_registrations).toEqual([]);
});
});
@@ -35,7 +35,7 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
action: 'get_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {registration_url_count: '1', pending_registration_count: '0'},
metadata: {registration_url_count: '1'},
},
};
},
@@ -129,6 +129,30 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'GET',
route: '/admin/instance/pending-registrations',
async prepare({harness}) {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await registerUser(harness, {
email: createUniqueEmail('coverage-listed'),
username: createUniqueUsername('coveragelisted'),
global_name: 'Coverage Listed',
password: 'coverage-listed-password',
date_of_birth: '2000-01-01',
consent: true,
});
return {
request: {path: '/admin/instance/pending-registrations'},
expected: {
action: 'list_pending_registrations',
targetType: 'instance_config',
targetId: '0',
metadata: {pending_registration_count: '1'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/instance/pending-registrations/:user_id',
@@ -23,7 +23,10 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {
InstanceConfigResponse,
PendingRegistrationListResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -62,6 +65,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
});
@@ -82,6 +86,13 @@ describe('signups racing on registration URLs and pending approvals', () => {
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const readPendingRegistrations = async (): Promise<PendingRegistrationListResponse['pending_registrations']> =>
(
await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.execute()
).pending_registrations;
const completeSso = async (prefix: string) => {
const start = await createBuilderWithoutAuth<{state: string}>(harness)
.post('/auth/sso/start')
@@ -116,7 +127,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
};
const expectOnePendingAccount = async () => {
const pending = (await readAdminConfig()).registration.pending_registrations;
const pending = await readPendingRegistrations();
expect(pending).toHaveLength(1);
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
@@ -209,8 +220,8 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('seatreleased', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect(await readPendingRegistrations()).toEqual([]);
const withdrawn = await readAdminConfig();
expect(withdrawn.registration.pending_registrations).toEqual([]);
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
const retried = await register('seatreleasedretry', code);
@@ -307,7 +318,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`pending${index}`)));
expect(attempts.map((attempt) => attempt.json.registration_pending_approval)).toEqual(Array(5).fill(true));
const pending = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
const pending = (await readPendingRegistrations()).map((entry) => entry.user_id);
expect(pending.toSorted()).toEqual(attempts.map((attempt) => attempt.json.user_id).toSorted());
});
@@ -340,7 +351,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('pendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
expect(await readPendingRegistrations()).toHaveLength(1);
});
it('lists no pending approval for an approval-mode signup that failed before the account was created', async () => {
@@ -350,7 +361,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('pendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
expect(await readPendingRegistrations()).toEqual([]);
});
it('lists an SSO account provisioned in approval mode whose provisioning failed after the account was created', async () => {
@@ -383,7 +394,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await completeSso('ssopendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
expect(await readPendingRegistrations()).toHaveLength(1);
});
it('lists no pending approval for an SSO signup in approval mode that failed before the account was created', async () => {
@@ -394,7 +405,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await completeSso('ssopendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
expect(await readPendingRegistrations()).toEqual([]);
});
it('keeps a stored pending approval listed until an admin decides it', async () => {
@@ -417,16 +428,16 @@ describe('signups racing on registration URLs and pending approvals', () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
const listed = (await readPendingRegistrations()).map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const decided = await createBuilder<InstanceConfigResponse>(harness, admin.token)
const decided = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.registration.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(decided.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(
JSON.parse(
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
@@ -61,7 +61,7 @@ import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
type InstanceEmailSmtpTestRequest,
type PendingRegistrationResponse,
PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
@@ -1053,7 +1053,6 @@ function parseStoredRegistrationConfig(raw: string | null): InstanceRegistration
}
const RegistrationUrlSchema = InstanceConfigResponse.shape.registration.shape.urls.element;
const PendingRegistrationSchema = InstanceConfigResponse.shape.registration.shape.pending_registrations.element;
const StoredRegistrationTimestampSchema = z.iso
.datetime({offset: true})
.refine((value) => value === value.trim() && Number.isFinite(Date.parse(value)))
@@ -1086,9 +1085,9 @@ const StoredRegistrationUrlSchema = RegistrationUrlSchema.extend({
last_used_at: StoredRegistrationTimestampSchema.nullable().default(null),
last_used_by_user_id: StoredNullableStringSchema.pipe(StoredSnowflakeStringSchema.nullable()),
}) satisfies z.ZodType<InstanceRegistrationUrl>;
const StoredPendingRegistrationSchema = PendingRegistrationSchema.extend({
const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
user_id: StoredSnowflakeStringSchema,
discriminator: PendingRegistrationSchema.shape.discriminator.default(0),
discriminator: PendingRegistrationResponse.shape.discriminator.default(0),
global_name: StoredNullableStringSchema,
email: StoredNullableStringSchema,
requested_at: StoredRegistrationTimestampSchema,