From bf1e1778858ae4e9f6665b5312def827964a43cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:00:30 +0000 Subject: [PATCH] Add branded email verification page with localization support Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- locales/en-US.yml | 3 + locales/it-IT.yml | 3 + locales/ja-JP.yml | 3 + packages/backend/src/server/ServerService.ts | 23 +-- .../backend/src/server/api/endpoint-list.ts | 1 + .../src/server/api/endpoints/verify-email.ts | 78 ++++++++++ packages/frontend/src/pages/verify-email.vue | 146 ++++++++++++++++++ packages/frontend/src/router.definition.ts | 3 + 8 files changed, 239 insertions(+), 21 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/verify-email.ts create mode 100644 packages/frontend/src/pages/verify-email.vue diff --git a/locales/en-US.yml b/locales/en-US.yml index 395540ea01..5703913a33 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -776,6 +776,9 @@ highlightSensitiveMedia: "Highlight sensitive media" verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification." notSet: "Not set" emailVerified: "Email has been verified" +emailVerifiedDescription: "Your email address has been successfully verified. You can now close this page." +emailVerificationFailed: "Email verification failed" +emailVerificationFailedDescription: "The verification link is invalid or has expired. Please try again." noteFavoritesCount: "Number of favorite notes" pageLikesCount: "Number of liked Pages" pageLikedCount: "Number of received Page likes" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 81487c4d79..88629492bf 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -776,6 +776,9 @@ highlightSensitiveMedia: "Evidenzia i media espliciti" verificationEmailSent: "Una mail di verifica è stata inviata. Si prega di accedere al collegamento per compiere la verifica." notSet: "Non impostato" emailVerified: "Il tuo indirizzo email è stato verificato" +emailVerifiedDescription: "Il tuo indirizzo email è stato verificato con successo. Ora puoi chiudere questa pagina." +emailVerificationFailed: "Verifica email fallita" +emailVerificationFailedDescription: "Il link di verifica non è valido o è scaduto. Riprova di nuovo." noteFavoritesCount: "Conteggio note tra i preferiti" pageLikesCount: "Numero di pagine che ti piacciono" pageLikedCount: "Numero delle tue pagine che hanno ricevuto \"Mi piace\"" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d04445282a..886b46d6d5 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -776,6 +776,9 @@ highlightSensitiveMedia: "メディアがセンシティブであることを分 verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。" notSet: "未設定" emailVerified: "メールアドレスが確認されました" +emailVerifiedDescription: "メールアドレスの認証が完了しました。ページを閉じてお進みください。" +emailVerificationFailed: "メールアドレスの認証に失敗しました" +emailVerificationFailedDescription: "認証リンクが無効か期限切れです。もう一度お試しください。" noteFavoritesCount: "お気に入りノートの数" pageLikesCount: "Pageにいいねした数" pageLikedCount: "Pageにいいねされた数" diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 23c085ee27..f84d363fac 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -239,27 +239,8 @@ export class ServerService implements OnApplicationShutdown { }); fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => { - const profile = await this.userProfilesRepository.findOneBy({ - emailVerifyCode: request.params.code, - }); - - if (profile != null) { - await this.userProfilesRepository.update({ userId: profile.userId }, { - emailVerified: true, - emailVerifyCode: null, - }); - - this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { - schema: 'MeDetailed', - includeSecrets: true, - })); - - reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。'); - return; - } else { - reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください'); - return; - } + // Redirect to frontend page for verification + reply.redirect(`/verify-email/${request.params.code}`); }); fastify.register(this.clientServerService.createServer); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index c0c43dd5c9..4b5b34648c 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -382,6 +382,7 @@ export * as 'sw/update-registration' from './endpoints/sw/update-registration.js export * as 'test' from './endpoints/test.js'; export * as 'username/available' from './endpoints/username/available.js'; export * as 'users' from './endpoints/users.js'; +export * as 'verify-email' from './endpoints/verify-email.js'; export * as 'users/achievements' from './endpoints/users/achievements.js'; export * as 'users/clips' from './endpoints/users/clips.js'; export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; diff --git a/packages/backend/src/server/api/endpoints/verify-email.ts b/packages/backend/src/server/api/endpoints/verify-email.ts new file mode 100644 index 0000000000..6aab486399 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/verify-email.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserProfilesRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApiError } from '../error.js'; + +export const meta = { + requireCredential: false, + + tags: ['account'], + + errors: { + noSuchCode: { + message: 'No such code.', + code: 'NO_SUCH_CODE', + id: '97c1f576-e4b8-4b8a-a6dc-9cb65e7f6f85', + }, + }, + + res: { + type: 'object', + properties: { + success: { + type: 'boolean', + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + code: { type: 'string' }, + }, + required: ['code'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + const profile = await this.userProfilesRepository.findOneBy({ + emailVerifyCode: ps.code, + }); + + if (profile == null) { + throw new ApiError(meta.errors.noSuchCode); + } + + await this.userProfilesRepository.update({ userId: profile.userId }, { + emailVerified: true, + emailVerifyCode: null, + }); + + this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { + schema: 'MeDetailed', + includeSecrets: true, + })); + + return { + success: true, + }; + }); + } +} \ No newline at end of file diff --git a/packages/frontend/src/pages/verify-email.vue b/packages/frontend/src/pages/verify-email.vue new file mode 100644 index 0000000000..25a59aaae7 --- /dev/null +++ b/packages/frontend/src/pages/verify-email.vue @@ -0,0 +1,146 @@ + + + + + + + \ No newline at end of file diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index 57d9a860d6..c30b0d9d96 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -199,6 +199,9 @@ export const ROUTE_DEF = [{ }, { path: '/reset-password/:token?', component: page(() => import('@/pages/reset-password.vue')), +}, { + path: '/verify-email/:code', + component: page(() => import('@/pages/verify-email.vue')), }, { path: '/signup-complete/:code', component: page(() => import('@/pages/signup-complete.vue')),