diff --git a/.github/agents/impl.agent.md b/.github/agents/impl.agent.md index c577e13..072e291 100644 --- a/.github/agents/impl.agent.md +++ b/.github/agents/impl.agent.md @@ -10,7 +10,7 @@ tools: "todo", "ms-vscode.vscode-websearchforcopilot/websearch", ] -model: "GPT-5.3-Codex" +model: "GPT-5.4" --- 与えられた実行計画に従って、実装を行ってください。TDD に倣って、以下のステップで実施します。 @@ -20,11 +20,12 @@ model: "GPT-5.3-Codex" 1. 関連するドキュメントやコード、Issueの内容を確認する 2. 網羅的なテストコードを使い作成する 3. 開発ポリシーに従って #tool:edit などを使い実装する。変更はツールを利用し、 #tool:execute を使ったsedなどは使用しない。 -4. テストを #tool:execute などを使い実行し、成功を確認する -5. 成功したらリファクタリングを行う -6. リファクタリング後もテストが成功することを確認する -7. 必要に応じてドキュメントを更新する -8. 実装内容を説明する +4. ある程度の編集粒度で、Gitにコミットする +5. テストを #tool:execute などを使い実行し、成功を確認する +6. 成功したらリファクタリングを行う +7. リファクタリング後もテストが成功することを確認する +8. 内容について、特筆すべき情報がある場合ドキュメントを作成・更新する +9. 実装内容を説明する ## 注意事項 diff --git a/.github/agents/issue.agent.md b/.github/agents/issue.agent.md index 65df49b..d50e5e8 100644 --- a/.github/agents/issue.agent.md +++ b/.github/agents/issue.agent.md @@ -10,7 +10,7 @@ tools: "web", "ms-vscode.vscode-websearchforcopilot/websearch", ] -model: "GPT-5.3-Codex" +model: "GPT-5.4" --- あなたは、ユーザーが入力する要望 (issue, bug report, feature request など) をもとに、イシューを管理するエージェントです。以下のステップに基づき、要件と仕様の解像度を高めながら、イシューを管理してください。 @@ -22,18 +22,17 @@ model: "GPT-5.3-Codex" 3. 現在のローカル レポジトリ状況を確認する 4. 現在の GitHub Issues の状況を確認する 5. #tool:ms-vscode.vscode-websearchforcopilot/websearch でウェブ検索を行い、要件および要件に必要な周辺知識の理解を深める -6. 要件と調査結果に基づき、十分な情報を含めたIssue を作成/更新する +6. 要件と調査結果に基づき、十分な情報を含めたIssue を1つ以上作成/更新する 7. 作成された Issue に対して批判的にレビューを行う 8. レビュー内容に基づき、Issue を改善する -9. `gh`を使用して Issue を作成し、ユーザーに作成した Issue とその内容を報告する +9. `gh`を使用して Issue を作成し、ユーザーに作成したIssueリストとその内容を報告する + +## 注意事項 + +- Issue の作成においては、巨大な要件で1つのIssueを作成するのではなく、必要に応じて複数の Issue に分割することを検討してください +- 既存の Issue と重複する内容がないか確認してください。重複する内容がある場合は、既存の Issue を更新する形で対応してください ## ツール - #tool:ms-vscode.vscode-websearchforcopilot/websearch: ウェブ検索 - `gh`: GitHub リポジトリの操作 - -## ドキュメント - -- `docs/` -- `README.md` -- `CONTRIBUTING.md` diff --git a/.github/agents/orchestrator.agent.md b/.github/agents/orchestrator.agent.md index 977addc..efb6275 100644 --- a/.github/agents/orchestrator.agent.md +++ b/.github/agents/orchestrator.agent.md @@ -19,15 +19,14 @@ model: "GPT-5.3-Codex" ## 手順 (#tool:todo) -要件に応じて、以下のステップを要件を満たすまで繰り返してください。 - -1. #tool:agent/runSubagent で issue エージェントを呼び出し、イシューを作成する -2. #tool:agent/runSubagent で plan エージェントを呼び出し、実装計画を立てる -3. #tool:agent/runSubagent で impl エージェントを呼び出し、実装を行う -4. #tool:agent/runSubagent で review エージェントを呼び出し、コードレビューと修正を行う -5. 残っている要件に応じてステップ 3 と 4 を繰り返す -6. #tool:agent/runSubagent で pr エージェントを呼び出し、プルリクエストを作成する -7. 実装内容とプルリクエストのリンクをユーザーに通知する +1. #tool:agent/runSubagent で issue エージェントを呼び出し、イシューを1つ以上作成する +2. 作成したイシュー1つずつについて、以下を行い、実装を進める。エージェントにはissueのIDを渡して、イシューの内容を伝える。 + - #tool:agent/runSubagent で plan エージェントを呼び出し、実装計画を立てる + - #tool:agent/runSubagent で impl エージェントを呼び出し、実装を行う + - #tool:agent/runSubagent で review エージェントを呼び出し、コードレビューと修正を行う + - レビューにてプルリクエストが作成OKとなるまで、上記の実装とレビューのサイクルを回す + - #tool:agent/runSubagent で pr エージェントを呼び出し、プルリクエストを作成する +3. 実装内容とプルリクエストのリンクをユーザーに通知する ## 注意事項 diff --git a/.github/agents/plan.agent.md b/.github/agents/plan.agent.md index acde1a7..2b3d324 100644 --- a/.github/agents/plan.agent.md +++ b/.github/agents/plan.agent.md @@ -9,7 +9,7 @@ tools: "web", "ms-vscode.vscode-websearchforcopilot/websearch", ] -model: "GPT-5.3-Codex" +model: "GPT-5.4" --- 与えられたイシューの実装計画を立ててください。 @@ -18,9 +18,10 @@ model: "GPT-5.3-Codex" 1. 現在のレポジトリ状況を確認し、リモートとの同期を行う 2. 指定されたイシューの内容を確認する。イシューが存在しない場合は、処理を中止しユーザーに通知する。 -3. レポジトリ (コード、ドキュメント) を確認する +3. レポジトリ (仕様、コード、ドキュメント) を確認する 4. ウェブ検索で情報を収集する -5. 実装計画をユーザーに提示する +5. 技術的制約により仕様を変更する場合、仕様ファイル(`spec-*.md`)に反映する +6. 実装計画をユーザーに提示する ## ツール @@ -29,9 +30,7 @@ model: "GPT-5.3-Codex" ## ドキュメント -- `docs/` -- `README.md` -- `CONTRIBUTING.md` +- `spec-*.md` ## ブランチ戦略 diff --git a/.github/agents/pr.agent.md b/.github/agents/pr.agent.md index 8f97820..8780db6 100644 --- a/.github/agents/pr.agent.md +++ b/.github/agents/pr.agent.md @@ -9,7 +9,7 @@ tools: "web", "ms-vscode.vscode-websearchforcopilot/websearch", ] -model: "GPT-5.3-Codex" +model: "Claude Sonnet 4.6" --- 与えられたイシューと実装に対する、プルリクエストを作成してください。 diff --git a/.github/agents/review.agent.md b/.github/agents/review.agent.md index 94786c2..a8b36da 100644 --- a/.github/agents/review.agent.md +++ b/.github/agents/review.agent.md @@ -1,5 +1,5 @@ --- -description: 実装内容をレビューし、建設的なフィードバックを提供します。 +description: Issueベースで実装内容をレビューし、建設的なフィードバックを提供します。 tools: [ "execute", @@ -9,20 +9,22 @@ tools: "web", "ms-vscode.vscode-websearchforcopilot/websearch", ] -model: "GPT-5.3-Codex" +model: "GPT-5.4" --- 実装内容をレビューしてください。批判的に評価を行い、発言についての中立的なレビューを提供してください。新たな情報を検索、分析することを推奨します。あくまでレビューの提供までがあなたの役割です。 ## 手順 (#tool:todo) +1. 与えられた課題が何であるかをIssueから理解する 1. 網羅的に情報を収集する - レポジトリの分析 - ドキュメント群の分析 - ウェブ検索 (#tool:ms-vscode.vscode-websearchforcopilot/websearch) によるベストプラクティス、pitfalls、代替案の調査 - 要件の確認と理解 -2. 収集した情報をもとに、実装内容を批判的に評価する (正確性、完全性、一貫性、正当性、妥当性、関連性、明確性、客観性、バイアスの有無、可読性、保守性、テストが十分であるかなどの観点) -3. 改善点や懸念点があれば指摘し、アクションプランを示す + - spec-*.mdに含まれる仕様の確認 +1. 収集した情報をもとに、実装内容を批判的に評価する (正確性、完全性、一貫性、正当性、妥当性、関連性、明確性、客観性、バイアスの有無、可読性、保守性、テストが十分であるかなどの観点) +1. 改善点や懸念点があれば指摘し、アクションプランを示す ## ツール diff --git a/apps/backend/package.json b/apps/backend/package.json index 243959a..1c6f916 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -25,11 +25,13 @@ "better-auth": "^1.5.6", "drizzle-orm": "^0.45.2", "hono": "^4.12.9", + "nodemailer": "^8.0.5", "pg": "^8.13.3", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.5.0", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "drizzle-kit": "^0.31.10", "tsx": "^4.19.3" diff --git a/apps/backend/src/__tests__/app.issue11.integration.test.ts b/apps/backend/src/__tests__/app.issue11.integration.test.ts index c436292..edb4970 100644 --- a/apps/backend/src/__tests__/app.issue11.integration.test.ts +++ b/apps/backend/src/__tests__/app.issue11.integration.test.ts @@ -1211,10 +1211,13 @@ describe('issue #11 api integration', () => { expect(approveRes.status).toBe(200); expect(mockSendEmail).toHaveBeenCalledWith({ to: 'owner@approve.example', - subject: 'Approve University の代表者招待', - html: expect.stringMatching( - /^招待リンク: invitation:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ), + template: 'university-owner-invitation-link', + payload: { + universityName: 'Approve University', + invitationLink: expect.stringMatching( + /^http:\/\/localhost:3000\/invite\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), + }, }); const approveJson = (await approveRes.json()) as { data: { status: string; createdOrganizationId: string }; diff --git a/apps/backend/src/auth.ts b/apps/backend/src/auth.ts index 6000640..63561fa 100644 --- a/apps/backend/src/auth.ts +++ b/apps/backend/src/auth.ts @@ -4,6 +4,7 @@ import { organization } from 'better-auth/plugins'; import { db } from './db/index.js'; import * as schema from './db/schema.js'; import { env } from './lib/config.js'; +import { buildInvitationLink } from './lib/invitation-link.js'; import { verifyPassword } from './lib/password.js'; import { emailService } from './services/email/index.js'; @@ -27,18 +28,50 @@ export const auth = betterAuth({ trustedOrigins: env.CORS_ALLOWED_ORIGINS, emailAndPassword: { enabled: true, + requireEmailVerification: true, + autoSignIn: false, + async sendResetPassword({ user, url }) { + await emailService.sendEmail({ + to: user.email, + template: 'password-reset', + payload: { + userName: user.name, + resetLink: url, + }, + }); + }, + revokeSessionsOnPasswordReset: true, password: { verify: verifyPassword, }, }, + emailVerification: { + sendOnSignUp: true, + sendOnSignIn: true, + autoSignInAfterVerification: true, + async sendVerificationEmail({ user, url }) { + await emailService.sendEmail({ + to: user.email, + template: 'email-verification', + payload: { + userName: user.name, + verificationLink: url, + }, + }); + }, + }, plugins: [ organization({ async sendInvitationEmail(data) { - const inviteLink = `${env.APP_URL}/invite/${data.id}`; + const inviteLink = buildInvitationLink(data.id); await emailService.sendEmail({ to: data.email, - subject: `${data.organization.name} への招待`, - html: `${data.inviter.user.name} さんが ${data.organization.name} へ招待しました: ${inviteLink}`, + template: 'organization-invitation', + payload: { + organizationName: data.organization.name, + inviterName: data.inviter.user.name, + inviteLink, + }, }); }, }), diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index d762e0f..8a1df6a 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -1,4 +1,4 @@ -type Env = { +export type Env = { DATABASE_URL: string; PORT: number; BETTER_AUTH_SECRET: string; @@ -12,9 +12,16 @@ type Env = { S3_BUCKET_RULES: string; S3_BUCKET_SUBMISSIONS: string; S3_FORCE_PATH_STYLE: boolean; - EMAIL_PROVIDER: 'console' | 'sendgrid'; + EMAIL_PROVIDER: 'console' | 'sendgrid' | 'smtp'; SENDGRID_API_KEY?: string; SENDGRID_FROM: string; + SMTP_HOST?: string; + SMTP_PORT: number; + SMTP_USER?: string; + SMTP_PASS?: string; + SMTP_FROM?: string; + SMTP_SECURE: boolean; + SMTP_REQUIRE_TLS: boolean; }; const toBool = (value: string | undefined, fallback: boolean): boolean => { @@ -51,7 +58,17 @@ export const env: Env = { S3_BUCKET_RULES: process.env.S3_BUCKET_RULES ?? 'robocon-rules', S3_BUCKET_SUBMISSIONS: process.env.S3_BUCKET_SUBMISSIONS ?? 'robocon-submissions', S3_FORCE_PATH_STYLE: toBool(process.env.S3_FORCE_PATH_STYLE, true), - EMAIL_PROVIDER: process.env.EMAIL_PROVIDER === 'sendgrid' ? 'sendgrid' : 'console', + EMAIL_PROVIDER: + process.env.EMAIL_PROVIDER === 'sendgrid' || process.env.EMAIL_PROVIDER === 'smtp' + ? process.env.EMAIL_PROVIDER + : 'console', SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, SENDGRID_FROM: process.env.SENDGRID_FROM ?? 'noreply@example.com', + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: Number(process.env.SMTP_PORT ?? 587), + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM: process.env.SMTP_FROM, + SMTP_SECURE: toBool(process.env.SMTP_SECURE, false), + SMTP_REQUIRE_TLS: toBool(process.env.SMTP_REQUIRE_TLS, false), }; diff --git a/apps/backend/src/lib/invitation-link.ts b/apps/backend/src/lib/invitation-link.ts new file mode 100644 index 0000000..351df08 --- /dev/null +++ b/apps/backend/src/lib/invitation-link.ts @@ -0,0 +1,4 @@ +import { env } from './config.js'; + +export const buildInvitationLink = (invitationId: string): string => + new URL(`/invite/${encodeURIComponent(invitationId)}`, env.APP_URL).toString(); diff --git a/apps/backend/src/middleware/organization.test.ts b/apps/backend/src/middleware/organization.test.ts index 2b5e67d..cb96461 100644 --- a/apps/backend/src/middleware/organization.test.ts +++ b/apps/backend/src/middleware/organization.test.ts @@ -2,7 +2,7 @@ import type { Next } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const mockLimit = vi.fn<() => Promise>>(async () => []); +const mockLimit = vi.fn<() => Promise>>(async () => []); vi.mock('../db/index.js', () => ({ db: { @@ -33,7 +33,7 @@ const createContext = (params: { }): TestContext => { const vars = new Map(); vars.set('currentUser', params.user); - vars.set('sessionActiveOrganizationId', params.sessionActiveOrganizationId ?? null); + vars.set('session', { activeOrganizationId: params.sessionActiveOrganizationId ?? null }); return { req: { @@ -63,7 +63,7 @@ describe('resolveOrganization', () => { headerOrganizationId: 'org-1', sessionActiveOrganizationId: 'org-1', }); - mockLimit.mockResolvedValue([{ id: 'm-1' }]); + mockLimit.mockResolvedValue([{ organizationId: 'org-1' }]); await resolveOrganization(c as never, vi.fn(async () => undefined) as Next); @@ -76,7 +76,7 @@ describe('resolveOrganization', () => { headerOrganizationId: 'org-header', sessionActiveOrganizationId: null, }); - mockLimit.mockResolvedValue([{ id: 'm-1' }]); + mockLimit.mockResolvedValue([{ organizationId: 'org-header' }]); await resolveOrganization(c as never, vi.fn(async () => undefined) as Next); @@ -99,7 +99,7 @@ describe('resolveOrganization', () => { const c = createContext({ user: { id: 'user-1', isAdmin: false }, headerOrganizationId: 'org-x', - sessionActiveOrganizationId: 'org-1', + sessionActiveOrganizationId: null, }); await expect( diff --git a/apps/backend/src/middleware/organization.ts b/apps/backend/src/middleware/organization.ts index 36e57f0..9c1bb74 100644 --- a/apps/backend/src/middleware/organization.ts +++ b/apps/backend/src/middleware/organization.ts @@ -36,15 +36,23 @@ export const resolveOrganization: MiddlewareHandler<{ ) .limit(1); - if (!organizationId && rows.length > 0) { + if (organizationId && rows.length === 0) { + throw new HTTPException(403, { + message: 'Invalid organization context', + }); + } + + const fallbackOrganizationId = rows[0]?.organizationId ?? null; + + if (!organizationId && fallbackOrganizationId && auth.api.setActiveOrganization) { await auth.api.setActiveOrganization({ body: { - organizationId: rows[0].organizationId, + organizationId: fallbackOrganizationId, }, headers: c.req.raw.headers, }); } - c.set('organizationId', organizationId ?? rows[0]?.organizationId ?? null); + c.set('organizationId', organizationId ?? fallbackOrganizationId); await next(); }; diff --git a/apps/backend/src/routes/admin/requests.ts b/apps/backend/src/routes/admin/requests.ts index ecde31e..9ec57e4 100644 --- a/apps/backend/src/routes/admin/requests.ts +++ b/apps/backend/src/routes/admin/requests.ts @@ -11,6 +11,7 @@ import { universityCreationRequests, users, } from '../../db/schema.js'; +import { buildInvitationLink } from '../../lib/invitation-link.js'; import { createPaginatedResponseSchema, createPaginationMeta, @@ -613,8 +614,11 @@ adminRequestRoutes.openapi(approveUniversityRequestRoute, async (c) => { await emailService.sendEmail({ to: request.representativeEmail, - subject: `${request.universityName} の代表者招待`, - html: `招待リンク: invitation:${invitationId}`, + template: 'university-owner-invitation-link', + payload: { + universityName: request.universityName, + invitationLink: buildInvitationLink(invitationId), + }, }); await db diff --git a/apps/backend/src/routes/admin/universities.ts b/apps/backend/src/routes/admin/universities.ts index e63004b..a12d5d0 100644 --- a/apps/backend/src/routes/admin/universities.ts +++ b/apps/backend/src/routes/admin/universities.ts @@ -3,6 +3,7 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { asc, count, desc, ilike, or } from 'drizzle-orm'; import { db } from '../../db/index.js'; import { invitations, organizations } from '../../db/schema.js'; +import { buildInvitationLink } from '../../lib/invitation-link.js'; import { createPaginatedResponseSchema, createPaginationMeta, @@ -134,8 +135,11 @@ adminUniversityRoutes.openapi(createUniversityRoute, async (c) => { await emailService.sendEmail({ to: body.data.ownerEmail, - subject: `${inserted[0].name} の代表者招待`, - html: `招待ID: ${invitationId}`, + template: 'organization-member-invitation-link', + payload: { + organizationName: inserted[0].name, + invitationLink: buildInvitationLink(invitationId), + }, }); } diff --git a/apps/backend/src/routes/university.ts b/apps/backend/src/routes/university.ts index 99a1412..4d4a6ef 100644 --- a/apps/backend/src/routes/university.ts +++ b/apps/backend/src/routes/university.ts @@ -3,6 +3,7 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { and, asc, count, desc, eq, ilike, or } from 'drizzle-orm'; import { db } from '../db/index.js'; import { invitations, members, organizations, users } from '../db/schema.js'; +import { buildInvitationLink } from '../lib/invitation-link.js'; import { createPaginatedResponseSchema, createPaginationMeta, @@ -381,8 +382,11 @@ universityRoutes.openapi(inviteUniversityRoute, async (c) => { await emailService.sendEmail({ to: body.data.email, - subject: `${inviter[0]?.orgName ?? organizationId} への招待`, - html: `招待リンク: invitation:${invitationId}`, + template: 'organization-member-invitation-link', + payload: { + organizationName: inviter[0]?.orgName ?? organizationId, + invitationLink: buildInvitationLink(invitationId), + }, }); return c.json({ data: inserted[0] }, 201); diff --git a/apps/backend/src/services/email/console.test.ts b/apps/backend/src/services/email/console.test.ts index c841ff3..3ca1a74 100644 --- a/apps/backend/src/services/email/console.test.ts +++ b/apps/backend/src/services/email/console.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ConsoleEmailService } from './console.js'; describe('ConsoleEmailService', () => { @@ -12,4 +12,35 @@ describe('ConsoleEmailService', () => { expect(result.success).toBe(true); }); + + it('resolves template-based emails before logging', async () => { + const service = new ConsoleEmailService(); + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const result = await service.sendEmail({ + to: 'owner@example.com', + template: 'organization-invitation', + payload: { + organizationName: 'DocShare University', + inviterName: 'Admin User', + inviteLink: 'https://app.example.test/invite/inv-1', + }, + }); + + expect(result.success).toBe(true); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[EMAIL]', + expect.stringContaining('"subject":"DocShare University への招待"'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[EMAIL]', + expect.stringContaining('Admin User さんから'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[EMAIL]', + expect.stringContaining('"text":"DocShare University への招待'), + ); + + consoleLogSpy.mockRestore(); + }); }); diff --git a/apps/backend/src/services/email/console.ts b/apps/backend/src/services/email/console.ts index 4f491f5..4114498 100644 --- a/apps/backend/src/services/email/console.ts +++ b/apps/backend/src/services/email/console.ts @@ -1,8 +1,10 @@ import type { EmailService, SendEmailParams, SendEmailResult } from './interface.js'; +import { resolveSendEmailParams } from './templates.js'; export class ConsoleEmailService implements EmailService { async sendEmail(params: SendEmailParams): Promise { - console.log('[EMAIL]', JSON.stringify(params)); + const resolved = resolveSendEmailParams(params); + console.log('[EMAIL]', JSON.stringify({ ...params, ...resolved })); return { success: true, messageId: 'console' }; } } diff --git a/apps/backend/src/services/email/index.test.ts b/apps/backend/src/services/email/index.test.ts new file mode 100644 index 0000000..4d27751 --- /dev/null +++ b/apps/backend/src/services/email/index.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Env } from '../../lib/config.js'; + +const createTransportMock = vi.fn(() => ({ + sendMail: vi.fn(), +})); +const setApiKeyMock = vi.fn(); + +vi.mock('nodemailer', () => ({ + default: { + createTransport: createTransportMock, + }, +})); + +vi.mock('@sendgrid/mail', () => ({ + default: { + send: vi.fn(), + setApiKey: setApiKeyMock, + }, +})); + +const createEnv = (overrides: Partial = {}): Env => ({ + DATABASE_URL: 'postgres://robocon:password@localhost:5432/robocon', + PORT: 8787, + BETTER_AUTH_SECRET: 'dev-secret', + BETTER_AUTH_URL: 'http://localhost:8787', + APP_URL: 'http://localhost:3000', + CORS_ALLOWED_ORIGINS: ['http://localhost:3000'], + S3_ENDPOINT: 'http://localhost:9000', + S3_REGION: 'us-east-1', + S3_ACCESS_KEY: 'minioadmin', + S3_SECRET_KEY: 'minioadmin', + S3_BUCKET_RULES: 'robocon-rules', + S3_BUCKET_SUBMISSIONS: 'robocon-submissions', + S3_FORCE_PATH_STYLE: true, + EMAIL_PROVIDER: 'console', + SENDGRID_API_KEY: undefined, + SENDGRID_FROM: 'sendgrid@example.com', + SMTP_HOST: undefined, + SMTP_PORT: 587, + SMTP_USER: undefined, + SMTP_PASS: undefined, + SMTP_FROM: undefined, + SMTP_SECURE: false, + SMTP_REQUIRE_TLS: false, + ...overrides, +}); + +describe('createEmailService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses console email by default', async () => { + const { createEmailService } = await import('./index.js'); + const { ConsoleEmailService } = await import('./console.js'); + + expect(createEmailService(createEnv())).toBeInstanceOf(ConsoleEmailService); + }); + + it('uses SendGrid when configured with an API key', async () => { + const { createEmailService } = await import('./index.js'); + const { SendGridEmailService } = await import('./sendgrid.js'); + + const service = createEmailService( + createEnv({ + EMAIL_PROVIDER: 'sendgrid', + SENDGRID_API_KEY: 'sendgrid-key', + }), + ); + + expect(service).toBeInstanceOf(SendGridEmailService); + expect(setApiKeyMock).toHaveBeenCalledWith('sendgrid-key'); + }); + + it('keeps the existing SendGrid fallback when the API key is missing', async () => { + const { createEmailService } = await import('./index.js'); + const { ConsoleEmailService } = await import('./console.js'); + + expect(createEmailService(createEnv({ EMAIL_PROVIDER: 'sendgrid' }))).toBeInstanceOf( + ConsoleEmailService, + ); + }); + + it('uses SMTP when all required SMTP settings are present', async () => { + const { createEmailService } = await import('./index.js'); + const { SmtpEmailService } = await import('./smtp.js'); + + const service = createEmailService( + createEnv({ + EMAIL_PROVIDER: 'smtp', + SMTP_HOST: 'smtp.example.com', + SMTP_PORT: 465, + SMTP_USER: 'smtp-user', + SMTP_PASS: 'smtp-pass', + SMTP_FROM: 'from@example.com', + SMTP_SECURE: true, + SMTP_REQUIRE_TLS: true, + }), + ); + + expect(service).toBeInstanceOf(SmtpEmailService); + expect(createTransportMock).toHaveBeenCalledWith({ + host: 'smtp.example.com', + port: 465, + secure: true, + auth: { + user: 'smtp-user', + pass: 'smtp-pass', + }, + requireTLS: true, + }); + }); + + it('throws when SMTP provider is missing required settings', async () => { + const { createEmailService } = await import('./index.js'); + + expect(() => createEmailService(createEnv({ EMAIL_PROVIDER: 'smtp' }))).toThrow( + 'EMAIL_PROVIDER=smtp requires SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM.', + ); + }); +}); diff --git a/apps/backend/src/services/email/index.ts b/apps/backend/src/services/email/index.ts index badc0ce..897d980 100644 --- a/apps/backend/src/services/email/index.ts +++ b/apps/backend/src/services/email/index.ts @@ -1,9 +1,53 @@ -import { env } from '../../lib/config.js'; +import { type Env, env } from '../../lib/config.js'; import { ConsoleEmailService } from './console.js'; import type { EmailService } from './interface.js'; import { SendGridEmailService } from './sendgrid.js'; +import { SmtpEmailService } from './smtp.js'; -export const emailService: EmailService = - env.EMAIL_PROVIDER === 'sendgrid' && env.SENDGRID_API_KEY - ? new SendGridEmailService(env.SENDGRID_API_KEY, env.SENDGRID_FROM) - : new ConsoleEmailService(); +export const createEmailService = (config: Env): EmailService => { + if (config.EMAIL_PROVIDER === 'sendgrid' && config.SENDGRID_API_KEY) { + return new SendGridEmailService(config.SENDGRID_API_KEY, config.SENDGRID_FROM); + } + + if (config.EMAIL_PROVIDER === 'smtp') { + const smtpConfig = { + host: config.SMTP_HOST, + port: config.SMTP_PORT, + user: config.SMTP_USER, + pass: config.SMTP_PASS, + from: config.SMTP_FROM, + secure: config.SMTP_SECURE, + requireTLS: config.SMTP_REQUIRE_TLS, + }; + const missing = [ + ['SMTP_HOST', smtpConfig.host], + ['SMTP_USER', smtpConfig.user], + ['SMTP_PASS', smtpConfig.pass], + ['SMTP_FROM', smtpConfig.from], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + + if (missing.length > 0) { + throw new Error(`EMAIL_PROVIDER=smtp requires ${missing.join(', ')}.`); + } + + if (!smtpConfig.host || !smtpConfig.user || !smtpConfig.pass || !smtpConfig.from) { + throw new Error('EMAIL_PROVIDER=smtp requires valid SMTP settings.'); + } + + return new SmtpEmailService({ + host: smtpConfig.host, + port: smtpConfig.port, + user: smtpConfig.user, + pass: smtpConfig.pass, + from: smtpConfig.from, + secure: smtpConfig.secure, + requireTLS: smtpConfig.requireTLS, + }); + } + + return new ConsoleEmailService(); +}; + +export const emailService: EmailService = createEmailService(env); diff --git a/apps/backend/src/services/email/interface.ts b/apps/backend/src/services/email/interface.ts index 35cee28..63f9c61 100644 --- a/apps/backend/src/services/email/interface.ts +++ b/apps/backend/src/services/email/interface.ts @@ -1,10 +1,51 @@ -export type SendEmailParams = { +export type EmailTemplateMap = { + 'organization-invitation': { + organizationName: string; + inviterName: string; + inviteLink: string; + }; + 'organization-member-invitation-link': { + organizationName: string; + invitationLink: string; + }; + 'university-owner-invitation-link': { + universityName: string; + invitationLink: string; + }; + 'email-verification': { + userName: string; + verificationLink: string; + }; + 'password-reset': { + userName: string; + resetLink: string; + }; +}; + +export type EmailTemplateId = keyof EmailTemplateMap; + +export type SendEmailContentParams = { to: string; subject: string; html: string; text?: string; + template?: never; + payload?: never; }; +export type SendEmailTemplateParams = { + [TemplateId in EmailTemplateId]: { + to: string; + template: TemplateId; + payload: EmailTemplateMap[TemplateId]; + text?: string; + subject?: never; + html?: never; + }; +}[EmailTemplateId]; + +export type SendEmailParams = SendEmailContentParams | SendEmailTemplateParams; + export type SendEmailResult = { success: boolean; messageId?: string; diff --git a/apps/backend/src/services/email/sendgrid.test.ts b/apps/backend/src/services/email/sendgrid.test.ts new file mode 100644 index 0000000..7a5fdea --- /dev/null +++ b/apps/backend/src/services/email/sendgrid.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendMock = vi.fn(); +const setApiKeyMock = vi.fn(); + +vi.mock('@sendgrid/mail', () => ({ + default: { + send: sendMock, + setApiKey: setApiKeyMock, + }, +})); + +describe('SendGridEmailService', () => { + beforeEach(() => { + vi.clearAllMocks(); + sendMock.mockResolvedValue([ + { + statusCode: 202, + headers: { + 'x-message-id': 'message-1', + }, + }, + ]); + }); + + it('sends raw subject/html emails as-is', async () => { + const { SendGridEmailService } = await import('./sendgrid.js'); + const service = new SendGridEmailService('test-key', 'from@example.com'); + + const result = await service.sendEmail({ + to: 'owner@example.com', + subject: 'Hello', + html: '

World

', + text: 'World', + }); + + expect(setApiKeyMock).toHaveBeenCalledWith('test-key'); + expect(sendMock).toHaveBeenCalledWith({ + to: 'owner@example.com', + from: 'from@example.com', + subject: 'Hello', + html: '

World

', + text: 'World', + }); + expect(result).toEqual({ + success: true, + messageId: 'message-1', + }); + }); + + it('resolves template-based emails before sending', async () => { + const { SendGridEmailService } = await import('./sendgrid.js'); + const service = new SendGridEmailService('test-key', 'from@example.com'); + + await service.sendEmail({ + to: 'owner@example.com', + template: 'university-owner-invitation-link', + payload: { + universityName: 'Approve University', + invitationLink: 'https://app.example.test/invite/invite-1', + }, + }); + + expect(sendMock).toHaveBeenCalledWith({ + to: 'owner@example.com', + from: 'from@example.com', + subject: 'Approve University の代表者招待', + html: expect.stringContaining('https://app.example.test/invite/invite-1'), + text: expect.stringContaining('代表者設定を開く: https://app.example.test/invite/invite-1'), + }); + }); +}); diff --git a/apps/backend/src/services/email/sendgrid.ts b/apps/backend/src/services/email/sendgrid.ts index f7b6312..2e07af7 100644 --- a/apps/backend/src/services/email/sendgrid.ts +++ b/apps/backend/src/services/email/sendgrid.ts @@ -1,5 +1,6 @@ import sendgridMail from '@sendgrid/mail'; import type { EmailService, SendEmailParams, SendEmailResult } from './interface.js'; +import { resolveSendEmailParams } from './templates.js'; export class SendGridEmailService implements EmailService { constructor( @@ -10,12 +11,13 @@ export class SendGridEmailService implements EmailService { } async sendEmail(params: SendEmailParams): Promise { + const resolved = resolveSendEmailParams(params); const [res] = await sendgridMail.send({ - to: params.to, + to: resolved.to, from: this.fromAddress, - subject: params.subject, - html: params.html, - text: params.text, + subject: resolved.subject, + html: resolved.html, + text: resolved.text, }); return { diff --git a/apps/backend/src/services/email/smtp.test.ts b/apps/backend/src/services/email/smtp.test.ts new file mode 100644 index 0000000..549d34d --- /dev/null +++ b/apps/backend/src/services/email/smtp.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendMailMock = vi.fn(); +const createTransportMock = vi.fn(() => ({ + sendMail: sendMailMock, +})); + +vi.mock('nodemailer', () => ({ + default: { + createTransport: createTransportMock, + }, +})); + +describe('SmtpEmailService', () => { + beforeEach(() => { + vi.clearAllMocks(); + sendMailMock.mockResolvedValue({ + messageId: 'smtp-message-1', + }); + }); + + it('creates a transporter with SMTP settings', async () => { + const { SmtpEmailService } = await import('./smtp.js'); + + new SmtpEmailService({ + host: 'smtp.example.com', + port: 465, + user: 'smtp-user', + pass: 'smtp-pass', + from: 'from@example.com', + secure: true, + requireTLS: true, + }); + + expect(createTransportMock).toHaveBeenCalledWith({ + host: 'smtp.example.com', + port: 465, + secure: true, + auth: { + user: 'smtp-user', + pass: 'smtp-pass', + }, + requireTLS: true, + }); + }); + + it('sends raw subject/html emails as-is', async () => { + const { SmtpEmailService } = await import('./smtp.js'); + const service = new SmtpEmailService({ + host: 'smtp.example.com', + port: 587, + user: 'smtp-user', + pass: 'smtp-pass', + from: 'from@example.com', + secure: false, + requireTLS: false, + }); + + const result = await service.sendEmail({ + to: 'owner@example.com', + subject: 'Hello', + html: '

World

', + text: 'World', + }); + + expect(sendMailMock).toHaveBeenCalledWith({ + to: 'owner@example.com', + from: 'from@example.com', + subject: 'Hello', + html: '

World

', + text: 'World', + }); + expect(result).toEqual({ + success: true, + messageId: 'smtp-message-1', + }); + }); + + it('resolves template-based emails before sending', async () => { + const { SmtpEmailService } = await import('./smtp.js'); + const service = new SmtpEmailService({ + host: 'smtp.example.com', + port: 587, + user: 'smtp-user', + pass: 'smtp-pass', + from: 'from@example.com', + secure: false, + requireTLS: true, + }); + + await service.sendEmail({ + to: 'owner@example.com', + template: 'university-owner-invitation-link', + payload: { + universityName: 'Approve University', + invitationLink: 'https://app.example.test/invite/invite-1', + }, + }); + + expect(sendMailMock).toHaveBeenCalledWith({ + to: 'owner@example.com', + from: 'from@example.com', + subject: 'Approve University の代表者招待', + html: expect.stringContaining('https://app.example.test/invite/invite-1'), + text: expect.stringContaining('代表者設定を開く: https://app.example.test/invite/invite-1'), + }); + }); +}); diff --git a/apps/backend/src/services/email/smtp.ts b/apps/backend/src/services/email/smtp.ts new file mode 100644 index 0000000..790dab2 --- /dev/null +++ b/apps/backend/src/services/email/smtp.ts @@ -0,0 +1,47 @@ +import nodemailer, { type Transporter } from 'nodemailer'; +import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; +import type { EmailService, SendEmailParams, SendEmailResult } from './interface.js'; +import { resolveSendEmailParams } from './templates.js'; + +export type SmtpEmailServiceConfig = { + host: string; + port: number; + user: string; + pass: string; + from: string; + secure: boolean; + requireTLS: boolean; +}; + +export class SmtpEmailService implements EmailService { + private readonly transporter: Transporter; + + constructor(private readonly config: SmtpEmailServiceConfig) { + this.transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: { + user: config.user, + pass: config.pass, + }, + requireTLS: config.requireTLS, + }); + } + + async sendEmail(params: SendEmailParams): Promise { + const resolved = resolveSendEmailParams(params); + const info = await this.transporter.sendMail({ + to: resolved.to, + from: this.config.from, + subject: resolved.subject, + html: resolved.html, + text: resolved.text, + }); + + return { + success: true, + messageId: info.messageId, + }; + } +} diff --git a/apps/backend/src/services/email/templates.test.ts b/apps/backend/src/services/email/templates.test.ts new file mode 100644 index 0000000..52f2557 --- /dev/null +++ b/apps/backend/src/services/email/templates.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { resolveEmailTemplate } from './templates.js'; + +describe('resolveEmailTemplate', () => { + it('renders organization invitation emails', () => { + const email = resolveEmailTemplate('organization-invitation', { + organizationName: 'DocShare University', + inviterName: 'Admin User', + inviteLink: 'https://app.example.test/invite/inv-1', + }); + + expect(email.subject).toBe('DocShare University への招待'); + expect(email.html).toContain('Admin User さんから'); + expect(email.html).toContain('DocShare の DocShare University'); + expect(email.html).toContain('https://app.example.test/invite/inv-1'); + expect(email.text).toContain('Admin User さんから'); + expect(email.text).toContain('https://app.example.test/invite/inv-1'); + }); + + it('renders invitation link-based owner emails', () => { + const email = resolveEmailTemplate('university-owner-invitation-link', { + universityName: 'Approve University', + invitationLink: 'https://app.example.test/invite/1234', + }); + + expect(email.subject).toBe('Approve University の代表者招待'); + expect(email.html).toContain('代表者アカウントを設定するための招待リンク'); + expect(email.html).toContain('https://app.example.test/invite/1234'); + expect(email.text).toContain('代表者アカウントを設定するための招待リンク'); + expect(email.text).toContain('代表者設定を開く: https://app.example.test/invite/1234'); + }); + + it('renders member invitation emails', () => { + const email = resolveEmailTemplate('organization-member-invitation-link', { + organizationName: 'Engineering Org', + invitationLink: 'https://app.example.test/invite/5678', + }); + + expect(email.subject).toBe('Engineering Org への招待'); + expect(email.html).toContain('メンバーとして参加するための招待リンク'); + expect(email.html).toContain('https://app.example.test/invite/5678'); + expect(email.text).toContain('メンバーとして参加するための招待リンク'); + expect(email.text).toContain('メンバー招待を開く: https://app.example.test/invite/5678'); + }); + + it('renders email verification emails', () => { + const email = resolveEmailTemplate('email-verification', { + userName: 'New User', + verificationLink: 'https://app.example.test/auth/verify-email?token=token-1', + }); + + expect(email.subject).toBe('DocShare メールアドレスの確認'); + expect(email.html).toContain('New User さん'); + expect(email.html).toContain('メールアドレスの確認を完了してください'); + expect(email.text).toContain( + 'メールアドレスを確認する: https://app.example.test/auth/verify-email?token=token-1', + ); + }); + + it('renders password reset emails', () => { + const email = resolveEmailTemplate('password-reset', { + userName: 'Existing User', + resetLink: 'https://app.example.test/auth/reset-password?token=token-2', + }); + + expect(email.subject).toBe('DocShare パスワード再設定'); + expect(email.html).toContain('Existing User さん'); + expect(email.html).toContain('パスワード再設定がリクエストされました'); + expect(email.text).toContain( + 'パスワードを再設定する: https://app.example.test/auth/reset-password?token=token-2', + ); + }); + + it('escapes dynamic values in html output', () => { + const email = resolveEmailTemplate('organization-invitation', { + organizationName: 'R&D ', + inviterName: 'Admin & Owner', + inviteLink: 'https://app.example.test/invite?token=&next="home"', + }); + + expect(email.subject).toBe('R&D への招待'); + expect(email.html).toContain('R&D <Team>'); + expect(email.html).toContain('Admin & Owner'); + expect(email.html).toContain( + 'https://app.example.test/invite?token=<abc>&next="home"', + ); + expect(email.text).toContain('R&D '); + expect(email.text).toContain('Admin & Owner'); + }); +}); diff --git a/apps/backend/src/services/email/templates.ts b/apps/backend/src/services/email/templates.ts new file mode 100644 index 0000000..887100f --- /dev/null +++ b/apps/backend/src/services/email/templates.ts @@ -0,0 +1,192 @@ +import type { + EmailTemplateId, + EmailTemplateMap, + SendEmailContentParams, + SendEmailParams, + SendEmailTemplateParams, +} from './interface.js'; + +type EmailTemplateDefinition = { + render: (payload: EmailTemplateMap[TemplateId]) => Omit; +}; + +type EmailTemplateContent = { + subject: string; + heading: string; + body: string[]; + action?: { + label: string; + href: string; + }; + detail?: { + label: string; + value: string; + }; +}; + +const escapeHtml = (value: string): string => + value.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return char; + } + }); + +const renderParagraphs = (paragraphs: string[]): string => + paragraphs + .map((paragraph) => `

${escapeHtml(paragraph)}

`) + .join(''); + +const renderEmail = (content: EmailTemplateContent): Omit => { + const detailText = content.detail + ? `${content.detail.label}: ${content.detail.value}` + : undefined; + const actionText = content.action ? `${content.action.label}: ${content.action.href}` : undefined; + const text = [ + content.heading, + '', + ...content.body, + ...(detailText ? ['', detailText] : []), + ...(actionText ? ['', actionText] : []), + '', + 'このメールに心当たりがない場合は、破棄してください。', + '', + 'DocShare', + ].join('\n'); + + const detailHtml = content.detail + ? `
+
${escapeHtml(content.detail.label)}
+
${escapeHtml(content.detail.value)}
+
` + : ''; + const actionHtml = content.action + ? ` +

ボタンを開けない場合は、次のURLをブラウザに貼り付けてください。
${escapeHtml(content.action.href)}

` + : ''; + + return { + subject: content.subject, + html: `
+

${escapeHtml(content.heading)}

+ ${renderParagraphs(content.body)} + ${detailHtml} + ${actionHtml} +

このメールに心当たりがない場合は、破棄してください。

+

DocShare

+
`, + text, + }; +}; + +const emailTemplateDefinitions: { + [TemplateId in EmailTemplateId]: EmailTemplateDefinition; +} = { + 'organization-invitation': { + render: (payload) => + renderEmail({ + subject: `${payload.organizationName} への招待`, + heading: `${payload.organizationName} への招待`, + body: [ + `${payload.inviterName} さんから、DocShare の ${payload.organizationName} に参加するための招待が届いています。`, + '以下のリンクから参加手続きを完了してください。', + ], + action: { + label: '招待を確認する', + href: payload.inviteLink, + }, + }), + }, + 'organization-member-invitation-link': { + render: (payload) => + renderEmail({ + subject: `${payload.organizationName} への招待`, + heading: `${payload.organizationName} への招待`, + body: [ + `DocShare の ${payload.organizationName} にメンバーとして参加するための招待リンクをお送りします。`, + '以下のリンクからアカウントの確認または登録を進めてください。', + ], + action: { + label: 'メンバー招待を開く', + href: payload.invitationLink, + }, + }), + }, + 'university-owner-invitation-link': { + render: (payload) => + renderEmail({ + subject: `${payload.universityName} の代表者招待`, + heading: `${payload.universityName} の代表者招待`, + body: [ + `DocShare で ${payload.universityName} の代表者アカウントを設定するための招待リンクをお送りします。`, + '以下のリンクから代表者アカウントの設定を完了してください。', + ], + action: { + label: '代表者設定を開く', + href: payload.invitationLink, + }, + }), + }, + 'email-verification': { + render: (payload) => + renderEmail({ + subject: 'DocShare メールアドレスの確認', + heading: 'メールアドレスの確認', + body: [ + `${payload.userName} さん、DocShare への登録ありがとうございます。`, + '以下のリンクからメールアドレスの確認を完了してください。', + ], + action: { + label: 'メールアドレスを確認する', + href: payload.verificationLink, + }, + }), + }, + 'password-reset': { + render: (payload) => + renderEmail({ + subject: 'DocShare パスワード再設定', + heading: 'パスワード再設定', + body: [ + `${payload.userName} さんの DocShare アカウントで、パスワード再設定がリクエストされました。`, + '以下のリンクから新しいパスワードを設定してください。', + ], + action: { + label: 'パスワードを再設定する', + href: payload.resetLink, + }, + }), + }, +}; + +export const resolveEmailTemplate = ( + template: TemplateId, + payload: EmailTemplateMap[TemplateId], +): Omit => emailTemplateDefinitions[template].render(payload); + +const isTemplateEmail = (params: SendEmailParams): params is SendEmailTemplateParams => + 'template' in params; + +export const resolveSendEmailParams = (params: SendEmailParams): SendEmailContentParams => { + if (!isTemplateEmail(params)) { + return params; + } + + return { + to: params.to, + text: params.text, + ...resolveEmailTemplate(params.template, params.payload), + }; +}; diff --git a/apps/frontend/app/(admin)/admin/editions/page.tsx b/apps/frontend/app/(admin)/admin/editions/page.tsx index ba110f9..2df0874 100644 --- a/apps/frontend/app/(admin)/admin/editions/page.tsx +++ b/apps/frontend/app/(admin)/admin/editions/page.tsx @@ -30,14 +30,17 @@ import { useDeleteEditionMutation, useUploadEditionRuleMutation, } from '@/features/admin/editions/mutations'; -import { useAdminEditionsList } from '@/features/admin/editions/query'; +import { useAdminEditionsList, useSeriesForEditionForm } from '@/features/admin/editions/query'; import type { Edition, SharingStatus } from '@/features/admin/editions/types'; import { SHARING_STATUS_LABELS } from '@/features/admin/editions/types'; +const ALL_SERIES_VALUE = 'all'; + const paginationParsers = { page: parseAsInteger.withDefault(1), pageSize: parseAsInteger.withDefault(20), q: parseAsString.withDefault(''), + seriesId: parseAsString.withDefault(''), }; export default function AdminEditionsPage() { @@ -48,9 +51,12 @@ export default function AdminEditionsPage() { const [uploadingEditionId, setUploadingEditionId] = useState(null); const { data, isLoading } = useAdminEditionsList(queryParams); + const { data: seriesData } = useSeriesForEditionForm(); const deleteMutation = useDeleteEditionMutation(); const changeStatusMutation = useChangeEditionStatusMutation(); const uploadRuleMutation = useUploadEditionRuleMutation(); + const series = seriesData?.data ?? []; + const selectedSeries = series.find((item) => item.id === queryParams.seriesId); const handleRuleUpload = async (editionId: string, file: File) => { const edition = data?.data.find((item) => item.id === editionId); @@ -169,7 +175,28 @@ export default function AdminEditionsPage() { />

大会回管理

-
+
+ (null); + const { form, error, validators } = useForgotPasswordForm((email) => { + setSentEmail(email); + }); + + if (sentEmail) { + return ( +
+ + + 再設定メールを送信しました + {sentEmail} に再設定リンクを送信しました + + +

+ メール内のリンクから新しいパスワードを設定してください。 +

+
+
+
+ ); + } + + return ( +
+ + + パスワード再設定 + 登録済みのメールアドレスへ再設定リンクを送信します + + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + className='space-y-4' + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors[0] && ( +

{field.state.meta.errors[0].message}

+ )} +
+ )} +
+ {error &&

{error}

} + +
+

+ + ログインへ戻る + +

+
+
+
+ ); +} diff --git a/apps/frontend/app/(public)/auth/login/page.tsx b/apps/frontend/app/(public)/auth/login/page.tsx index c9c2b71..3cf8977 100644 --- a/apps/frontend/app/(public)/auth/login/page.tsx +++ b/apps/frontend/app/(public)/auth/login/page.tsx @@ -83,6 +83,11 @@ function LoginPageContent() { {form.state.isSubmitting ? 'ログイン中...' : 'ログイン'} +

+ + パスワードをお忘れの方 + +

アカウントをお持ちでない方は{' '} diff --git a/apps/frontend/app/(public)/auth/register/page.tsx b/apps/frontend/app/(public)/auth/register/page.tsx index 40b28b3..1691374 100644 --- a/apps/frontend/app/(public)/auth/register/page.tsx +++ b/apps/frontend/app/(public)/auth/register/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -9,10 +10,32 @@ import { useRegisterForm } from '@/features/public/auth/register/hooks'; export default function RegisterPage() { const router = useRouter(); - const { form, error, validators } = useRegisterForm(() => { - router.push('/dashboard'); + const [registeredEmail, setRegisteredEmail] = useState(null); + const { form, error, validators } = useRegisterForm((email) => { + setRegisteredEmail(email); }); + if (registeredEmail) { + return ( +

+ + + 確認メールを送信しました + {registeredEmail} に確認リンクを送信しました + + +

+ メール内のリンクを開くとアカウント作成が完了します。 +

+ +
+
+
+ ); + } + return (
diff --git a/apps/frontend/app/(public)/auth/reset-password/page.tsx b/apps/frontend/app/(public)/auth/reset-password/page.tsx new file mode 100644 index 0000000..5d0ac45 --- /dev/null +++ b/apps/frontend/app/(public)/auth/reset-password/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { useResetPasswordForm } from '@/features/public/auth/reset-password/hooks'; + +export default function ResetPasswordPage() { + return ( + + + + ); +} + +function ResetPasswordPageContent() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const linkError = searchParams.get('error'); + const [completed, setCompleted] = useState(false); + const { form, error, validators } = useResetPasswordForm(token, () => { + setCompleted(true); + }); + + if (completed) { + return ( +
+ + + パスワードを再設定しました + 新しいパスワードでログインできます + + + + + +
+ ); + } + + return ( +
+ + + 新しいパスワード + アカウントに設定する新しいパスワードを入力してください + + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + className='space-y-4' + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors[0] && ( +

{field.state.meta.errors[0].message}

+ )} +
+ )} +
+ { + return validators.confirmPassword({ + value, + password: fieldApi.form.getFieldValue('password'), + }); + }, + }} + > + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> + {field.state.meta.errors[0] && ( +

{field.state.meta.errors[0].message}

+ )} +
+ )} +
+ {linkError &&

再設定リンクが無効です

} + {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/apps/frontend/app/(public)/auth/verify-email/page.tsx b/apps/frontend/app/(public)/auth/verify-email/page.tsx new file mode 100644 index 0000000..f8bc325 --- /dev/null +++ b/apps/frontend/app/(public)/auth/verify-email/page.tsx @@ -0,0 +1,40 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function VerifyEmailPage() { + return ( + + + + ); +} + +function VerifyEmailPageContent() { + const searchParams = useSearchParams(); + const error = searchParams.get('error'); + + return ( +
+ + + {error ? '確認リンクが無効です' : 'メールアドレスを確認しました'} + + {error + ? 'リンクの有効期限が切れているか、すでに使用されています' + : 'DocShare にログインできるようになりました'} + + + + + + +
+ ); +} diff --git a/apps/frontend/features/admin/editions/query.ts b/apps/frontend/features/admin/editions/query.ts index bf5804c..eca168a 100644 --- a/apps/frontend/features/admin/editions/query.ts +++ b/apps/frontend/features/admin/editions/query.ts @@ -13,6 +13,7 @@ export function useAdminEditionsList(queryParams: AdminEditionsQueryParams) { page: queryParams.page, pageSize: queryParams.pageSize, q: queryParams.q || undefined, + series_id: queryParams.seriesId || undefined, }, }, }); diff --git a/apps/frontend/features/admin/editions/types.ts b/apps/frontend/features/admin/editions/types.ts index fda2d96..0d67089 100644 --- a/apps/frontend/features/admin/editions/types.ts +++ b/apps/frontend/features/admin/editions/types.ts @@ -33,6 +33,7 @@ export type AdminEditionsQueryParams = { page: number; pageSize: number; q: string; + seriesId: string; }; export type EditionFormValues = { diff --git a/apps/frontend/features/public/auth/forgot-password/hooks.ts b/apps/frontend/features/public/auth/forgot-password/hooks.ts new file mode 100644 index 0000000..a87c18f --- /dev/null +++ b/apps/frontend/features/public/auth/forgot-password/hooks.ts @@ -0,0 +1,34 @@ +import { useForm } from '@tanstack/react-form'; +import { useState } from 'react'; +import { z } from 'zod'; +import { authClient } from '@/lib/auth/client'; + +export function useForgotPasswordForm(onSuccess: (email: string) => void) { + const [error, setError] = useState(null); + + const form = useForm({ + defaultValues: { email: '' }, + onSubmit: async ({ value }) => { + setError(null); + const result = await authClient.requestPasswordReset({ + email: value.email, + redirectTo: `${window.location.origin}/auth/reset-password`, + }); + + if (result.error) { + setError(result.error.message ?? 'パスワード再設定メールの送信に失敗しました'); + return; + } + + onSuccess(value.email); + }, + }); + + return { + form, + error, + validators: { + email: z.string().email('有効なメールアドレスを入力してください'), + }, + }; +} diff --git a/apps/frontend/features/public/auth/login/hooks.ts b/apps/frontend/features/public/auth/login/hooks.ts index b80465c..2586967 100644 --- a/apps/frontend/features/public/auth/login/hooks.ts +++ b/apps/frontend/features/public/auth/login/hooks.ts @@ -18,6 +18,11 @@ export function useLoginForm(onSuccess: () => void) { }); if (result.error) { + if (result.error.code === 'EMAIL_NOT_VERIFIED') { + setError('メールアドレスの確認が必要です。確認メールを再送信しました。'); + return; + } + setError('メールアドレスまたはパスワードが正しくありません'); return; } diff --git a/apps/frontend/features/public/auth/register/hooks.ts b/apps/frontend/features/public/auth/register/hooks.ts index 073c401..d3bad4c 100644 --- a/apps/frontend/features/public/auth/register/hooks.ts +++ b/apps/frontend/features/public/auth/register/hooks.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { useInvalidateMe } from '@/contexts/AuthContext'; import { authClient } from '@/lib/auth/client'; -export function useRegisterForm(onSuccess: () => void) { +export function useRegisterForm(onSuccess: (email: string) => void) { const invalidateMe = useInvalidateMe(); const [error, setError] = useState(null); @@ -16,6 +16,7 @@ export function useRegisterForm(onSuccess: () => void) { name: value.name, email: value.email, password: value.password, + callbackURL: `${window.location.origin}/auth/verify-email`, }); if (result.error) { @@ -23,8 +24,10 @@ export function useRegisterForm(onSuccess: () => void) { return; } - await invalidateMe(); - onSuccess(); + if (result.data?.token) { + await invalidateMe(); + } + onSuccess(value.email); }, }); diff --git a/apps/frontend/features/public/auth/reset-password/hooks.ts b/apps/frontend/features/public/auth/reset-password/hooks.ts new file mode 100644 index 0000000..9bda83c --- /dev/null +++ b/apps/frontend/features/public/auth/reset-password/hooks.ts @@ -0,0 +1,46 @@ +import { useForm } from '@tanstack/react-form'; +import { useState } from 'react'; +import { z } from 'zod'; +import { authClient } from '@/lib/auth/client'; + +export function useResetPasswordForm(token: string | null, onSuccess: () => void) { + const [error, setError] = useState(null); + + const form = useForm({ + defaultValues: { password: '', confirmPassword: '' }, + onSubmit: async ({ value }) => { + setError(null); + if (!token) { + setError('再設定リンクが無効です'); + return; + } + + const result = await authClient.resetPassword({ + newPassword: value.password, + token, + }); + + if (result.error) { + setError(result.error.message ?? 'パスワードの再設定に失敗しました'); + return; + } + + onSuccess(); + }, + }); + + return { + form, + error, + validators: { + password: z.string().min(8, 'パスワードは8文字以上で入力してください'), + confirmPassword: ({ value, password }: { value: string; password: string }) => { + if (value !== password) { + return { message: 'パスワードが一致しません' }; + } + + return undefined; + }, + }, + }; +} diff --git a/package.json b/package.json index 9b0f621..11a2d11 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "packageManager": "pnpm@10.0.0", "scripts": { "dev": "dotenv -- pnpm --filter \"./apps/*\" dev", + "dev:frontend": "dotenv -- pnpm --filter \"./apps/frontend\" dev", "build": "pnpm --filter \"./apps/*\" build", "generate:api": "pnpm --filter backend openapi:generate && pnpm --filter frontend generate:api", "check": "biome check .", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb39c4..e3cdc89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: hono: specifier: ^4.12.9 version: 4.12.9 + nodemailer: + specifier: ^8.0.5 + version: 8.0.5 pg: specifier: ^8.13.3 version: 8.20.0 @@ -64,6 +67,9 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -2204,6 +2210,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -3614,6 +3623,10 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nodemailer@8.0.5: + resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} + engines: {node: '>=6.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -6611,6 +6624,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 25.5.0 + '@types/pg@8.20.0': dependencies: '@types/node': 25.5.0 @@ -8005,6 +8022,8 @@ snapshots: node-releases@2.0.36: {} + nodemailer@8.0.5: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1