feat: implement email verification and password reset features

- Add EmailTokenService for handling email verification and password reset tokens.
- Create EmailService for sending verification and reset emails via SMTP.
- Update AuthService to handle email verification status during login.
- Modify user registration to redirect to a check email screen instead of issuing a token.
- Implement resend verification email functionality.
- Add deep link handling for email verification and password reset in the mobile app.
- Update mobile app routes and components to support new email verification flow.
- Enhance error handling for unverified emails during login attempts.
- Update configuration to include SMTP settings for email service.
This commit is contained in:
2026-03-14 23:38:15 -03:00
parent f5f4e878b4
commit d5af338ee1
38 changed files with 1146 additions and 75 deletions
@@ -1,15 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
import { resendVerificationUseCase } from '@infrastructure/container';
const COOLDOWN_SECS = 60;
/**
* Tela de Verificação de E-mail RU-0
* Mostrada após o cadastro bem-sucedido.
*/
export default function VerifyEmailScreen(): React.ReactElement {
export default function CheckEmailScreen(): React.ReactElement {
const { email } = useLocalSearchParams<{ email: string }>();
const [isLoading, setIsLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [sent, setSent] = useState(false);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
async function handleResend(): Promise<void> {
if (!email || cooldown > 0) return;
setIsLoading(true);
try {
await resendVerificationUseCase.execute(email);
setSent(true);
setCooldown(COOLDOWN_SECS);
} finally {
setIsLoading(false);
}
}
return (
<Screen>
<Header title="Verificar e-mail" showBack />
@@ -24,6 +52,27 @@ export default function VerifyEmailScreen(): React.ReactElement {
Enviamos um link de verificação para o seu e-mail. Clique no link para ativar sua conta e poder fazer login.
</Text>
{email && (
<Button
label={
cooldown > 0
? `Reenviar em ${cooldown}s`
: sent
? 'Reenviar e-mail'
: 'Reenviar e-mail'
}
variant="secondary"
onPress={handleResend}
isLoading={isLoading}
disabled={cooldown > 0}
style={styles.resendButton}
/>
)}
{sent && cooldown > 0 && (
<Text style={styles.sentText}>E-mail reenviado! Verifique sua caixa de entrada.</Text>
)}
<View style={styles.hint}>
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
<Text style={styles.hintText}>Não recebeu? Verifique sua pasta de spam.</Text>
@@ -64,11 +113,20 @@ const styles = StyleSheet.create({
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
resendButton: {
width: '100%',
},
sentText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
textAlign: 'center',
},
hint: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[2],
marginTop: spacing[4],
marginTop: spacing[2],
},
hintText: {
fontFamily: typography.fontFamily.ui,
+10 -2
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import axios from 'axios';
import { Link, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -42,8 +43,15 @@ export default function LoginScreen(): React.ReactElement {
await setSession(session);
router.replace('/(app)/(tabs)/home');
} catch (err) {
console.error('login error', err);
Alert.alert('Erro', 'E-mail ou senha incorretos.');
if (axios.isAxiosError(err) && err.response?.data?.code === 'EMAIL_NOT_VERIFIED') {
Alert.alert(
'E-mail não verificado',
'Confirme seu e-mail antes de entrar. Verifique sua caixa de entrada.',
[{ text: 'OK', onPress: () => router.push(`/(auth)/check-email?email=${encodeURIComponent(data.email)}`) }],
);
} else {
Alert.alert('Erro', 'E-mail ou senha incorretos.');
}
} finally {
setIsLoading(false);
}
+2 -5
View File
@@ -11,7 +11,6 @@ import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
import { registerUseCase } from '@infrastructure/container';
import { useAuthStore } from '@store/authStore';
const schema = z.object({
email: z.string().email('E-mail inválido'),
@@ -31,7 +30,6 @@ type FormData = z.infer<typeof schema>;
*/
export default function RegisterScreen(): React.ReactElement {
const router = useRouter();
const setSession = useAuthStore((s) => s.setSession);
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
@@ -42,9 +40,8 @@ export default function RegisterScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
const session = await registerUseCase.execute({ email: data.email, password: data.password });
await setSession(session);
router.replace('/(auth)/verify-email');
await registerUseCase.execute({ email: data.email, password: data.password });
router.replace(`/(auth)/check-email?email=${encodeURIComponent(data.email)}`);
} catch {
Alert.alert('Erro', 'Não foi possível criar sua conta.');
} finally {