From 08eaad6f7677604af2f3dc5eb15bfc91fc165565 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Wed, 18 Feb 2026 19:04:25 +0000 Subject: [PATCH] feat(admin): add resend verification email op --- packages/api/src/admin/AdminService.tsx | 9 ++++++ .../admin/controllers/UserAdminController.tsx | 25 ++++++++++++++++ .../services/AdminUserSecurityService.tsx | 29 +++++++++++++++++++ .../src/admin/services/AdminUserService.tsx | 9 ++++++ .../src/domains/admin/AdminUserSchemas.tsx | 6 ++++ 5 files changed, 78 insertions(+) diff --git a/packages/api/src/admin/AdminService.tsx b/packages/api/src/admin/AdminService.tsx index a4655fed..029d6013 100644 --- a/packages/api/src/admin/AdminService.tsx +++ b/packages/api/src/admin/AdminService.tsx @@ -106,6 +106,7 @@ import type { ListUserDmChannelsRequest, ListWebAuthnCredentialsRequest, LookupUserRequest, + ResendVerificationEmailRequest, SendPasswordResetRequest, SetUserAclsRequest, SetUserBotStatusRequest, @@ -378,6 +379,14 @@ export class AdminService { return this.userService.verifyUserEmail(data, adminUserId, auditLogReason); } + async resendVerificationEmail( + data: ResendVerificationEmailRequest, + adminUserId: UserID, + auditLogReason: string | null, + ) { + return this.userService.resendVerificationEmail(data, adminUserId, auditLogReason); + } + async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) { return this.userService.sendPasswordReset(data, adminUserId, auditLogReason); } diff --git a/packages/api/src/admin/controllers/UserAdminController.tsx b/packages/api/src/admin/controllers/UserAdminController.tsx index 5cd60f84..e5c6908a 100644 --- a/packages/api/src/admin/controllers/UserAdminController.tsx +++ b/packages/api/src/admin/controllers/UserAdminController.tsx @@ -46,6 +46,7 @@ import { ListWebAuthnCredentialsRequest, LookupUserRequest, LookupUserResponse, + ResendVerificationEmailRequest, ScheduleAccountDeletionRequest, SendPasswordResetRequest, SetUserAclsRequest, @@ -312,6 +313,30 @@ export function UserAdminController(app: HonoApp) { }, ); + app.post( + '/admin/users/resend-verification-email', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.USER_UPDATE_EMAIL), + Validator('json', ResendVerificationEmailRequest), + OpenAPI({ + operationId: 'admin_resend_verification_email', + summary: 'Resend verification email', + responseSchema: null, + statusCode: 204, + security: 'adminApiKey', + tags: 'Admin', + description: + 'Resend the account verification email for a user. Creates audit log entry and honours email verification resend limits. Requires USER_UPDATE_EMAIL permission.', + }), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + await adminService.resendVerificationEmail(ctx.req.valid('json'), adminUserId, auditLogReason); + return ctx.body(null, 204); + }, + ); + app.post( '/admin/users/send-password-reset', RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), diff --git a/packages/api/src/admin/services/AdminUserSecurityService.tsx b/packages/api/src/admin/services/AdminUserSecurityService.tsx index d2eb7254..bb9c5f3e 100644 --- a/packages/api/src/admin/services/AdminUserSecurityService.tsx +++ b/packages/api/src/admin/services/AdminUserSecurityService.tsx @@ -41,6 +41,7 @@ import type { DisableForSuspiciousActivityRequest, DisableMfaRequest, ListWebAuthnCredentialsRequest, + ResendVerificationEmailRequest, SendPasswordResetRequest, SetUserAclsRequest, SetUserTraitsRequest, @@ -186,6 +187,34 @@ export class AdminUserSecurityService { }); } + async resendVerificationEmail( + data: ResendVerificationEmailRequest, + adminUserId: UserID, + auditLogReason: string | null, + ) { + const {userRepository, authService, auditService} = this.deps; + const userId = createUserID(data.user_id); + const user = await userRepository.findUnique(userId); + if (!user) { + throw new UnknownUserError(); + } + + if (!user.email) { + throw InputValidationError.fromCode('email', ValidationErrorCodes.USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS); + } + + await authService.resendVerificationEmail(user); + + await auditService.createAuditLog({ + adminUserId, + targetType: 'user', + targetId: BigInt(userId), + action: 'resend_verification_email', + auditLogReason, + metadata: new Map([['email', user.email]]), + }); + } + async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) { const {userRepository, authService, auditService} = this.deps; const userId = createUserID(data.user_id); diff --git a/packages/api/src/admin/services/AdminUserService.tsx b/packages/api/src/admin/services/AdminUserService.tsx index 55cf6d97..91c3d382 100644 --- a/packages/api/src/admin/services/AdminUserService.tsx +++ b/packages/api/src/admin/services/AdminUserService.tsx @@ -55,6 +55,7 @@ import type { ListUserDmChannelsRequest, ListWebAuthnCredentialsRequest, LookupUserRequest, + ResendVerificationEmailRequest, ScheduleAccountDeletionRequest, SendPasswordResetRequest, SetUserAclsRequest, @@ -221,6 +222,14 @@ export class AdminUserService { return this.securityService.sendPasswordReset(data, adminUserId, auditLogReason); } + async resendVerificationEmail( + data: ResendVerificationEmailRequest, + adminUserId: UserID, + auditLogReason: string | null, + ) { + return this.securityService.resendVerificationEmail(data, adminUserId, auditLogReason); + } + async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) { return this.securityService.terminateSessions(data, adminUserId, auditLogReason); } diff --git a/packages/schema/src/domains/admin/AdminUserSchemas.tsx b/packages/schema/src/domains/admin/AdminUserSchemas.tsx index c0132bf0..ca7587cb 100644 --- a/packages/schema/src/domains/admin/AdminUserSchemas.tsx +++ b/packages/schema/src/domains/admin/AdminUserSchemas.tsx @@ -272,6 +272,12 @@ export const VerifyUserEmailRequest = z.object({ export type VerifyUserEmailRequest = z.infer; +export const ResendVerificationEmailRequest = z.object({ + user_id: SnowflakeType.describe('ID of the user to resend verification email to'), +}); + +export type ResendVerificationEmailRequest = z.infer; + export const SendPasswordResetRequest = z.object({ user_id: SnowflakeType.describe('ID of the user to send password reset to'), });