refactor(auth): cleanup auth module and streamline the registration flow
This commit is contained in:
@@ -3,7 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import CreatorHome from '@/views/creators/CreatorHome.vue';
|
||||
import CreatorLayout from '@/views/creators/CreatorLayout.vue';
|
||||
const LoginView = () => import('@/views/LoginView.vue');
|
||||
|
||||
const LoginView = () => import('@/views/auth/LoginView.vue');
|
||||
|
||||
const About = () => import('@/views/documentation/About.vue');
|
||||
const ContentPolicy = () => import('@/views/documentation/ContentPolicy.vue');
|
||||
@@ -14,151 +15,158 @@ const HelpAndContact = () => import('@/views/documentation/HelpAndContact.vue');
|
||||
const Pricing = () => import('@/views/documentation/Pricing.vue');
|
||||
const TermsAndConditions = () => import('@/views/documentation/TermsAndConditions.vue');
|
||||
const ProfilePage = () => import('@/views/profile/ProfilePage.vue');
|
||||
const PaymentCompleted = () => import('@/views/PaymentCompleted.vue');
|
||||
const PaymentFailed = () => import('@/views/PaymentFailed.vue');
|
||||
const PaymentCompleted = () => import('@/views/creators/PaymentCompleted.vue');
|
||||
const PaymentFailed = () => import('@/views/creators/PaymentFailed.vue');
|
||||
const Landing = () => import('@/views/main/Landing.vue');
|
||||
|
||||
const CreateCreator = () => import('@/views/creators/CreateCreator.vue');
|
||||
const RegisterView = () => import('@/views/RegisterView.vue');
|
||||
const ForgotPasswordView = () => import('@/views/ForgotPasswordView.vue');
|
||||
const ResetPasswordView = () => import('@/views/ResetPasswordView.vue');
|
||||
const RegisterView = () => import('@/views/auth/RegisterView.vue');
|
||||
const ForgotPasswordView = () => import('@/views/auth/ForgotPasswordView.vue');
|
||||
const ResetPasswordView = () => import('@/views/auth/ResetPasswordView.vue');
|
||||
const VerifyEmailView = () => import('@/views/auth/VerifyEmailView.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/landing',
|
||||
name: 'landing',
|
||||
component: Landing,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'landing' },
|
||||
},
|
||||
{
|
||||
path: '/@:creator',
|
||||
component: CreatorLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'creator',
|
||||
component: CreatorHome,
|
||||
},
|
||||
{
|
||||
path: 'tip-completed',
|
||||
name: 'PaymentCompleted',
|
||||
component: PaymentCompleted,
|
||||
},
|
||||
{
|
||||
path: 'tip-cancelled',
|
||||
name: 'PaymentFailed',
|
||||
component: PaymentFailed,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
component: DocumentationLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'helpandcontact',
|
||||
name: 'helpandcontact',
|
||||
component: HelpAndContact,
|
||||
},
|
||||
{
|
||||
path: 'termsandconditions',
|
||||
name: 'termsandconditions',
|
||||
component: TermsAndConditions,
|
||||
},
|
||||
{
|
||||
path: 'contentpolicy',
|
||||
name: 'contentpolicy',
|
||||
component: ContentPolicy,
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
name: 'FAQ',
|
||||
component: FAQ,
|
||||
},
|
||||
{
|
||||
path: 'guideforcreators',
|
||||
name: 'guideforcreators',
|
||||
component: CreatorGuide,
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'about',
|
||||
component: About,
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'pricing',
|
||||
component: Pricing,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: { notAuthenticated: true },
|
||||
props: (route) => ({ returnUrl: route.query.returnUrl || '/landing' })
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: ProfilePage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/create-creator',
|
||||
name: 'create-creator',
|
||||
component: CreateCreator,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: RegisterView,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPasswordView,
|
||||
meta: { notAuthenticated: true }
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: ResetPasswordView,
|
||||
meta: { notAuthenticated: true },
|
||||
props: (route) => ({ email: route.query.email, token: route.query.token })
|
||||
}
|
||||
{
|
||||
path: '/landing',
|
||||
name: 'landing',
|
||||
component: Landing,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'landing' },
|
||||
},
|
||||
{
|
||||
path: '/@:creator',
|
||||
component: CreatorLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'creator',
|
||||
component: CreatorHome,
|
||||
},
|
||||
{
|
||||
path: 'tip-completed',
|
||||
name: 'PaymentCompleted',
|
||||
component: PaymentCompleted,
|
||||
},
|
||||
{
|
||||
path: 'tip-cancelled',
|
||||
name: 'PaymentFailed',
|
||||
component: PaymentFailed,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
component: DocumentationLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'helpandcontact',
|
||||
name: 'helpandcontact',
|
||||
component: HelpAndContact,
|
||||
},
|
||||
{
|
||||
path: 'termsandconditions',
|
||||
name: 'termsandconditions',
|
||||
component: TermsAndConditions,
|
||||
},
|
||||
{
|
||||
path: 'contentpolicy',
|
||||
name: 'contentpolicy',
|
||||
component: ContentPolicy,
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
name: 'FAQ',
|
||||
component: FAQ,
|
||||
},
|
||||
{
|
||||
path: 'guideforcreators',
|
||||
name: 'guideforcreators',
|
||||
component: CreatorGuide,
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
name: 'about',
|
||||
component: About,
|
||||
},
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'pricing',
|
||||
component: Pricing,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: { notAuthenticated: true },
|
||||
props: route => ({ returnUrl: route.query.returnUrl || '/landing' }),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: ProfilePage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/create-creator',
|
||||
name: 'create-creator',
|
||||
component: CreateCreator,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: RegisterView,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPasswordView,
|
||||
meta: { notAuthenticated: true },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: ResetPasswordView,
|
||||
meta: { notAuthenticated: true },
|
||||
props: route => ({ email: route.query.email, token: route.query.token }),
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
name: 'verify-email',
|
||||
component: VerifyEmailView,
|
||||
meta: { notAuthenticated: true },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
// Navigation guards
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
next({
|
||||
name: 'login',
|
||||
query: { returnUrl: to.fullPath }
|
||||
});
|
||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
next({
|
||||
name: 'login',
|
||||
query: { returnUrl: to.fullPath },
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else if (to.matched.some(record => record.meta.notAuthenticated)) {
|
||||
if (authStore.isAuthenticated) next({ name: 'landing' });
|
||||
else next();
|
||||
} else {
|
||||
next();
|
||||
next();
|
||||
}
|
||||
} else if (to.matched.some((record) => record.meta.notAuthenticated)) {
|
||||
if (authStore.isAuthenticated) next({ name: 'landing' });
|
||||
else next();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,280 +4,273 @@ 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";
|
||||
import { formatDuration } from '@/internal_time_ago.js';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const clientApi = useClient();
|
||||
const router = useRouter();
|
||||
const clientApi = useClient();
|
||||
const router = useRouter();
|
||||
|
||||
const isRefreshing = ref(false);
|
||||
let refreshPromise = null;
|
||||
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 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 isAuthenticated = computed(() => !!accessToken.value);
|
||||
const userId = computed(() => tokenClaims.value?.sub);
|
||||
|
||||
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(redirectTo = '/landing') {
|
||||
console.log('logout called, redirecting to:', redirectTo);
|
||||
try {
|
||||
// Optionally call logout endpoint if you have one
|
||||
// await clientApi.post('api/users/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
cleanTokens();
|
||||
await router.push(redirectTo);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
function updateTokens(data) {
|
||||
if (!data?.accessToken || !data?.refreshToken) {
|
||||
throw new Error('Invalid token data');
|
||||
}
|
||||
})();
|
||||
|
||||
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;
|
||||
accessToken.value = data.accessToken;
|
||||
refreshToken.value = data.refreshToken;
|
||||
const claims = getClaimsFromToken(data.accessToken);
|
||||
tokenClaims.value = claims;
|
||||
console.log('Tokens updated, user ID:', claims?.sub);
|
||||
}
|
||||
|
||||
const claims = getClaimsFromToken(token);
|
||||
if (!claims || !claims.exp) {
|
||||
console.log('No valid claims found, considered expiring soon');
|
||||
return true;
|
||||
function cleanTokens() {
|
||||
console.log('cleanTokens called - clearing stored tokens');
|
||||
accessToken.value = undefined;
|
||||
refreshToken.value = undefined;
|
||||
tokenClaims.value = null;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
async function logout() {
|
||||
cleanTokens();
|
||||
await router.push('/');
|
||||
}
|
||||
|
||||
return isExpiring;
|
||||
}
|
||||
async function login(email, password) {
|
||||
console.log('login called with email:', email);
|
||||
if (!email || !password) {
|
||||
throw new Error('Email and password are required');
|
||||
}
|
||||
|
||||
async function changePassword(newPassword) {
|
||||
console.log('changePassword called');
|
||||
if (!isAuthenticated.value) {
|
||||
throw new Error('User must be authenticated to change password');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
throw new Error('New password is required');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await clientApi.post('api/users/set-password', {
|
||||
newPassword
|
||||
});
|
||||
async function loginWithFacebook(authResponse) {
|
||||
console.log('loginWithFacebook called');
|
||||
if (!authResponse?.accessToken) {
|
||||
throw new Error('Facebook access token is required');
|
||||
}
|
||||
|
||||
console.log('Password changed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Password change failed:', error);
|
||||
throw error;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
userId,
|
||||
isRefreshing,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
loginWithFacebook,
|
||||
logout,
|
||||
refresh,
|
||||
isTokenExpiringSoon,
|
||||
changePassword
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
userId,
|
||||
isRefreshing,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
loginWithFacebook,
|
||||
logout,
|
||||
refresh,
|
||||
isTokenExpiringSoon,
|
||||
changePassword,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-h-full w-full items-center justify-center p-20">
|
||||
<div 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')" type="email" required></v-text-field>
|
||||
|
||||
<v-text-field v-model="password" :label="t('password')" :type="showPassword ? 'text' : 'password'" required
|
||||
:hint="t('passwordRequirements')">
|
||||
<template v-slot:append-inner>
|
||||
<v-icon @click="showPassword = !showPassword" class="visibility-toggle" size="small"
|
||||
:icon="showPassword ? mdiEyeOff : mdiEye" />
|
||||
</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 @click="showConfirmPassword = !showConfirmPassword" class="visibility-toggle" size="small"
|
||||
:icon="showConfirmPassword ? mdiEyeOff : mdiEye" />
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-btn type="submit" color="primary" block :loading="isLoading">
|
||||
{{ t('register') }}
|
||||
</v-btn>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
{{ t('alreadyHaveAccount') }}
|
||||
<router-link to="/login" class="text-blue-500">
|
||||
{{ t('signIn') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</v-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-snackbar v-model="errorSnackBar" color="error">
|
||||
{{ errorMessage }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const clientApi = useClient();
|
||||
|
||||
const name = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const isLoading = ref(false);
|
||||
const errorSnackBar = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
|
||||
async function handleRegister() {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = t('passwordsDoNotMatch');
|
||||
errorSnackBar.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Register the user
|
||||
const response = await clientApi.post('api/users/register', {
|
||||
name: name.value,
|
||||
email: email.value.trim(),
|
||||
password: password.value
|
||||
});
|
||||
|
||||
// If registration is successful, log them in
|
||||
await authStore.login(email.value, password.value);
|
||||
|
||||
// Redirect to home or welcome page
|
||||
await router.push('/landing');
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
errorMessage.value = error.response?.data?.message || t('registrationFailed');
|
||||
errorSnackBar.value = true;
|
||||
} 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."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"es": {
|
||||
"title": "Crea tu cuenta",
|
||||
"alt": "Registro de Hutopy",
|
||||
"name": "Nombre completo",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
|
||||
"register": "Registrarse",
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
"signIn": "Iniciar sesión",
|
||||
"passwordsDoNotMatch": "Las contraseñas no coinciden",
|
||||
"registrationFailed": "El registro falló. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -44,6 +44,12 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-center">
|
||||
<a @click="resendVerification" class="cursor-pointer text-sm text-blue-500">
|
||||
{{ t('resendVerification') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
{{ t('noAccount') }}
|
||||
<router-link to="/register" class="text-blue-500">
|
||||
@@ -113,6 +119,10 @@ async function googleCallback(token) {
|
||||
function forgotPassword() {
|
||||
router.push('/forgot-password');
|
||||
}
|
||||
|
||||
function resendVerification() {
|
||||
router.push('/verify-email');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -146,6 +156,7 @@ function forgotPassword() {
|
||||
"password": "Password",
|
||||
"signIn": "Connect",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"resendVerification": "Resend verification email",
|
||||
"orContinueWith": "Or",
|
||||
"noAccount": "Don't have an account?",
|
||||
"register": "Register",
|
||||
@@ -159,6 +170,7 @@ function forgotPassword() {
|
||||
"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",
|
||||
@@ -172,6 +184,7 @@ function forgotPassword() {
|
||||
"password": "Contraseña",
|
||||
"signIn": "Conéctate",
|
||||
"forgotPassword": "¿Olvidó su contraseña?",
|
||||
"resendVerification": "Reenviar correo de verificación",
|
||||
"orContinueWith": "o",
|
||||
"noAccount": "¿No tiene una cuenta?",
|
||||
"register": "Registrarse",
|
||||
257
frontend/src/views/auth/RegisterView.vue
Normal file
257
frontend/src/views/auth/RegisterView.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<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
|
||||
class="text-blue-500 hover:underline"
|
||||
:to="{ path: '/verify-email', query: { email: userEmail } }"
|
||||
>
|
||||
{{ 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"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"title": "Crea tu cuenta",
|
||||
"alt": "Registro de Hutopy",
|
||||
"name": "Nombre completo",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
|
||||
"register": "Registrarse",
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
"signIn": "Iniciar sesión",
|
||||
"passwordsDoNotMatch": "Las contraseñas no coinciden",
|
||||
"registrationFailed": "El registro falló. Por favor, inténtelo de nuevo.",
|
||||
"success": {
|
||||
"title": "¡Registro exitoso!",
|
||||
"message": "Por favor revisa tu correo electrónico para verificar tu cuenta. Hemos enviado un enlace de verificación a:",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"resendVerification": "¿No recibiste el correo? Reenviar verificación"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
219
frontend/src/views/auth/VerifyEmailView.vue
Normal file
219
frontend/src/views/auth/VerifyEmailView.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<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 indeterminate color="primary" 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 icon="mdi-check-circle" color="green" 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 icon="mdi-alert-circle" color="error" 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 @submit.prevent="handleResendVerification" class="w-full">
|
||||
<div class="flex flex-col gap-4">
|
||||
<v-text-field
|
||||
v-model="resendEmail"
|
||||
:label="t('resend.emailLabel')"
|
||||
type="email"
|
||||
required
|
||||
:error-messages="resendEmailError"
|
||||
></v-text-field>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="secondary"
|
||||
block
|
||||
:loading="resendLoading"
|
||||
>
|
||||
{{ 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 { ref, onMounted } from 'vue';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } 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."
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"verifying": "Verificando tu correo electrónico...",
|
||||
"success": {
|
||||
"title": "¡Correo electrónico verificado con éxito!",
|
||||
"message": "Tu correo electrónico ha sido verificado. Ahora puedes iniciar sesión en tu cuenta.",
|
||||
"goToLogin": "Ir al inicio de sesión"
|
||||
},
|
||||
"error": {
|
||||
"title": "Falló la verificación",
|
||||
"defaultMessage": "No pudimos verificar tu correo electrónico. El enlace puede ser inválido o estar caducado.",
|
||||
"missingParams": "Faltan parámetros de verificación requeridos.",
|
||||
"goToLogin": "Ir al inicio de sesión"
|
||||
},
|
||||
"resend": {
|
||||
"title": "Reenviar correo de verificación",
|
||||
"emailLabel": "Correo electrónico",
|
||||
"button": "Reenviar correo de verificación",
|
||||
"success": "Correo de verificación enviado con éxito. Por favor revisa tu bandeja de entrada.",
|
||||
"error": "Error al enviar el correo de verificación. Por favor, inténtelo de nuevo.",
|
||||
"invalidEmail": "Por favor, introduce una dirección de correo electrónico válida."
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,191 +1,206 @@
|
||||
<script setup>
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useAuthStore } from "@/stores/authStore.js";
|
||||
import { useCreatorProfileStore } from "@/stores/creatorProfileStore.js";
|
||||
import { useUserProfileStore } from "@/stores/userProfileStore.js";
|
||||
import { useLanguageStore } from "@/stores/languageStore.js";
|
||||
import { useRoute } from 'vue-router';
|
||||
import { mdiFileAccountOutline, mdiAccount, mdiLogin, mdiTranslateVariant, mdiLogout } from '@mdi/js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/stores/authStore.js';
|
||||
import { useCreatorProfileStore } from '@/stores/creatorProfileStore.js';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore.js';
|
||||
import { useLanguageStore } from '@/stores/languageStore.js';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { mdiAccount, mdiFileAccountOutline, mdiLogin, mdiLogout, mdiTranslateVariant } from '@mdi/js';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const languageStore = useLanguageStore();
|
||||
const route = useRoute();
|
||||
const { locale, t } = useI18n();
|
||||
const languageStore = useLanguageStore();
|
||||
const route = useRoute();
|
||||
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const authStore = useAuthStore();
|
||||
const userProfileStore = useUserProfileStore();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function toggleLanguage() {
|
||||
const languages = ['fr', 'en', 'es'];
|
||||
const currentIndex = languages.indexOf(locale.value);
|
||||
const nextIndex = (currentIndex + 1) % languages.length;
|
||||
languageStore.setLocale(languages[nextIndex]);
|
||||
}
|
||||
function toggleLanguage() {
|
||||
const languages = ['fr', 'en', 'es'];
|
||||
const currentIndex = languages.indexOf(locale.value);
|
||||
const nextIndex = (currentIndex + 1) % languages.length;
|
||||
languageStore.setLocale(languages[nextIndex]);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// Check if current route requires authentication
|
||||
const requiresAuth = route.matched.some(record => record.meta.requiresAuth);
|
||||
// If on a protected page, redirect to landing, otherwise stay on current page
|
||||
const redirectTo = requiresAuth ? '/landing' : route.fullPath;
|
||||
authStore.logout(redirectTo);
|
||||
}
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="side-container">
|
||||
|
||||
<div class="side-logo">
|
||||
<router-link to="/@hutopy">
|
||||
<img src="/images/hutopy-logo.png" alt="hutopy logo" height="50">
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="side-menu">
|
||||
|
||||
<div v-if="authStore.isAuthenticated" class="side-menu-portrait">
|
||||
<img :src="userProfileStore.portraitUrl" alt="Profile Image" referrerpolicy="no-referrer" class="rounded-full">
|
||||
<span class="profile-label">{{ userProfileStore.alias }}</span>
|
||||
</div>
|
||||
|
||||
<div class="side-menu-items">
|
||||
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link v-if="creatorProfileStore.hasCreator" :to="`/@${creatorProfileStore.creator.slug}`">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiFileAccountOutline" />
|
||||
<span class="label">{{ t('sidebar.myPage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<router-link v-else to="/create-creator">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiFileAccountOutline" />
|
||||
<span class="label">{{ t('sidebar.myPage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link to="/profile">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiAccount" />
|
||||
<span class="label">{{ t('sidebar.myProfile') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<button class="menu-item-action" @click="toggleLanguage">
|
||||
<v-icon :icon="mdiTranslateVariant" />
|
||||
<span class="label">{{ locale }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!authStore.isAuthenticated">
|
||||
<router-link to="/login">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiLogin" />
|
||||
<span class="label">{{ t('sidebar.signIn') }}</span>
|
||||
</button>
|
||||
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else>
|
||||
<button class="menu-item-action" @click="handleLogout">
|
||||
<v-icon :icon="mdiLogout" />
|
||||
<span class="label">{{ t('sidebar.signOut') }}</span>
|
||||
</button>
|
||||
<nav class="side-container">
|
||||
<div class="side-logo">
|
||||
<router-link to="/@hutopy">
|
||||
<img
|
||||
alt="hutopy logo"
|
||||
height="50"
|
||||
src="/images/hutopy-logo.png"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="side-menu">
|
||||
<div
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="side-menu-portrait"
|
||||
>
|
||||
<img
|
||||
:src="userProfileStore.portraitUrl"
|
||||
alt="Profile Image"
|
||||
class="rounded-full"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<span class="profile-label">{{ userProfileStore.alias }}</span>
|
||||
</div>
|
||||
|
||||
<div class="side-menu-items">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link
|
||||
v-if="creatorProfileStore.hasCreator"
|
||||
:to="`/@${creatorProfileStore.creator.slug}`"
|
||||
>
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiFileAccountOutline" />
|
||||
<span class="label">{{ t('sidebar.myPage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
to="/create-creator"
|
||||
>
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiFileAccountOutline" />
|
||||
<span class="label">{{ t('sidebar.myPage') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<router-link to="/profile">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiAccount" />
|
||||
<span class="label">{{ t('sidebar.myProfile') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<button
|
||||
class="menu-item-action"
|
||||
@click="toggleLanguage"
|
||||
>
|
||||
<v-icon :icon="mdiTranslateVariant" />
|
||||
<span class="label">{{ locale }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!authStore.isAuthenticated">
|
||||
<router-link to="/login">
|
||||
<button class="menu-item-action">
|
||||
<v-icon :icon="mdiLogin" />
|
||||
<span class="label">{{ t('sidebar.signIn') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else>
|
||||
<button
|
||||
class="menu-item-action"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<v-icon :icon="mdiLogout" />
|
||||
<span class="label">{{ t('sidebar.signOut') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-container {
|
||||
@apply bg-hSurface text-hOnSurface;
|
||||
@apply lg:fixed lg:max-h-screen;
|
||||
@apply flex;
|
||||
@apply lg:flex-col lg:w-64 lg:max-w-64;
|
||||
@apply h-16 lg:h-screen;
|
||||
@apply lg:border-r-2 lg:border-[#2d282d];
|
||||
}
|
||||
.side-container {
|
||||
@apply bg-hSurface text-hOnSurface;
|
||||
@apply lg:fixed lg:max-h-screen;
|
||||
@apply flex;
|
||||
@apply lg:flex-col lg:w-64 lg:max-w-64;
|
||||
@apply h-16 lg:h-screen;
|
||||
@apply lg:border-r-2 lg:border-[#2d282d];
|
||||
}
|
||||
|
||||
.side-logo {
|
||||
@apply flex flex-grow;
|
||||
@apply items-center justify-start p-4;
|
||||
@apply lg:items-start lg:justify-center lg:pt-4;
|
||||
}
|
||||
.side-logo {
|
||||
@apply flex flex-grow;
|
||||
@apply items-center justify-start p-4;
|
||||
@apply lg:items-start lg:justify-center lg:pt-4;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
@apply flex gap-4 p-6;
|
||||
@apply items-center lg:items-stretch;
|
||||
@apply flex-row-reverse lg:flex-col;
|
||||
}
|
||||
.side-menu {
|
||||
@apply flex gap-4 p-6;
|
||||
@apply items-center lg:items-stretch;
|
||||
@apply flex-row-reverse lg:flex-col;
|
||||
}
|
||||
|
||||
.side-menu-portrait {
|
||||
@apply w-10 h-10;
|
||||
@apply -ml-1;
|
||||
@apply flex items-center justify-start;
|
||||
}
|
||||
.side-menu-portrait {
|
||||
@apply w-10 h-10;
|
||||
@apply -ml-1;
|
||||
@apply flex items-center justify-start;
|
||||
}
|
||||
|
||||
.side-menu-items {
|
||||
@apply flex gap-2;
|
||||
@apply flex-row;
|
||||
@apply lg:w-full lg:flex-col;
|
||||
}
|
||||
.side-menu-items {
|
||||
@apply flex gap-2;
|
||||
@apply flex-row;
|
||||
@apply lg:w-full lg:flex-col;
|
||||
}
|
||||
|
||||
.profile-label {
|
||||
@apply ml-5;
|
||||
@apply text-lg font-sans capitalize;
|
||||
@apply font-semibold;
|
||||
@apply hidden lg:inline;
|
||||
@apply min-w-40 truncate;
|
||||
}
|
||||
.profile-label {
|
||||
@apply ml-5;
|
||||
@apply text-lg font-sans capitalize;
|
||||
@apply font-semibold;
|
||||
@apply hidden lg:inline;
|
||||
@apply min-w-40 truncate;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-nowrap;
|
||||
@apply ml-4;
|
||||
@apply hidden lg:inline;
|
||||
}
|
||||
.label {
|
||||
@apply text-nowrap;
|
||||
@apply ml-4;
|
||||
@apply hidden lg:inline;
|
||||
}
|
||||
|
||||
.menu-item-action {
|
||||
@apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
|
||||
@apply capitalize;
|
||||
@apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
|
||||
@apply mx-0;
|
||||
@apply lg:pl-2;
|
||||
@apply w-10 h-10 justify-center lg:w-full lg:h-auto lg:justify-normal;
|
||||
.menu-item-action {
|
||||
@apply bg-hSurface text-hOnSurface hover:mix-blend-screen;
|
||||
@apply capitalize;
|
||||
@apply flex items-center gap-3 p-2 rounded-full md:rounded-full;
|
||||
@apply mx-0;
|
||||
@apply lg:pl-2;
|
||||
@apply w-10 h-10 justify-center lg:w-full lg:h-auto lg:justify-normal;
|
||||
|
||||
i {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
i {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"sidebar": {
|
||||
"myPage": "My Page",
|
||||
"myProfile": "My Profile",
|
||||
"signIn": "Sign In",
|
||||
"signOut": "Sign Out"
|
||||
"en": {
|
||||
"sidebar": {
|
||||
"myPage": "My Page",
|
||||
"myProfile": "My Profile",
|
||||
"signIn": "Sign In",
|
||||
"signOut": "Sign Out"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"sidebar": {
|
||||
"myPage": "Ma Page",
|
||||
"myProfile": "Mon Profil",
|
||||
"signIn": "Se Connecter",
|
||||
"signOut": "Se Déconnecter"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"sidebar": {
|
||||
"myPage": "Mi Página",
|
||||
"myProfile": "Mi Perfil",
|
||||
"signIn": "Iniciar Sesión",
|
||||
"signOut": "Cerrar Sesión"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"sidebar": {
|
||||
"myPage": "Ma Page",
|
||||
"myProfile": "Mon Profil",
|
||||
"signIn": "Se Connecter",
|
||||
"signOut": "Se Déconnecter"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"sidebar": {
|
||||
"myPage": "Mi Página",
|
||||
"myProfile": "Mi Perfil",
|
||||
"signIn": "Iniciar Sesión",
|
||||
"signOut": "Cerrar Sesión"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
Reference in New Issue
Block a user