Skip to content

Commit 7a5be09

Browse files
committed
add email verification and password reset
1 parent b0b1aae commit 7a5be09

13 files changed

Lines changed: 458 additions & 5 deletions

File tree

apps/backend/src/auth.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,38 @@ export const auth = betterAuth({
2828
trustedOrigins: env.CORS_ALLOWED_ORIGINS,
2929
emailAndPassword: {
3030
enabled: true,
31+
requireEmailVerification: true,
32+
autoSignIn: false,
33+
async sendResetPassword({ user, url }) {
34+
await emailService.sendEmail({
35+
to: user.email,
36+
template: 'password-reset',
37+
payload: {
38+
userName: user.name,
39+
resetLink: url,
40+
},
41+
});
42+
},
43+
revokeSessionsOnPasswordReset: true,
3144
password: {
3245
verify: verifyPassword,
3346
},
3447
},
48+
emailVerification: {
49+
sendOnSignUp: true,
50+
sendOnSignIn: true,
51+
autoSignInAfterVerification: true,
52+
async sendVerificationEmail({ user, url }) {
53+
await emailService.sendEmail({
54+
to: user.email,
55+
template: 'email-verification',
56+
payload: {
57+
userName: user.name,
58+
verificationLink: url,
59+
},
60+
});
61+
},
62+
},
3563
plugins: [
3664
organization({
3765
async sendInvitationEmail(data) {

apps/backend/src/services/email/interface.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export type EmailTemplateMap = {
1212
universityName: string;
1313
invitationLink: string;
1414
};
15+
'email-verification': {
16+
userName: string;
17+
verificationLink: string;
18+
};
19+
'password-reset': {
20+
userName: string;
21+
resetLink: string;
22+
};
1523
};
1624

1725
export type EmailTemplateId = keyof EmailTemplateMap;

apps/backend/src/services/email/templates.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,34 @@ describe('resolveEmailTemplate', () => {
4343
expect(email.text).toContain('メンバー招待を開く: https://app.example.test/invite/5678');
4444
});
4545

46+
it('renders email verification emails', () => {
47+
const email = resolveEmailTemplate('email-verification', {
48+
userName: 'New User',
49+
verificationLink: 'https://app.example.test/auth/verify-email?token=token-1',
50+
});
51+
52+
expect(email.subject).toBe('DocShare メールアドレスの確認');
53+
expect(email.html).toContain('New User さん');
54+
expect(email.html).toContain('メールアドレスの確認を完了してください');
55+
expect(email.text).toContain(
56+
'メールアドレスを確認する: https://app.example.test/auth/verify-email?token=token-1',
57+
);
58+
});
59+
60+
it('renders password reset emails', () => {
61+
const email = resolveEmailTemplate('password-reset', {
62+
userName: 'Existing User',
63+
resetLink: 'https://app.example.test/auth/reset-password?token=token-2',
64+
});
65+
66+
expect(email.subject).toBe('DocShare パスワード再設定');
67+
expect(email.html).toContain('Existing User さん');
68+
expect(email.html).toContain('パスワード再設定がリクエストされました');
69+
expect(email.text).toContain(
70+
'パスワードを再設定する: https://app.example.test/auth/reset-password?token=token-2',
71+
);
72+
});
73+
4674
it('escapes dynamic values in html output', () => {
4775
const email = resolveEmailTemplate('organization-invitation', {
4876
organizationName: 'R&D <Team>',

apps/backend/src/services/email/templates.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,36 @@ const emailTemplateDefinitions: {
139139
},
140140
}),
141141
},
142+
'email-verification': {
143+
render: (payload) =>
144+
renderEmail({
145+
subject: 'DocShare メールアドレスの確認',
146+
heading: 'メールアドレスの確認',
147+
body: [
148+
`${payload.userName} さん、DocShare への登録ありがとうございます。`,
149+
'以下のリンクからメールアドレスの確認を完了してください。',
150+
],
151+
action: {
152+
label: 'メールアドレスを確認する',
153+
href: payload.verificationLink,
154+
},
155+
}),
156+
},
157+
'password-reset': {
158+
render: (payload) =>
159+
renderEmail({
160+
subject: 'DocShare パスワード再設定',
161+
heading: 'パスワード再設定',
162+
body: [
163+
`${payload.userName} さんの DocShare アカウントで、パスワード再設定がリクエストされました。`,
164+
'以下のリンクから新しいパスワードを設定してください。',
165+
],
166+
action: {
167+
label: 'パスワードを再設定する',
168+
href: payload.resetLink,
169+
},
170+
}),
171+
},
142172
};
143173

144174
export const resolveEmailTemplate = <TemplateId extends EmailTemplateId>(
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { useState } from 'react';
5+
import { Button } from '@/components/ui/button';
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7+
import { Input } from '@/components/ui/input';
8+
import { useForgotPasswordForm } from '@/features/public/auth/forgot-password/hooks';
9+
10+
export default function ForgotPasswordPage() {
11+
const [sentEmail, setSentEmail] = useState<string | null>(null);
12+
const { form, error, validators } = useForgotPasswordForm((email) => {
13+
setSentEmail(email);
14+
});
15+
16+
if (sentEmail) {
17+
return (
18+
<div className='container mx-auto px-4 py-16 flex justify-center'>
19+
<Card className='w-full max-w-md'>
20+
<CardHeader>
21+
<CardTitle>再設定メールを送信しました</CardTitle>
22+
<CardDescription>{sentEmail} に再設定リンクを送信しました</CardDescription>
23+
</CardHeader>
24+
<CardContent>
25+
<p className='text-sm text-muted-foreground'>
26+
メール内のリンクから新しいパスワードを設定してください。
27+
</p>
28+
</CardContent>
29+
</Card>
30+
</div>
31+
);
32+
}
33+
34+
return (
35+
<div className='container mx-auto px-4 py-16 flex justify-center'>
36+
<Card className='w-full max-w-md'>
37+
<CardHeader>
38+
<CardTitle>パスワード再設定</CardTitle>
39+
<CardDescription>登録済みのメールアドレスへ再設定リンクを送信します</CardDescription>
40+
</CardHeader>
41+
<CardContent>
42+
<form
43+
onSubmit={(e) => {
44+
e.preventDefault();
45+
form.handleSubmit();
46+
}}
47+
className='space-y-4'
48+
>
49+
<form.Field name='email' validators={{ onChange: validators.email }}>
50+
{(field) => (
51+
<div className='space-y-1'>
52+
<label htmlFor={field.name} className='text-sm font-medium'>
53+
メールアドレス
54+
</label>
55+
<Input
56+
id={field.name}
57+
type='email'
58+
placeholder='you@example.com'
59+
value={field.state.value}
60+
onBlur={field.handleBlur}
61+
onChange={(e) => field.handleChange(e.target.value)}
62+
/>
63+
{field.state.meta.errors[0] && (
64+
<p className='text-sm text-destructive'>{field.state.meta.errors[0].message}</p>
65+
)}
66+
</div>
67+
)}
68+
</form.Field>
69+
{error && <p className='text-sm text-destructive'>{error}</p>}
70+
<Button type='submit' className='w-full' disabled={form.state.isSubmitting}>
71+
{form.state.isSubmitting ? '送信中...' : '再設定メールを送信'}
72+
</Button>
73+
</form>
74+
<p className='text-sm text-center text-muted-foreground mt-4'>
75+
<Link href='/auth/login' className='text-primary hover:underline'>
76+
ログインへ戻る
77+
</Link>
78+
</p>
79+
</CardContent>
80+
</Card>
81+
</div>
82+
);
83+
}

apps/frontend/app/(public)/auth/login/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ function LoginPageContent() {
8383
{form.state.isSubmitting ? 'ログイン中...' : 'ログイン'}
8484
</Button>
8585
</form>
86+
<p className='text-sm text-center text-muted-foreground mt-4'>
87+
<Link href='/auth/forgot-password' className='text-primary hover:underline'>
88+
パスワードをお忘れの方
89+
</Link>
90+
</p>
8691
<p className='text-sm text-center text-muted-foreground mt-4'>
8792
アカウントをお持ちでない方は{' '}
8893
<Link href='/auth/register' className='text-primary hover:underline'>

apps/frontend/app/(public)/auth/register/page.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,40 @@
22

33
import Link from 'next/link';
44
import { useRouter } from 'next/navigation';
5+
import { useState } from 'react';
56
import { Button } from '@/components/ui/button';
67
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
78
import { Input } from '@/components/ui/input';
89
import { useRegisterForm } from '@/features/public/auth/register/hooks';
910

1011
export default function RegisterPage() {
1112
const router = useRouter();
12-
const { form, error, validators } = useRegisterForm(() => {
13-
router.push('/dashboard');
13+
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
14+
const { form, error, validators } = useRegisterForm((email) => {
15+
setRegisteredEmail(email);
1416
});
1517

18+
if (registeredEmail) {
19+
return (
20+
<div className='container mx-auto px-4 py-16 flex justify-center'>
21+
<Card className='w-full max-w-md'>
22+
<CardHeader>
23+
<CardTitle>確認メールを送信しました</CardTitle>
24+
<CardDescription>{registeredEmail} に確認リンクを送信しました</CardDescription>
25+
</CardHeader>
26+
<CardContent className='space-y-4'>
27+
<p className='text-sm text-muted-foreground'>
28+
メール内のリンクを開くとアカウント作成が完了します。
29+
</p>
30+
<Button type='button' className='w-full' onClick={() => router.push('/auth/login')}>
31+
ログインへ
32+
</Button>
33+
</CardContent>
34+
</Card>
35+
</div>
36+
);
37+
}
38+
1639
return (
1740
<div className='container mx-auto px-4 py-16 flex justify-center'>
1841
<Card className='w-full max-w-md'>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { useSearchParams } from 'next/navigation';
5+
import { Suspense, useState } from 'react';
6+
import { Button } from '@/components/ui/button';
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
8+
import { Input } from '@/components/ui/input';
9+
import { useResetPasswordForm } from '@/features/public/auth/reset-password/hooks';
10+
11+
export default function ResetPasswordPage() {
12+
return (
13+
<Suspense fallback={null}>
14+
<ResetPasswordPageContent />
15+
</Suspense>
16+
);
17+
}
18+
19+
function ResetPasswordPageContent() {
20+
const searchParams = useSearchParams();
21+
const token = searchParams.get('token');
22+
const linkError = searchParams.get('error');
23+
const [completed, setCompleted] = useState(false);
24+
const { form, error, validators } = useResetPasswordForm(token, () => {
25+
setCompleted(true);
26+
});
27+
28+
if (completed) {
29+
return (
30+
<div className='container mx-auto px-4 py-16 flex justify-center'>
31+
<Card className='w-full max-w-md'>
32+
<CardHeader>
33+
<CardTitle>パスワードを再設定しました</CardTitle>
34+
<CardDescription>新しいパスワードでログインできます</CardDescription>
35+
</CardHeader>
36+
<CardContent>
37+
<Button className='w-full' render={<Link href='/auth/login' />}>
38+
ログインへ
39+
</Button>
40+
</CardContent>
41+
</Card>
42+
</div>
43+
);
44+
}
45+
46+
return (
47+
<div className='container mx-auto px-4 py-16 flex justify-center'>
48+
<Card className='w-full max-w-md'>
49+
<CardHeader>
50+
<CardTitle>新しいパスワード</CardTitle>
51+
<CardDescription>アカウントに設定する新しいパスワードを入力してください</CardDescription>
52+
</CardHeader>
53+
<CardContent>
54+
<form
55+
onSubmit={(e) => {
56+
e.preventDefault();
57+
form.handleSubmit();
58+
}}
59+
className='space-y-4'
60+
>
61+
<form.Field name='password' validators={{ onChange: validators.password }}>
62+
{(field) => (
63+
<div className='space-y-1'>
64+
<label htmlFor={field.name} className='text-sm font-medium'>
65+
パスワード
66+
</label>
67+
<Input
68+
id={field.name}
69+
type='password'
70+
value={field.state.value}
71+
onBlur={field.handleBlur}
72+
onChange={(e) => field.handleChange(e.target.value)}
73+
/>
74+
{field.state.meta.errors[0] && (
75+
<p className='text-sm text-destructive'>{field.state.meta.errors[0].message}</p>
76+
)}
77+
</div>
78+
)}
79+
</form.Field>
80+
<form.Field
81+
name='confirmPassword'
82+
validators={{
83+
onChangeListenTo: ['password'],
84+
onChange: ({ value, fieldApi }) => {
85+
return validators.confirmPassword({
86+
value,
87+
password: fieldApi.form.getFieldValue('password'),
88+
});
89+
},
90+
}}
91+
>
92+
{(field) => (
93+
<div className='space-y-1'>
94+
<label htmlFor={field.name} className='text-sm font-medium'>
95+
パスワード(確認)
96+
</label>
97+
<Input
98+
id={field.name}
99+
type='password'
100+
value={field.state.value}
101+
onBlur={field.handleBlur}
102+
onChange={(e) => field.handleChange(e.target.value)}
103+
/>
104+
{field.state.meta.errors[0] && (
105+
<p className='text-sm text-destructive'>{field.state.meta.errors[0].message}</p>
106+
)}
107+
</div>
108+
)}
109+
</form.Field>
110+
{linkError && <p className='text-sm text-destructive'>再設定リンクが無効です</p>}
111+
{error && <p className='text-sm text-destructive'>{error}</p>}
112+
<Button type='submit' className='w-full' disabled={form.state.isSubmitting || !token}>
113+
{form.state.isSubmitting ? '再設定中...' : 'パスワードを再設定'}
114+
</Button>
115+
</form>
116+
</CardContent>
117+
</Card>
118+
</div>
119+
);
120+
}

0 commit comments

Comments
 (0)