From b48f7783c97234de13b361b0614889e86daf0a2d Mon Sep 17 00:00:00 2001 From: Diogo Tavares Date: Tue, 26 May 2026 11:36:09 +0100 Subject: [PATCH] recover password feature --- app/(auth)/recover/confirm.tsx | 106 +++++++++++ app/(auth)/recover/index.tsx | 162 ++++++++--------- app/(auth)/recover/reset.tsx | 166 ++++++++++++++++++ app/(tabs)/home/index.tsx | 4 + .../components/auth/RecoverScreenLayout.tsx | 52 ++++++ assets/config/api.ts | 6 +- assets/contexts/useAuth.tsx | 11 +- assets/services/documentSync.ts | 17 ++ assets/services/offlineStorage.ts | 24 ++- assets/services/passwordRecovery.ts | 50 ++++++ assets/types/index.tsx | 5 + styles/screens/auth/recover.styles.ts | 21 +++ 12 files changed, 527 insertions(+), 97 deletions(-) create mode 100644 app/(auth)/recover/confirm.tsx create mode 100644 app/(auth)/recover/reset.tsx create mode 100644 assets/components/auth/RecoverScreenLayout.tsx create mode 100644 assets/services/passwordRecovery.ts diff --git a/app/(auth)/recover/confirm.tsx b/app/(auth)/recover/confirm.tsx new file mode 100644 index 0000000..541e3ad --- /dev/null +++ b/app/(auth)/recover/confirm.tsx @@ -0,0 +1,106 @@ +import { RecoverScreenLayout } from '@/assets/components/auth/RecoverScreenLayout'; +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { confirmRecoveryToken } from '@/assets/services/passwordRecovery'; +import styles from '@/styles/screens/auth/recover.styles'; +import { router, type Href, useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { Image, Pressable, Text, TextInput } from 'react-native'; + +export default function RecoverConfirmScreen() { + const { email, message } = useLocalSearchParams<{ email?: string; message?: string }>(); + const [token, setToken] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const emailValue = typeof email === 'string' ? email : ''; + + const handleTokenChange = (text: string) => { + setToken(text.replace(/\D/g, '').slice(0, 6)); + }; + + const handleSubmit = async () => { + if (!emailValue) { + setError('Sessão inválida. Volta ao início do processo.'); + return; + } + if (token.length !== 6) { + setError('Introduz o código de 6 dígitos recebido por email.'); + return; + } + + setError(null); + setIsLoading(true); + try { + const data = await confirmRecoveryToken(emailValue, token); + if (data.status === 200) { + router.push({ + pathname: '/recover/reset', + params: { email: emailValue, token }, + } as Href); + return; + } + setError(data.message || 'Código inválido ou expirado.'); + } catch { + setError('Falha ao contactar o servidor. Tenta novamente.'); + } finally { + setIsLoading(false); + } + }; + + if (!emailValue) { + return ( + + Email em falta. + router.replace('/recover' as Href)}> + Voltar + + + ); + } + + return ( + + + Código* + + + + {!!error && {error}} + + + {isLoading ? ( + + ) : ( + <> + Validar código + + + )} + + + router.replace('/recover' as Href)} disabled={isLoading}> + Voltar + + + ); +} diff --git a/app/(auth)/recover/index.tsx b/app/(auth)/recover/index.tsx index 8ff08b8..1ad15ca 100644 --- a/app/(auth)/recover/index.tsx +++ b/app/(auth)/recover/index.tsx @@ -1,106 +1,86 @@ -import { Ionicons } from '@expo/vector-icons'; -import { router, type Href } from 'expo-router'; -import React, { useState } from 'react'; -import { - Image, - ImageBackground, - KeyboardAvoidingView, - Modal, - Platform, - Pressable, - ScrollView, - Text, - TextInput, - View, - StatusBar, -} from 'react-native'; +import { RecoverScreenLayout } from '@/assets/components/auth/RecoverScreenLayout'; +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { recoverPassword } from '@/assets/services/passwordRecovery'; import styles from '@/styles/screens/auth/recover.styles'; +import { router, type Href } from 'expo-router'; +import { useState } from 'react'; +import { Image, Pressable, Text, TextInput } from 'react-native'; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export default function Recover() { +export default function RecoverEmailScreen() { const [email, setEmail] = useState(''); - const [localError, setLocalError] = useState(null); - const [showSuccessModal, setShowSuccessModal] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); - const handleRecover = () => { - if (!email.trim()) { - setLocalError('Indica o teu email para continuar.'); + const handleSubmit = async () => { + const trimmed = email.trim(); + if (!trimmed) { + setError('Indica o teu email para continuar.'); + return; + } + if (!EMAIL_REGEX.test(trimmed)) { + setError('Introduz um email válido.'); return; } - setLocalError(null); - setShowSuccessModal(true); + setError(null); + setIsLoading(true); + try { + const data = await recoverPassword(trimmed); + if (data.status === 200) { + router.push({ + pathname: '/recover/confirm', + params: { email: trimmed, message: data.message ?? '' }, + } as Href); + return; + } + setError(data.message || 'Não foi possível enviar o código.'); + } catch { + setError('Falha ao contactar o servidor. Tenta novamente.'); + } finally { + setIsLoading(false); + } }; return ( - - - - - - - - Repor palavra-passe - Indica o teu email para receberes o link de recuperacao. - - + + + Email* + + - - - Email* - - + {!!error && {error}} - {!!localError && {localError}} + + {isLoading ? ( + + ) : ( + <> + Enviar código + + + )} + - - Recuperar Palavra-passe - - - - router.replace('/login' as Href)}> - Voltar ao Login - - - - - - - - - - - Verifica o teu email - Enviamos-te as instrucoes para recuperares a palavra-passe. - { - setShowSuccessModal(false); - router.replace('/login' as Href); - }}> - Ok - - - - - + router.replace('/login' as Href)} disabled={isLoading}> + Voltar ao Login + + ); -} \ No newline at end of file +} diff --git a/app/(auth)/recover/reset.tsx b/app/(auth)/recover/reset.tsx new file mode 100644 index 0000000..5099e7d --- /dev/null +++ b/app/(auth)/recover/reset.tsx @@ -0,0 +1,166 @@ +import { RecoverScreenLayout } from '@/assets/components/auth/RecoverScreenLayout'; +import { LoadingSpinner } from '@/assets/components/LoadingSpinner'; +import { resetPassword } from '@/assets/services/passwordRecovery'; +import styles from '@/styles/screens/auth/recover.styles'; +import { Ionicons } from '@expo/vector-icons'; +import { router, type Href, useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { Image, Modal, Pressable, Text, TextInput, View } from 'react-native'; + +export default function RecoverResetScreen() { + const { email, token } = useLocalSearchParams<{ email?: string; token?: string }>(); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const emailValue = typeof email === 'string' ? email : ''; + const tokenValue = typeof token === 'string' ? token : ''; + + const handleSubmit = async () => { + if (!emailValue || !tokenValue) { + setError('Sessão inválida. Volta ao início do processo.'); + return; + } + if (password.length < 5) { + setError('A palavra-passe deve ter pelo menos 5 caracteres.'); + return; + } + if (password !== confirmPassword) { + setError('As palavras-passe não coincidem.'); + return; + } + + setError(null); + setIsLoading(true); + try { + const data = await resetPassword(emailValue, tokenValue, password, confirmPassword); + if (data.status === 200) { + setSuccessMessage(data.message || 'Password atualizada com sucesso.'); + setShowSuccessModal(true); + return; + } + setError(data.message || 'Não foi possível atualizar a palavra-passe.'); + } catch { + setError('Falha ao contactar o servidor. Tenta novamente.'); + } finally { + setIsLoading(false); + } + }; + + if (!emailValue || !tokenValue) { + return ( + + Dados em falta. + router.replace('/recover' as Href)}> + Voltar + + + ); + } + + return ( + <> + + + Nova palavra-passe* + + + + setShowPassword((p) => !p)} hitSlop={8}> + + + + + + Confirmar palavra-passe* + + + + setShowConfirmPassword((p) => !p)} hitSlop={8}> + + + + + {!!error && {error}} + + + {isLoading ? ( + + ) : ( + <> + Guardar palavra-passe + + + )} + + + + router.replace({ + pathname: '/recover/confirm', + params: { email: emailValue }, + } as Href) + } + disabled={isLoading}> + Voltar + + + + + + + + + + Palavra-passe atualizada + {successMessage} + { + setShowSuccessModal(false); + router.replace('/login' as Href); + }}> + Ir para o Login + + + + + + ); +} diff --git a/app/(tabs)/home/index.tsx b/app/(tabs)/home/index.tsx index 9d7b800..777ff32 100644 --- a/app/(tabs)/home/index.tsx +++ b/app/(tabs)/home/index.tsx @@ -79,6 +79,7 @@ export default function Home() { if (!token) { setReservas([]); + setIsFromCache(false); return; } @@ -127,6 +128,9 @@ export default function Home() { }; useEffect(() => { + setReservas([]); + setIsFromCache(false); + setIsLoading(true); fetchReservas(); }, [token]); diff --git a/assets/components/auth/RecoverScreenLayout.tsx b/assets/components/auth/RecoverScreenLayout.tsx new file mode 100644 index 0000000..7b585ed --- /dev/null +++ b/assets/components/auth/RecoverScreenLayout.tsx @@ -0,0 +1,52 @@ +import styles from '@/styles/screens/auth/recover.styles'; +import { ReactNode } from 'react'; +import { + Image, + ImageBackground, + KeyboardAvoidingView, + Platform, + ScrollView, + StatusBar, + Text, + View, +} from 'react-native'; + +type Props = { + title: string; + subtitle: string; + children: ReactNode; +}; + +export function RecoverScreenLayout({ title, subtitle, children }: Props) { + return ( + + + + + + + + {title} + {subtitle} + + + + {children} + + + ); +} diff --git a/assets/config/api.ts b/assets/config/api.ts index ab1ae6b..1ff2051 100644 --- a/assets/config/api.ts +++ b/assets/config/api.ts @@ -9,7 +9,6 @@ export const API_BASE_URL = "https://apmtests.webclientes.com/pt/app"; export const API_BASE_URL_DEV = "https://apmtests.webclientes.com/pt/app"; -export const API_URL_RECUPERAR_PALAVRA_PASSE = "https://gurudasviagens.pt/pt/areareservada/recuperarPassword/"; /** * Endpoints da aplicação * Adicione novos endpoints aqui conforme necessário @@ -19,7 +18,10 @@ export const API_ENDPOINTS = { USER_LOGIN: "/UserLogin", USER_LOGOUT: "/UserLogout", UPDATE_PROFILE: "/UserEditProfile", - + RECOVER_PASSWORD: "/recoverPassword", + CONFIRM_TOKEN: "/confirmToken", + RESET_PASSWORD: "/resetPassword", + // Utilizador USER_RESERVAS: "/getUserReservas", USER_INFO: "/UserInfo", diff --git a/assets/contexts/useAuth.tsx b/assets/contexts/useAuth.tsx index 5a24b16..c7673ed 100644 --- a/assets/contexts/useAuth.tsx +++ b/assets/contexts/useAuth.tsx @@ -2,6 +2,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import CryptoJS from 'crypto-js'; import { createContext, useContext, useEffect, useState } from 'react'; import { API_CONFIG, API_ENDPOINTS, buildApiUrl } from '../config/api'; +import { clearDownloadedDocuments } from '../services/documentSync'; +import { clearUserSessionCache } from '../services/offlineStorage'; import { AuthContextType, AuthProviderProps, LoginResponse, User, UserData } from '../types'; // Contexto const AuthContext = createContext(undefined); @@ -74,6 +76,10 @@ export function AuthProvider({ children }: AuthProviderProps) { throw new Error(data.message || 'Erro ao fazer login'); } + // Limpar dados do utilizador anterior antes de guardar a nova sessão + await clearUserSessionCache(); + await clearDownloadedDocuments(); + // Se a resposta for bem-sucedida, Guardar token e user if (data.token) { setToken(data.token); @@ -186,7 +192,10 @@ export function AuthProvider({ children }: AuthProviderProps) { setToken(null); setError(null); - // Limpar AsyncStorage + await clearUserSessionCache(); + await clearDownloadedDocuments(); + + // Limpar credenciais de sessão await Promise.all([ AsyncStorage.removeItem(STORAGE_KEYS.TOKEN), AsyncStorage.removeItem(STORAGE_KEYS.USER), diff --git a/assets/services/documentSync.ts b/assets/services/documentSync.ts index e0013ef..67f28a0 100644 --- a/assets/services/documentSync.ts +++ b/assets/services/documentSync.ts @@ -128,6 +128,23 @@ export const verificarDocumentosParaDownload = async ( return para; }; +/** Remove documentos descarregados e checksums (ex.: no logout). */ +export const clearDownloadedDocuments = async (): Promise => { + try { + const keys = await AsyncStorage.getAllKeys(); + const docKeys = keys.filter((key) => key.startsWith("@cruiseLovers:documento:")); + if (docKeys.length > 0) { + await AsyncStorage.multiRemove(docKeys); + } + const info = await FileSystem.getInfoAsync(DOCUMENTOS_DIR); + if (info.exists) { + await FileSystem.deleteAsync(DOCUMENTOS_DIR, { idempotent: true }); + } + } catch (error) { + console.error("Erro ao limpar documentos locais:", error); + } +}; + // Descarrega múltiplos documentos export const downloadDocumentos = async ( documentos: DocumentoChecksum[], diff --git a/assets/services/offlineStorage.ts b/assets/services/offlineStorage.ts index 6e11097..04f51e3 100644 --- a/assets/services/offlineStorage.ts +++ b/assets/services/offlineStorage.ts @@ -166,9 +166,11 @@ export const clearCache = async (): Promise => { export const clearReservasCache = async (): Promise => { try { const keys = await AsyncStorage.getAllKeys(); - const reservaKeys = keys.filter(key => - key === STORAGE_KEYS.RESERVAS || - key.startsWith(STORAGE_KEYS.RESERVA_DETAIL) + const reservaKeys = keys.filter( + (key) => + key === STORAGE_KEYS.RESERVAS || + key.startsWith(STORAGE_KEYS.RESERVA_DETAIL) || + key.startsWith(STORAGE_KEYS.RESERVA_FULL), ); await AsyncStorage.multiRemove(reservaKeys); } catch (error) { @@ -176,6 +178,22 @@ export const clearReservasCache = async (): Promise => { } }; +/** Dados de sessão do utilizador — mantém contactos da agência (partilhados). */ +export const clearUserSessionCache = async (): Promise => { + try { + const keys = await AsyncStorage.getAllKeys(); + const sessionKeys = keys.filter( + (key) => + key.startsWith("@cruiseLovers:") && + key !== STORAGE_KEYS.CONTACTS && + key !== STORAGE_KEYS.SOCIALS, + ); + await AsyncStorage.multiRemove(sessionKeys); + } catch (error) { + console.error("Erro ao limpar cache da sessão:", error); + } +}; + /** * Armazena informações do perfil no cache */ diff --git a/assets/services/passwordRecovery.ts b/assets/services/passwordRecovery.ts new file mode 100644 index 0000000..d2e83bf --- /dev/null +++ b/assets/services/passwordRecovery.ts @@ -0,0 +1,50 @@ +import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api'; +import { ApiMessageResponse } from '@/assets/types'; +import CryptoJS from 'crypto-js'; + +async function postToEndpoint( + endpoint: string, + fields: Record, +): Promise { + const formData = new FormData(); + Object.entries(fields).forEach(([key, value]) => formData.append(key, value)); + + const response = await fetch(buildApiUrl(endpoint), { + method: 'POST', + headers: { Accept: 'application/json' }, + body: formData, + }); + + return response.json(); +} + +export async function recoverPassword(email: string): Promise { + return postToEndpoint(API_ENDPOINTS.RECOVER_PASSWORD, { email: email.trim() }); +} + +export async function confirmRecoveryToken( + email: string, + token: string, +): Promise { + return postToEndpoint(API_ENDPOINTS.CONFIRM_TOKEN, { + email: email.trim(), + token: token.trim(), + }); +} + +export async function resetPassword( + email: string, + token: string, + password: string, + confirmPassword: string, +): Promise { + const encryptedPassword = CryptoJS.MD5(password).toString(); + const encryptedConfirm = CryptoJS.MD5(confirmPassword).toString(); + + return postToEndpoint(API_ENDPOINTS.RESET_PASSWORD, { + email: email.trim(), + token: token.trim(), + password: encryptedPassword, + confirmPassword: encryptedConfirm, + }); +} diff --git a/assets/types/index.tsx b/assets/types/index.tsx index 7b037e5..1a1af71 100644 --- a/assets/types/index.tsx +++ b/assets/types/index.tsx @@ -34,6 +34,11 @@ export interface LoginResponse { [key: string]: any; } +export interface ApiMessageResponse { + status: number; + message?: string; +} + export interface UserData { email: string; nome: string; diff --git a/styles/screens/auth/recover.styles.ts b/styles/screens/auth/recover.styles.ts index b12402e..f7ac8ce 100644 --- a/styles/screens/auth/recover.styles.ts +++ b/styles/screens/auth/recover.styles.ts @@ -166,4 +166,25 @@ export default StyleSheet.create({ fontFamily: fonts.medium, fontSize: 16, }, + passwordWrapper: { + height: 52, + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 12, + paddingHorizontal: 14, + marginBottom: 16, + flexDirection: 'row', + alignItems: 'center', + }, + passwordInput: { + flex: 1, + fontFamily: fonts.regular, + color: '#1F2937', + }, + eyeIcon: { + color: '#9098A3', + }, + actionButtonDisabled: { + opacity: 0.6, + }, }); \ No newline at end of file