refactor: organize frontend by feature
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 01:05:50 -04:00
parent b6eb692c27
commit 121757546a
60 changed files with 107 additions and 183 deletions

View File

@@ -0,0 +1,74 @@
import {onMounted, ref} from "vue";
import {useHead} from "@vueuse/head";
import {useAuthStore} from "@/features/auth/stores/authStore.js";
import config from "@/config.js";
export function useFacebookLogin() {
const isSdkLoaded = ref(false);
useHead({
script: [
{
src: "https://connect.facebook.net/en_US/sdk.js",
async: true,
defer: true,
onload: "initializeFacebookSDK()",
},
],
});
(function (d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) {
return;
}
js = d.createElement(s);
js.id = id;
js.src = "https://connect.facebook.net/en_US/sdk.js";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
const initializeFacebookSDK = () => {
window.fbAsyncInit = function () {
FB.init({
appId: config.facebookAppId,
xfbml: true,
version: 'v22.0'
});
FB.AppEvents.logPageView();
isSdkLoaded.value = true;
};
};
const loginWithFacebook = () => {
if (!isSdkLoaded.value) {
console.error("Facebook SDK non encore chargé !");
return;
}
window.FB.login(
(response) => {
if (response.authResponse) {
console.log("Utilisateur connecté :", response);
const authStore = useAuthStore();
authStore.loginWithFacebook(response.authResponse);
} else {
console.log("Connexion annulée ou échouée.");
}
},
{
scope: "public_profile,email"
}
);
};
onMounted(() => {
initializeFacebookSDK();
});
return {
loginWithFacebook,
};
}

View File

@@ -0,0 +1,300 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js';
import { useSessionStorage } from '@vueuse/core';
import { jwtDecode } from 'jwt-decode';
import { formatDuration } from '@/internal_time_ago.js';
export const useAuthStore = defineStore('auth', () => {
const clientApi = useClient();
const router = useRouter();
const isRefreshing = ref(false);
let refreshPromise = null;
const accessToken = useSessionStorage('auth-accessToken', undefined);
const refreshToken = useSessionStorage('auth-refreshToken', undefined);
const tokenClaims = useSessionStorage('auth-tokenClaims', null, {
serializer: {
read: v => (v ? JSON.parse(v) : null),
write: v => (v ? JSON.stringify(v) : null),
},
});
const isAuthenticated = computed(() => !!accessToken.value);
const userId = computed(() => tokenClaims.value?.sub);
const userRoles = computed(() => {
const claims = tokenClaims.value ?? {};
const candidates = [
claims.role,
claims.roles,
claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'],
].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
return [...new Set(candidates)];
});
const persona = computed(() => tokenClaims.value?.persona ?? null);
const isManager = computed(() => userRoles.value.includes('Administrator') || userRoles.value.includes('Manager'));
const isClient = computed(() => userRoles.value.includes('Client'));
const isProvider = computed(() => userRoles.value.includes('Provider'));
function updateTokens(data) {
if (!data?.accessToken || !data?.refreshToken) {
throw new Error('Invalid token data');
}
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
const claims = getClaimsFromToken(data.accessToken);
tokenClaims.value = claims;
console.log('Tokens updated, user ID:', claims?.sub);
}
function cleanTokens() {
console.log('cleanTokens called - clearing stored tokens');
accessToken.value = undefined;
refreshToken.value = undefined;
tokenClaims.value = null;
}
async function logout() {
cleanTokens();
await router.push('/');
}
async function login(email, password) {
console.log('login called with email:', email);
if (!email || !password) {
throw new Error('Email and password are required');
}
try {
const response = await clientApi.post('api/users/login', {
email: email.trim(),
password: password,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid login response');
}
updateTokens(response.data);
console.log('login successful');
return true;
} catch (error) {
console.error('Login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithGoogle(accessTokenParam) {
console.log('loginWithGoogle called');
if (!accessTokenParam) {
throw new Error('Google access token is required');
}
try {
const response = await clientApi.post('api/users/login-with-google', {
token: accessTokenParam,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Google login response');
}
updateTokens(response.data);
console.log('Google login successful');
return true;
} catch (error) {
console.error('Google login failed:', error);
cleanTokens();
throw error;
}
}
async function loginWithFacebook(authResponse) {
console.log('loginWithFacebook called');
if (!authResponse?.accessToken) {
throw new Error('Facebook access token is required');
}
try {
const response = await clientApi.post('api/users/login-with-facebook', {
token: authResponse.accessToken,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid Facebook login response');
}
updateTokens(response.data);
console.log('Facebook login successful');
return true;
} catch (error) {
console.error('Facebook login failed:', error);
cleanTokens();
throw error;
}
}
async function refresh() {
console.log('refresh called');
if (!refreshToken.value) {
cleanTokens(); // Clear tokens first
throw new Error('No refresh token available');
}
if (isRefreshing.value && refreshPromise) {
console.log('Already refreshing, returning existing refreshPromise');
return refreshPromise;
}
try {
isRefreshing.value = true;
refreshPromise = (async () => {
try {
console.log('Sending refresh request...');
const response = await clientApi.post('api/users/refresh', {
refreshToken: refreshToken.value,
});
if (!response.data?.accessToken || !response.data?.refreshToken) {
throw new Error('Invalid refresh response');
}
updateTokens({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});
console.log('Token refresh successful');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
cleanTokens();
const currentRoute = router.currentRoute.value;
const returnUrl = currentRoute.fullPath;
// Handle navigation
router
.push({
name: 'login',
query: { returnUrl },
})
.catch(navError => {
console.error('Navigation error after token refresh failure:', navError);
});
throw error; // Re-throw to notify callers
}
})();
return await refreshPromise;
} catch (error) {
throw error;
} finally {
// Ensure these are always reset, even if an error is thrown
isRefreshing.value = false;
refreshPromise = null;
}
}
function getClaimsFromToken(token) {
if (!token) return null;
try {
return jwtDecode(token);
} catch (error) {
console.error('Failed to decode token:', error);
return null;
}
}
function isTokenExpiringSoon(token) {
if (!token) {
console.log('No token provided, considered expiring soon');
return true;
}
const claims = getClaimsFromToken(token);
if (!claims || !claims.exp) {
console.log('No valid claims found, considered expiring soon');
return true;
}
const expirationTime = claims.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const fiveMinutesInMs = 2 * 60 * 1000; // 2 minutes for demonstration
// Calculate time remaining (can be negative if already expired)
const timeRemainingMs = expirationTime - currentTime;
// Token is expiring soon if less than 2 minutes remaining or already expired
const isExpiring = timeRemainingMs < fiveMinutesInMs;
// Determine the sign for display purposes
const formattedTimeRemaining =
timeRemainingMs < 0 ? `-${formatDuration(Math.abs(timeRemainingMs))}` : formatDuration(timeRemainingMs);
if (isExpiring) {
console.log(`Token expiration check; is token expired: ${isExpiring}`, {
expirationTime: new Date(expirationTime).toLocaleString(),
currentTime: new Date(currentTime).toLocaleString(),
timeRemaining: formattedTimeRemaining,
});
}
return isExpiring;
}
async function changePassword(newPassword) {
console.log('changePassword called');
if (!isAuthenticated.value) {
throw new Error('User must be authenticated to change password');
}
if (!newPassword) {
throw new Error('New password is required');
}
try {
const response = await clientApi.post('api/users/set-password', {
newPassword,
});
console.log('Password changed successfully');
return true;
} catch (error) {
console.error('Password change failed:', error);
throw error;
}
}
function hasAnyRole(roles) {
return roles.some(role => userRoles.value.includes(role));
}
return {
accessToken,
refreshToken,
isAuthenticated,
userId,
userRoles,
persona,
hasAnyRole,
isManager,
isClient,
isProvider,
isRefreshing,
login,
loginWithGoogle,
loginWithFacebook,
logout,
refresh,
isTokenExpiringSoon,
changePassword,
};
});

View File

@@ -0,0 +1,218 @@
<template>
<div class="flex min-h-full justify-center items-center w-full p-4">
<div class="flex flex-col gap-10 w-full max-w-[512px]">
<h1 class="text-2xl font-bold text-center">
{{ t('title') }}
</h1>
<p class="text-center text-hOnSurface">
{{ t('description') }}
</p>
<div class="card">
<form @submit.prevent="handleForgotPassword">
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label
class="form-label"
for="email"
>
{{ t('email') }}
</label>
<input
id="email"
v-model="email"
class="form-input"
required
type="email"
/>
</div>
<button
:disabled="isLoading"
class="primary w-full"
type="submit"
>
<span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
<div class="text-center mt-4">
<router-link
class="text-sm text-blue-500"
to="/login"
>
{{ t('backToLogin') }}
</router-link>
</div>
</div>
</div>
</form>
</div>
<!-- Success message -->
<div
v-if="showSuccessMessage"
class="notification success"
>
{{ t('resetEmailSent') }}
</div>
<!-- Error message -->
<div
v-if="showErrorMessage"
class="notification error"
>
{{ errorMessage }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js';
const { t } = useI18n();
const router = useRouter();
const clientApi = useClient();
const email = ref('');
const isLoading = ref(false);
const showSuccessMessage = ref(false);
const showErrorMessage = ref(false);
const errorMessage = ref('');
async function handleForgotPassword() {
// Reset notification states
showSuccessMessage.value = false;
showErrorMessage.value = false;
if (!email.value) {
errorMessage.value = t('emailRequired');
showErrorMessage.value = true;
return;
}
isLoading.value = true;
try {
// Call password reset API
await clientApi.post('api/users/forgot-password', {
email: email.value.trim(),
});
// Show success message
showSuccessMessage.value = true;
// Clear the form
email.value = '';
// Redirect to login after a short delay
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (error) {
console.error('Password reset request failed:', error);
errorMessage.value = error.response?.data?.message || t('resetRequestFailed');
showErrorMessage.value = true;
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.card-content {
@apply p-6;
}
.form-field {
@apply flex flex-col mb-4;
}
.form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
}
.form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg
focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5
dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5
focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
}
.notification {
@apply fixed bottom-4 right-4 p-4 mb-4 rounded-lg text-sm;
animation:
fade-in 0.3s ease-in,
fade-out 0.3s ease-out 5s forwards;
}
.success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
}
.error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
.loading-spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>
<i18n>
{
"en": {
"title": "Forgot Password?",
"description": "Please enter your account email address. A password reset link will be sent to you.",
"email": "Email",
"resetPassword": "Reset Password",
"backToLogin": "Back to Login",
"resetEmailSent": "Password reset email sent. Please check your inbox.",
"resetRequestFailed": "Failed to request password reset. Please try again.",
"emailRequired": "Email is required."
},
"fr": {
"title": "Mot de passe oublié ?",
"description": "Veuillez saisir l'adresse e-mail de votre compte. Un lien de réinitialisation vous sera envoyé.",
"email": "Email",
"resetPassword": "Réinitialiser le mot de passe",
"backToLogin": "Retour à la connexion",
"resetEmailSent": "Email de réinitialisation du mot de passe envoyé. Veuillez vérifier votre boîte de réception.",
"resetRequestFailed": "Échec de la demande de réinitialisation du mot de passe. Veuillez réessayer.",
"emailRequired": "L'email est requis."
}
}
</i18n>

View File

@@ -0,0 +1,214 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex flex-col gap-4">
<google-login
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary">
<v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
</button>
</google-login>
</div>
<div class="my-4 flex items-center">
<div class="h-px grow bg-gray-200"></div>
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span>
<div class="h-px grow bg-gray-200"></div>
</div>
<!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin">
<div class="flex flex-col gap-4">
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<v-text-field
v-model="password"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-btn
block
color="primary"
type="submit"
>
{{ t('signIn') }}
</v-btn>
<div class="text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="forgotPassword"
>
{{ t('forgotPassword') }}
</a>
</div>
<div class="mt-2 text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="resendVerification"
>
{{ t('resendVerification') }}
</a>
</div>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link
class="text-blue-500"
to="/register"
>
{{ t('register') }}
</router-link>
</div>
</div>
</v-form>
</div>
<!-- Error notification -->
<v-snackbar
v-model="errorSnackBar"
color="error"
>
{{ t('loginFailed') }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { GoogleLogin } from 'vue3-google-login';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiEye, mdiEyeOff, mdiGoogle } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const email = ref('');
const password = ref('');
const errorSnackBar = ref(false);
const showPassword = ref(false);
const props = defineProps({
returnUrl: {
type: String,
default: '/landing',
},
});
async function handleLocalLogin() {
try {
await authStore.login(email.value, password.value);
await router.push(props.returnUrl);
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
async function googleCallback(token) {
try {
const response = await authStore.loginWithGoogle(JSON.stringify(token));
if (response === true) {
await router.push(props.returnUrl);
} else {
errorSnackBar.value = true;
}
} catch (error) {
console.error('Login failed:', error);
errorSnackBar.value = true;
}
}
function forgotPassword() {
router.push('/forgot-password');
}
function resendVerification() {
router.push('/verify-email');
}
</script>
<style scoped>
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
/* Dark mode support if needed */
@media (prefers-color-scheme: dark) {
.custom-divider {
background-color: rgb(75, 85, 99);
/* Equivalent to gray-600 */
}
}
</style>
<i18n>
{
"en": {
"title": "Sign in",
"alt": "Login",
"email": "Email",
"password": "Password",
"signIn": "Connect",
"forgotPassword": "Forgot password?",
"resendVerification": "Resend verification email",
"orContinueWith": "Or",
"noAccount": "Don't have an account?",
"register": "Register",
"loginFailed": "Login failed. Please check your credentials.",
"continueWithGoogle": "Continue with Google"
},
"fr": {
"title": "Se connecter",
"alt": "Connexion",
"email": "Email",
"password": "Mot de passe",
"signIn": "Connexion",
"forgotPassword": "Mot de passe oublié?",
"resendVerification": "Renvoyer l'email de vérification",
"orContinueWith": "Ou",
"noAccount": "Vous n'avez pas de compte?",
"register": "S'inscrire",
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
"continueWithGoogle": "Continuer avec Google"
}
}
</i18n>

View File

@@ -0,0 +1,237 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-20">
<!-- Show verification message on success -->
<div
v-if="registrationSuccess"
class="card justify-items-center"
>
<img
:alt="t('alt')"
src="/images/hutopymedia/loginpage/hutopylogin.svg"
/>
<div class="flex flex-col gap-10 text-center">
<h1 class="login-text text-2xl font-bold text-green-600">
{{ t('success.title') }}
</h1>
<div class="text-hOnSurface">
<p>{{ t('success.message') }}</p>
<p class="mt-2 font-medium">{{ userEmail }}</p>
</div>
<div class="mt-4 flex flex-col gap-2">
<router-link
class="text-blue-500 hover:underline"
to="/login"
>
{{ t('success.backToLogin') }}
</router-link>
<router-link
:to="{ path: '/verify-email', query: { email: userEmail } }"
class="text-blue-500 hover:underline"
>
{{ t('success.resendVerification') }}
</router-link>
</div>
</div>
</div>
<!-- Show registration form -->
<div
v-else
class="card justify-items-center"
>
<img
:alt="t('alt')"
src="/images/hutopymedia/loginpage/hutopylogin.svg"
/>
<div class="flex flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<v-form @submit.prevent="handleRegister">
<div class="flex flex-col gap-4">
<v-text-field
v-model="name"
:label="t('name')"
required
></v-text-field>
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<v-text-field
v-model="password"
:hint="t('passwordRequirements')"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-text-field
v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template>
</v-text-field>
<v-btn
:loading="isLoading"
block
color="primary"
type="submit"
>
{{ t('register') }}
</v-btn>
<!-- Error message displayed as block text below submit button -->
<div
v-if="errorMessage"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ errorMessage }}
</div>
<div class="mt-4 text-center">
{{ t('alreadyHaveAccount') }}
<router-link
class="text-blue-500"
to="/login"
>
{{ t('signIn') }}
</router-link>
</div>
</div>
</v-form>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const clientApi = useClient();
const name = ref('');
const email = ref('');
const password = ref('');
const confirmPassword = ref('');
const isLoading = ref(false);
const errorMessage = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const registrationSuccess = ref(false);
const userEmail = ref('');
async function handleRegister() {
if (password.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
isLoading.value = true;
errorMessage.value = '';
try {
await clientApi.post('api/users/register', {
name: name.value,
email: email.value.trim(),
password: password.value,
});
// On success, show verification message
userEmail.value = email.value.trim();
registrationSuccess.value = true;
} catch (error) {
console.error('Registration failed:', error);
errorMessage.value = error.response?.data?.message || t('registrationFailed');
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
</style>
<i18n>
{
"en": {
"title": "Create your account",
"alt": "Hutopy Registration",
"name": "Full Name",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"passwordRequirements": "Password must be at least 8 characters",
"register": "Register",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in",
"passwordsDoNotMatch": "Passwords do not match",
"registrationFailed": "Registration failed. Please try again.",
"success": {
"title": "Registration Successful!",
"message": "Please check your email to verify your account. We've sent a verification link to:",
"backToLogin": "Back to Login",
"resendVerification": "Didn't receive the email? Resend verification"
}
},
"fr": {
"title": "Créer votre compte",
"alt": "Inscription Hutopy",
"name": "Nom complet",
"email": "Email",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"register": "S'inscrire",
"alreadyHaveAccount": "Vous avez déjà un compte?",
"signIn": "Se connecter",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"registrationFailed": "L'inscription a échoué. Veuillez réessayer.",
"success": {
"title": "Inscription réussie!",
"message": "Veuillez vérifier votre email pour activer votre compte. Nous avons envoyé un lien de vérification à:",
"backToLogin": "Retour à la connexion",
"resendVerification": "Vous n'avez pas reçu l'email? Renvoyer la vérification"
}
}
}
</i18n>

View File

@@ -0,0 +1,264 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<form
class="card"
@submit.prevent="handleResetPassword"
>
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label
class="form-label"
for="password"
>
{{ t('newPassword') }}
</label>
<div class="relative">
<input
id="password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="form-input"
required
/>
<button
class="password-toggle"
type="button"
@click="showPassword = !showPassword"
>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
size="small"
/>
</button>
</div>
<p class="mt-1 text-sm text-gray-500">{{ t('passwordRequirements') }}</p>
</div>
<div class="form-field">
<label
class="form-label"
for="confirmPassword"
>
{{ t('confirmPassword') }}
</label>
<div class="relative">
<input
id="confirmPassword"
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
class="form-input"
required
/>
<button
class="password-toggle"
type="button"
@click="showConfirmPassword = !showConfirmPassword"
>
<v-icon
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
size="small"
/>
</button>
</div>
</div>
<div
v-if="errorMessage"
class="error-message"
>
{{ errorMessage }}
</div>
<button
:disabled="isLoading"
class="primary w-full"
type="submit"
>
<span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
</div>
</div>
</form>
<!-- Success message -->
<div
v-if="success"
class="success-message"
>
{{ t('passwordResetSuccess') }}
<div class="mt-4">
<router-link
class="text-blue-500"
to="/login"
>
{{ t('proceedToLogin') }}
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useClient } from '@/plugins/api.js';
import { mdiEye, mdiEyeOff } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
const email = ref('');
const token = ref('');
const password = ref('');
const confirmPassword = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const isLoading = ref(false);
const errorMessage = ref('');
const success = ref(false);
onMounted(() => {
// Get email and token from URL query parameters
email.value = route.query.email || '';
token.value = route.query.token || '';
// Validate that we have both email and token
if (!email.value || !token.value) {
errorMessage.value = t('invalidResetLink');
}
});
async function handleResetPassword() {
// Reset error message
errorMessage.value = '';
// Validate passwords match
if (password.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
// Validate password length
if (password.value.length < 8) {
errorMessage.value = t('passwordTooShort');
return;
}
// Validate that we have email and token
if (!email.value || !token.value) {
errorMessage.value = t('invalidResetLink');
return;
}
isLoading.value = true;
try {
// Call password reset API
await clientApi.post('api/users/reset-password', {
email: email.value,
token: token.value,
newPassword: password.value,
});
// Show success message
success.value = true;
// Clear form fields
password.value = '';
confirmPassword.value = '';
// Redirect to login after a delay
setTimeout(() => {
router.push('/login');
}, 5000);
} catch (error) {
console.error('Password reset failed:', error);
errorMessage.value = error.response?.data || t('resetFailed');
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
form {
@apply bg-hSurface rounded-xl p-4;
}
.form-field {
@apply flex flex-col mb-4;
}
.form-label {
@apply block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300;
}
.form-input {
@apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white;
}
.primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg text-sm px-5 py-2.5 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-50 disabled:cursor-not-allowed;
}
.error-message {
@apply p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-100 dark:bg-red-900 dark:text-red-300;
}
.success-message {
@apply p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-100 dark:bg-green-900 dark:text-green-300 text-center;
}
.password-toggle {
@apply absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300;
}
.loading-spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite];
}
</style>
<i18n>
{
"en": {
"title": "Reset Your Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordRequirements": "Password must be at least 8 characters",
"resetPassword": "Reset Password",
"passwordResetSuccess": "Your password has been reset successfully!",
"proceedToLogin": "Proceed to Login",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long",
"resetFailed": "Password reset failed. Please try again or request a new reset link.",
"invalidResetLink": "Invalid or expired reset link. Please request a new password reset."
},
"fr": {
"title": "Réinitialiser Votre Mot de Passe",
"newPassword": "Nouveau Mot de Passe",
"confirmPassword": "Confirmer le Mot de Passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"resetPassword": "Réinitialiser le Mot de Passe",
"passwordResetSuccess": "Votre mot de passe a été réinitialisé avec succès!",
"proceedToLogin": "Procéder à la Connexion",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"resetFailed": "Échec de la réinitialisation du mot de passe. Veuillez réessayer ou demander un nouveau lien de réinitialisation.",
"invalidResetLink": "Lien de réinitialisation invalide ou expiré. Veuillez demander une nouvelle réinitialisation de mot de passe."
}
}
</i18n>

View File

@@ -0,0 +1,237 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10 text-center">
<!-- Loading state while verification is in progress -->
<div
v-if="isLoading"
class="flex flex-col items-center gap-4"
>
<v-progress-circular
color="primary"
indeterminate
size="64"
></v-progress-circular>
<h2 class="text-xl font-medium">{{ t('verifying') }}</h2>
</div>
<!-- Success state -->
<div
v-else-if="verificationSuccess"
class="flex flex-col items-center gap-6"
>
<v-icon
color="green"
icon="mdi-check-circle"
size="64"
></v-icon>
<h1 class="text-2xl font-bold text-green-600">{{ t('success.title') }}</h1>
<p>{{ t('success.message') }}</p>
<v-btn
color="primary"
@click="goToLogin"
>
{{ t('success.goToLogin') }}
</v-btn>
</div>
<!-- Error state -->
<div
v-else
class="flex flex-col items-center gap-6"
>
<v-icon
color="error"
icon="mdi-alert-circle"
size="64"
></v-icon>
<h1 class="text-2xl font-bold text-red-600">{{ t('error.title') }}</h1>
<p>{{ errorMessage || t('error.defaultMessage') }}</p>
<div class="mt-4 flex flex-col gap-4 w-full">
<v-btn
color="primary"
@click="goToLogin"
>
{{ t('error.goToLogin') }}
</v-btn>
<v-divider class="my-4"></v-divider>
<!-- Resend verification email section -->
<h2 class="text-xl font-medium">{{ t('resend.title') }}</h2>
<v-form
class="w-full"
@submit.prevent="handleResendVerification"
>
<div class="flex flex-col gap-4">
<v-text-field
v-model="resendEmail"
:error-messages="resendEmailError"
:label="t('resend.emailLabel')"
required
type="email"
></v-text-field>
<v-btn
:loading="resendLoading"
block
color="secondary"
type="submit"
>
{{ t('resend.button') }}
</v-btn>
<!-- Resend success message -->
<div
v-if="resendSuccess"
class="mt-2 p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm"
>
{{ t('resend.success') }}
</div>
<!-- Resend error message -->
<div
v-if="resendError"
class="mt-2 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm"
>
{{ resendError }}
</div>
</div>
</v-form>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useClient } from '@/plugins/api.js';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const clientApi = useClient();
// Verification state
const isLoading = ref(true);
const verificationSuccess = ref(false);
const errorMessage = ref('');
// Resend verification state
const resendEmail = ref('');
const resendEmailError = ref('');
const resendLoading = ref(false);
const resendSuccess = ref(false);
const resendError = ref('');
onMounted(async () => {
const userId = route.query.userId;
const token = route.query.token;
// Populate resend email field if it was in the URL
if (route.query.email) {
resendEmail.value = route.query.email;
}
// Check if we have the required parameters
if (!userId || !token) {
isLoading.value = false;
errorMessage.value = t('error.missingParams');
return;
}
try {
// Call the verification endpoint
await clientApi.get(`/api/users/verify-email?userId=${userId}&token=${token}`);
verificationSuccess.value = true;
} catch (error) {
console.error('Email verification failed:', error);
errorMessage.value = error.response?.data?.message || t('error.defaultMessage');
} finally {
isLoading.value = false;
}
});
async function handleResendVerification() {
// Reset states
resendEmailError.value = '';
resendSuccess.value = false;
resendError.value = '';
// Simple email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(resendEmail.value)) {
resendEmailError.value = t('resend.invalidEmail');
return;
}
resendLoading.value = true;
try {
await clientApi.post('/api/users/resend-verification', {
email: resendEmail.value.trim(),
});
resendSuccess.value = true;
} catch (error) {
console.error('Resend verification failed:', error);
resendError.value = error.response?.data?.message || t('resend.error');
} finally {
resendLoading.value = false;
}
}
function goToLogin() {
router.push('/login');
}
</script>
<i18n>
{
"en": {
"verifying": "Verifying your email...",
"success": {
"title": "Email Verified Successfully!",
"message": "Your email has been verified. You can now log in to your account.",
"goToLogin": "Go to Login"
},
"error": {
"title": "Verification Failed",
"defaultMessage": "We couldn't verify your email. The link may be invalid or expired.",
"missingParams": "Missing required verification parameters.",
"goToLogin": "Go to Login"
},
"resend": {
"title": "Resend Verification Email",
"emailLabel": "Email",
"button": "Resend Verification Email",
"success": "Verification email sent successfully. Please check your inbox.",
"error": "Failed to send verification email. Please try again.",
"invalidEmail": "Please enter a valid email address."
}
},
"fr": {
"verifying": "Vérification de votre email...",
"success": {
"title": "Email vérifié avec succès !",
"message": "Votre email a été vérifié. Vous pouvez maintenant vous connecter à votre compte.",
"goToLogin": "Aller à la connexion"
},
"error": {
"title": "Échec de la vérification",
"defaultMessage": "Nous n'avons pas pu vérifier votre email. Le lien peut être invalide ou expiré.",
"missingParams": "Paramètres de vérification requis manquants.",
"goToLogin": "Aller à la connexion"
},
"resend": {
"title": "Renvoyer l'email de vérification",
"emailLabel": "Email",
"button": "Renvoyer l'email de vérification",
"success": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
"error": "Échec de l'envoi de l'email de vérification. Veuillez réessayer.",
"invalidEmail": "Veuillez entrer une adresse email valide."
}
}
}
</i18n>