Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 82 additions & 72 deletions frontend/components/partial/ProfileButton.vue
Original file line number Diff line number Diff line change
@@ -1,73 +1,80 @@
<template>
<Dropdown :triggerText="''" :offsetDistance="7">
<template #trigger>
<IconButton size="lg" :imgUrl="profilePicture" />
</template>
<div>
<Dropdown :triggerText="''" :offsetDistance="7">
<template #trigger>
<IconButton size="lg" :imgUrl="profilePicture" />
</template>

<template #body="{ close }">
<ul class="w-[230px] !py-0 font-semibold text-dark dark:text-white-dark dark:text-white-light/90">
<li>
<div class="flex items-center px-4 py-4">
<div class="flex-none">
<img class="h-10 w-10 rounded-md object-cover" :src="profilePicture" />
</div>
<div class="truncate ltr:pl-4 rtl:pr-4">
<h4 class="text-base">
{{ profileStore.userDetail?.name }}
<!-- <span class="rounded bg-success-light px-1 text-xs text-success ltr:ml-2 rtl:ml-2">Pro</span> -->
</h4>
<span class="text-black/60 dark:text-dark-light/60 dark:hover:text-white">
{{ profileStore.email }}
</span>
<template #body="{ close }">
<ul class="w-[230px] !py-0 font-semibold text-dark dark:text-white-dark dark:text-white-light/90">
<li>
<div class="flex items-center px-4 py-4">
<div class="flex-none">
<img class="h-10 w-10 rounded-md object-cover" :src="profilePicture" />
</div>
<div class="truncate ltr:pl-4 rtl:pr-4">
<h4 class="text-base">
{{ profileStore.userDetail?.name }}
</h4>
<span class="text-black/60 dark:text-dark-light/60 dark:hover:text-white">
{{ profileStore.email }}
</span>
</div>
</div>
</div>
</li>
<li class="cursor-pointer">
<a class="dark:hover:text-white" @click="
close();
goToProfileSettings();
">
<Icon name="IconUser" class="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
{{ t('profile.profile') }}
</a>
</li>
<li class="cursor-pointer">
<a class="dark:hover:text-white" @click="
close();
goToLeitnerSettings();
">
<Icon name="IconSettings" class="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
{{ t('smart_review.settings') }}
</a>
</li>
<li class="cursor-pointer border-t border-white-light dark:border-white-light/10">
<!-- Sign Out Confirmation Modal -->
<Modal :title="t('confirm-sign-out')">
<template #trigger="{ toggleModal }">
<a class="flex items-center !py-3 text-danger ltr:pl-5 rtl:pr-5" @click="toggleModal(true)">
<Icon name="icon-logout" class="h-4.5 w-4.5 shrink-0 rotate-90 ltr:mr-2 rtl:ml-2" />
{{ t('sign-out') }}
</a>
</template>
</li>
<li class="cursor-pointer">
<a class="dark:hover:text-white" @click="
close();
goToProfileSettings();
">
<Icon name="IconUser" class="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
{{ t('profile.profile') }}
</a>
</li>
<li class="cursor-pointer">
<a class="dark:hover:text-white" @click="
close();
goToLeitnerSettings();
">
<Icon name="IconSettings" class="h-4.5 w-4.5 shrink-0 ltr:mr-2 rtl:ml-2" />
{{ t('smart_review.settings') }}
</a>
</li>
<li class="cursor-pointer border-t border-white-light dark:border-white-light/10">
<a class="flex items-center !py-3 text-danger ltr:pl-5 rtl:pr-5" @click="
close();
showSignOutModal = true;
">
<Icon name="icon-logout" class="h-4.5 w-4.5 shrink-0 rotate-90 ltr:mr-2 rtl:ml-2" />
{{ t('sign-out') }}
</a>
</li>
</ul>
</template>
</Dropdown>

<template #default>
<div class="flex flex-col space-y-2 p-4">
<p>{{ t('confirm-sign-out-message') }}</p>
</div>
</template>
<!-- Sign Out Confirmation Modal -->
<!-- Rendered outside the Dropdown so its <ul @click="close()"> doesn't
unmount the modal before it has a chance to display. -->
<Modal v-model="showSignOutModal" :title="t('confirm-sign-out')">
<template #trigger>
<div class="hidden" />
</template>

<template #footer="{ toggleModal }">
<!-- Footer -->
<div class="flex justify-end space-x-2">
<Button @click="toggleModal(false)">{{ t('cancel') }}</Button>
<Button color="danger" @click="confirmSignOut(toggleModal)">{{ t('sign-out') }}</Button>
</div>
</template>
</Modal>
</li>
</ul>
</template>
</Dropdown>
<template #default>
<div class="flex flex-col space-y-2 p-4">
<p>{{ t('confirm-sign-out-message') }}</p>
</div>
</template>

<template #footer="{ toggleModal }">
<div class="flex justify-end space-x-2">
<Button @click="toggleModal(false)">{{ t('cancel') }}</Button>
<Button color="danger" @click="confirmSignOut">{{ t('sign-out') }}</Button>
</div>
</template>
</Modal>
</div>
</template>

<script lang="ts" setup>
Expand All @@ -80,18 +87,21 @@ const { t } = useI18n();

const profileStore = useProfileStore();

const showSignOutModal = ref(false);

const profilePicture = computed(() => {
return profileStore.profilePicture || '/assets/images/user.png';
});

function logout() {
function confirmSignOut() {
showSignOutModal.value = false;
profileStore.logout();
router.push('/auth/login');
}

function confirmSignOut(toggleModal: (state: boolean) => void) {
toggleModal(false);
logout();
// Hard reload after logout: localStorage is now cleared, so the auth
// middleware on the next boot will detect no session and redirect to
// /auth/login. This sidesteps vue-router race conditions (queued
// navigations from the axios 401/412 interceptor or stale post-logout
// API responses) that were silently aborting in-page redirects.
window.location.reload();
}

function goToProfileSettings() {
Expand Down
13 changes: 1 addition & 12 deletions frontend/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,21 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const profileStore = useProfileStore();
const loginRoute = '/auth/login';

// If already on login route, allow access
if (to.fullPath.includes(loginRoute)) {
return true;
}

// If not logged in, try to login with last session
if (!profileStore.isLogin) {
try {
await profileStore.loginWithLastSession();
} catch (error) {
// If login fails, redirect to login page with original destination
const shouldIncludeRedirect = to.fullPath && to.fullPath !== '/' && to.fullPath !== '/#';
return navigateTo(shouldIncludeRedirect ? `${loginRoute}?redirect=${encodeURIComponent(to.fullPath)}` : loginRoute);
}
await profileStore.loginWithLastSession();
}

// If logged in, handle redirects
if (profileStore.isLogin) {
//Redirects
if (to.fullPath === '/' || to.fullPath === '/#') {
return navigateTo('/statistic');
}
return true;
}

// If still not logged in after all attempts, redirect to login with original destination
const shouldIncludeRedirect = to.fullPath && to.fullPath !== '/' && to.fullPath !== '/#';
return navigateTo(shouldIncludeRedirect ? `${loginRoute}?redirect=${encodeURIComponent(to.fullPath)}` : loginRoute);
});
14 changes: 13 additions & 1 deletion frontend/pages/auth/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,19 @@ onMounted(() => {
sessionStorage.setItem('auth_redirect_url', redirectUrl);
}

authentication.loginAsAnonymous();
// If a real user session is already active, don't show the login screen
// and don't clobber the in-memory user token with an anonymous one.
if (authentication.isLogin) {
router.replace(redirectUrl || '/');
return;
}

// Only prime an anonymous token when no token is loaded yet β€” keeps
// anonymous-allowed routes usable from the login page without
// overwriting an existing authenticated session.
if (!authentication.getToken) {
authentication.loginAsAnonymous();
}
});

function triggerGoogleLoginProcess() {
Expand Down
8 changes: 8 additions & 0 deletions frontend/pages/auth/login_with_token.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ onMounted(() => {
.loginWithToken(token, true)
.then(profileStore.bootstrap)
.then(() => {
// Defense-in-depth: re-save the user token after bootstrap.
// External actors (chrome extension content scripts, other tabs
// mid-anonymous flow) can overwrite localStorage.token between
// saveSession and bootstrap completion. Re-asserting the user
// token here ensures a reload won't read a stale anonymous token.
if (token) {
localStorage.setItem('token', token);
}
// Check for redirect URL in query parameter first, then in sessionStorage
const redirectUrl = (route.query.redirect as string) || sessionStorage.getItem('auth_redirect_url');

Expand Down
33 changes: 31 additions & 2 deletions frontend/plugins/modular-rest.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import { GlobalOptions } from '@modular-rest/client';
import { GlobalOptions, authentication } from '@modular-rest/client';
import axios from 'axios';

export default defineNuxtPlugin((_nuxtApp) => {
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();

GlobalOptions.set({
// the base url of the server, it should match with the server address
host: config.public.BASE_URL_API || window.location.origin,
});

if (!process.client) return;

const router = useRouter();

// When a request that carried an auth header comes back with 401 or 412,
// the token is bad (signature ok but user gone, expired, or revoked).
// Clear it and bounce to /auth/login so the user can re-authenticate
// instead of being stuck in a 412 loop.
axios.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
const headers = error?.config?.headers || {};
const sentAuth = headers.authorization || headers.Authorization;

if (sentAuth && (status === 401 || status === 412)) {
authentication.logout();

const onLoginRoute = window.location.hash.startsWith('#/auth/login');
if (!onLoginRoute) {
router.replace('/auth/login');
}
}

return Promise.reject(error);
}
);
});
27 changes: 25 additions & 2 deletions frontend/stores/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,32 @@ export const useProfileStore = defineStore('profile', () => {
return authentication
.loginWithLastSession()
.then(() => {
return bootstrap();
// Only fetch protected user data when this is a real user session.
// Anonymous tokens may legitimately be in localStorage (other tabs,
// login screen priming) β€” don't logout/clear them, just skip bootstrap.
if (authentication.user?.type !== 'user') {
return null;
}
const userToken = authentication.getToken;
return bootstrap()
.then(() => {
// Defense-in-depth: re-save the user token after bootstrap.
// External actors (chrome extension content scripts, other
// tabs mid-anonymous flow) can overwrite localStorage.token
// between saveSession and bootstrap completion.
if (userToken && authentication.user?.type === 'user') {
localStorage.setItem('token', userToken);
}
})
.catch((error) => {
const message = (error?.error || error?.message || '').toString().toLowerCase();
if (message.includes('user not found') || message.includes('authentication')) {
authentication.logout();
}
throw error;
});
})
.catch((error) => null);
.catch(() => null);
}

function updateProfile(profileData: { name?: string; profileImage?: File; preferences?: Record<string, boolean>; timeZone?: string }) {
Expand Down
Loading