Skip to content

Commit 5665774

Browse files
committed
resend activation email
1 parent 6859f0d commit 5665774

File tree

5 files changed

+209
-4
lines changed

5 files changed

+209
-4
lines changed

apps/backend/src/api/routes/auth.controller.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto
1515
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
1616
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
1717
import { ForgotPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot.password.dto';
18+
import { ResendActivationDto } from '@gitroom/nestjs-libraries/dtos/auth/resend-activation.dto';
1819
import { ApiTags } from '@nestjs/swagger';
1920
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
2021
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
@@ -234,6 +235,21 @@ export class AuthController {
234235
return response.status(200).json({ can: true });
235236
}
236237

238+
@Post('/resend-activation')
239+
async resendActivation(@Body() body: ResendActivationDto) {
240+
try {
241+
await this._authService.resendActivationEmail(body.email);
242+
return {
243+
success: true,
244+
};
245+
} catch (e: any) {
246+
return {
247+
success: false,
248+
message: e.message,
249+
};
250+
}
251+
}
252+
237253
@Post('/oauth/:provider/exists')
238254
async oauthExists(
239255
@Body('code') code: string,

apps/backend/src/services/auth/auth.service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,29 @@ export class AuthService {
222222
return false;
223223
}
224224

225+
async resendActivationEmail(email: string) {
226+
const user = await this._userService.getUserByEmail(email);
227+
228+
if (!user) {
229+
throw new Error('User not found');
230+
}
231+
232+
if (user.activated) {
233+
throw new Error('Account is already activated');
234+
}
235+
236+
const jwt = await this.jwt(user);
237+
238+
await this._emailService.sendEmail(
239+
user.email,
240+
'Activate your account',
241+
`Click <a href="${process.env.FRONTEND_URL}/auth/activate/${jwt}">here</a> to activate your account`,
242+
'top'
243+
);
244+
245+
return true;
246+
}
247+
225248
oauthLink(provider: string, query?: any) {
226249
const providerInstance = ProvidersFactory.loadProvider(
227250
provider as Provider

apps/frontend/src/components/auth/activate.tsx

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,73 @@
11
'use client';
22

33
import { useT } from '@gitroom/react/translation/get.transation.service.client';
4+
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
5+
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
6+
import { Button } from '@gitroom/react/form/button';
7+
import { Input } from '@gitroom/react/form/input';
8+
import { useState, useEffect, useCallback } from 'react';
9+
import Link from 'next/link';
10+
11+
type ResendInputs = {
12+
email: string;
13+
};
14+
15+
type ResendStatus = 'idle' | 'sent' | 'already_activated';
16+
17+
const COOLDOWN_SECONDS = 60;
418

519
export function Activate() {
620
const t = useT();
21+
const fetch = useFetch();
22+
const [loading, setLoading] = useState(false);
23+
const [status, setStatus] = useState<ResendStatus>('idle');
24+
const [cooldown, setCooldown] = useState(0);
25+
const form = useForm<ResendInputs>();
26+
27+
useEffect(() => {
28+
if (cooldown <= 0) return;
29+
30+
const timer = setInterval(() => {
31+
setCooldown((prev) => prev - 1);
32+
}, 1000);
33+
34+
return () => clearInterval(timer);
35+
}, [cooldown]);
36+
37+
const resetToForm = useCallback(() => {
38+
setStatus('idle');
39+
setCooldown(COOLDOWN_SECONDS);
40+
}, []);
41+
42+
const onSubmit: SubmitHandler<ResendInputs> = async (data) => {
43+
setLoading(true);
44+
try {
45+
const response = await fetch('/auth/resend-activation', {
46+
method: 'POST',
47+
body: JSON.stringify(data),
48+
});
49+
const result = await response.json();
50+
if (result.success) {
51+
setStatus('sent');
52+
setCooldown(COOLDOWN_SECONDS);
53+
} else if (result.message === 'Account is already activated') {
54+
setStatus('already_activated');
55+
} else {
56+
form.setError('email', {
57+
message: result.message || t('failed_to_resend', 'Failed to resend activation email'),
58+
});
59+
}
60+
} catch (e) {
61+
form.setError('email', {
62+
message: t('error_occurred', 'An error occurred. Please try again.'),
63+
});
64+
} finally {
65+
setLoading(false);
66+
}
67+
};
68+
769
return (
8-
<div className="flex flex-col">
70+
<div className="flex flex-col flex-1">
971
<div>
1072
<h1 className="text-3xl font-bold text-start mb-4 cursor-pointer">
1173
{t('activate_your_account', 'Activate your account')}
@@ -19,6 +81,78 @@ export function Activate() {
1981
'Please check your email to activate your account.'
2082
)}
2183
</div>
84+
85+
<div className="mt-8 border-t border-fifth pt-6">
86+
<h2 className="text-lg font-semibold mb-4">
87+
{t('didnt_receive_email', "Didn't receive the email?")}
88+
</h2>
89+
{status === 'sent' ? (
90+
<div className="flex flex-col gap-4">
91+
<div className="text-green-400">
92+
{t(
93+
'activation_email_sent',
94+
'Activation email has been sent! Please check your inbox.'
95+
)}
96+
</div>
97+
{cooldown > 0 ? (
98+
<p className="text-sm text-textColor">
99+
{t('resend_available_in', 'You can resend in')} {cooldown}s
100+
</p>
101+
) : (
102+
<Button
103+
onClick={resetToForm}
104+
className="rounded-[10px] !h-[52px]"
105+
>
106+
{t('send_again', 'Send Again')}
107+
</Button>
108+
)}
109+
</div>
110+
) : status === 'already_activated' ? (
111+
<div className="flex flex-col gap-4">
112+
<div className="text-green-400">
113+
{t(
114+
'account_already_activated',
115+
'Great news! Your account is already activated.'
116+
)}
117+
</div>
118+
<Link href="/auth/login">
119+
<Button className="rounded-[10px] !h-[52px] w-full">
120+
{t('go_to_login', 'Go to Login')}
121+
</Button>
122+
</Link>
123+
</div>
124+
) : (
125+
<FormProvider {...form}>
126+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
127+
<Input
128+
label={t('label_email', 'Email')}
129+
translationKey="label_email"
130+
{...form.register('email', { required: true })}
131+
type="email"
132+
placeholder={t('email_address', 'Email Address')}
133+
/>
134+
<Button
135+
type="submit"
136+
className="rounded-[10px] !h-[52px]"
137+
loading={loading}
138+
disabled={cooldown > 0}
139+
>
140+
{cooldown > 0
141+
? `${t('resend_available_in', 'You can resend in')} ${cooldown}s`
142+
: t('resend_activation_email', 'Resend Activation Email')}
143+
</Button>
144+
</form>
145+
</FormProvider>
146+
)}
147+
{status !== 'already_activated' && (
148+
<p className="mt-4 text-sm text-textColor">
149+
{t('already_activated', 'Already activated?')}&nbsp;
150+
<Link href="/auth/login" className="underline cursor-pointer">
151+
{t('sign_in', 'Sign In')}
152+
</Link>
153+
</p>
154+
)}
155+
</div>
22156
</div>
23157
);
24158
}

apps/frontend/src/components/auth/login.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Inputs = {
2424
export function Login() {
2525
const t = useT();
2626
const [loading, setLoading] = useState(false);
27+
const [notActivated, setNotActivated] = useState(false);
2728
const { isGeneral, neynarClientId, billingEnabled, genericOauth } =
2829
useVariables();
2930
const resolver = useMemo(() => {
@@ -39,6 +40,7 @@ export function Login() {
3940
const fetchData = useFetch();
4041
const onSubmit: SubmitHandler<Inputs> = async (data) => {
4142
setLoading(true);
43+
setNotActivated(false);
4244
const login = await fetchData('/auth/login', {
4345
method: 'POST',
4446
body: JSON.stringify({
@@ -47,9 +49,14 @@ export function Login() {
4749
}),
4850
});
4951
if (login.status === 400) {
50-
form.setError('email', {
51-
message: await login.text(),
52-
});
52+
const errorMessage = await login.text();
53+
if (errorMessage === 'User is not activated') {
54+
setNotActivated(true);
55+
} else {
56+
form.setError('email', {
57+
message: errorMessage,
58+
});
59+
}
5360
setLoading(false);
5461
}
5562
};
@@ -103,6 +110,22 @@ export function Login() {
103110
placeholder={t('label_password', 'Password')}
104111
/>
105112
</div>
113+
{notActivated && (
114+
<div className="bg-amber-500/10 border border-amber-500/30 rounded-[10px] p-4 mb-4">
115+
<p className="text-amber-400 text-sm mb-2">
116+
{t(
117+
'account_not_activated',
118+
'Your account is not activated yet. Please check your email for the activation link.'
119+
)}
120+
</p>
121+
<Link
122+
href="/auth/activate"
123+
className="text-amber-400 underline hover:font-bold text-sm"
124+
>
125+
{t('resend_activation_email', 'Resend Activation Email')}
126+
</Link>
127+
</div>
128+
)}
106129
<div className="text-center mt-6">
107130
<div className="w-full flex">
108131
<Button
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { IsDefined, IsEmail, IsString } from 'class-validator';
2+
3+
export class ResendActivationDto {
4+
@IsString()
5+
@IsDefined()
6+
@IsEmail()
7+
email: string;
8+
}
9+

0 commit comments

Comments
 (0)