refactor: organize frontend by feature
This commit is contained in:
74
frontend/src/features/auth/composables/useFacebookLogin.js
Normal file
74
frontend/src/features/auth/composables/useFacebookLogin.js
Normal 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,
|
||||
};
|
||||
}
|
||||
300
frontend/src/features/auth/stores/authStore.js
Normal file
300
frontend/src/features/auth/stores/authStore.js
Normal 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,
|
||||
};
|
||||
});
|
||||
218
frontend/src/features/auth/views/ForgotPasswordView.vue
Normal file
218
frontend/src/features/auth/views/ForgotPasswordView.vue
Normal 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>
|
||||
214
frontend/src/features/auth/views/LoginView.vue
Normal file
214
frontend/src/features/auth/views/LoginView.vue
Normal 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>
|
||||
237
frontend/src/features/auth/views/RegisterView.vue
Normal file
237
frontend/src/features/auth/views/RegisterView.vue
Normal 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>
|
||||
264
frontend/src/features/auth/views/ResetPasswordView.vue
Normal file
264
frontend/src/features/auth/views/ResetPasswordView.vue
Normal 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>
|
||||
237
frontend/src/features/auth/views/VerifyEmailView.vue
Normal file
237
frontend/src/features/auth/views/VerifyEmailView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user