Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c0dca8f13 | ||
|
|
0fe0b444c2 | ||
|
|
5132e3eb2f | ||
|
|
4c33975d1b | ||
|
|
8da82f2735 | ||
|
|
0dcd1cfcc4 | ||
|
|
6968631b86 | ||
|
|
85acc051b1 | ||
|
|
d8d7e3e38a | ||
|
|
88b51a6faa | ||
|
|
fd0fc20b7d | ||
|
|
d8c262ff5f |
+9
-3
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import Toast from './components/ui/toast/Toast.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
<NuxtLayout>
|
||||
<Title>Reddit for scientists</Title>
|
||||
<Toast />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
const store = useNotificationStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed bottom-6 right-6 z-[9999] flex flex-col gap-3 w-80">
|
||||
<TransitionGroup name="toast">
|
||||
<div v-for="toast in store.notifications" :key="toast.id" :class="[
|
||||
'p-4 rounded-lg shadow-xl border text-sm font-medium transition-all duration-300',
|
||||
toast.type === 'error'
|
||||
? 'bg-red-50 border-red-200 text-red-800'
|
||||
: 'bg-green-50 border-green-200 text-green-800'
|
||||
]">
|
||||
<div class="flex justify-between items-center">
|
||||
<span>{{ toast.message }}</span>
|
||||
<button class="ml-4 opacity-50 hover:opacity-100" @click="store.remove(toast.id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isDark = ref(false);
|
||||
const router = useRouter();
|
||||
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value;
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.theme = 'dark';
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
router.push("/auth/login");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 flex items-center justify-between px-6 py-4 bg-white/80 backdrop-blur-md shadow-sm border-b border-gray-100">
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<nuxt-link to="/" class="text-sm font-medium text-gray-600 hover:text-blue-600 transition-colors duration-200"
|
||||
active-class="text-blue-600 font-bold">
|
||||
Home
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="p-2 rounded-lg bg-gray-200 dark:bg-gray-700" @click="toggleDark">
|
||||
<span v-if="isDark">☀️</span>
|
||||
<span v-else>🌙</span>
|
||||
</button>
|
||||
|
||||
<template v-if="authStore?.token">
|
||||
<label class="py-2 text-sm font-semibold">{{ authStore?.user.name }}</label>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-semibold text-red-800 bg-red-100 hover:bg-red-200 rounded-lg transition-all duration-200"
|
||||
@click.prevent="logout">
|
||||
Logout
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<nuxt-link to="/auth/login"
|
||||
class="px-5 py-2 text-sm font-semibold text-white! bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm transition-all">
|
||||
Login
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto">
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const authStore = useAuthStore();
|
||||
const isLoggedIn = !!(authStore.user || authStore.token);
|
||||
const mustChangePassword = authStore.user?.mustChangePassword === true;
|
||||
|
||||
const isAuthPage = to.path.startsWith('/auth');
|
||||
const isUpdatePasswordPage = to.path === '/profile/update-password';
|
||||
|
||||
if (!isLoggedIn && !isAuthPage) {
|
||||
return navigateTo('/auth/login');
|
||||
}
|
||||
|
||||
if (isLoggedIn && mustChangePassword && !isUpdatePasswordPage) {
|
||||
return navigateTo('/profile/update-password');
|
||||
}
|
||||
|
||||
if (isLoggedIn && isAuthPage) {
|
||||
return navigateTo('/');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
const email = ref("");
|
||||
const error = ref(null);
|
||||
const message = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const router = useRouter();
|
||||
const notifications = useNotificationStore();
|
||||
|
||||
async function handleResetRequest() {
|
||||
error.value = null;
|
||||
message.value = "";
|
||||
|
||||
if (!email.value) {
|
||||
error.value = "Email is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\S+@\S+\.\S+$/.test(email.value)) {
|
||||
error.value = "Please enter a valid email address.";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/auth/recover-password`, {
|
||||
method: "POST",
|
||||
body: { email: email.value },
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
notifications.success("Password reset email sent.");
|
||||
router.push("/auth/login")
|
||||
}
|
||||
},
|
||||
onResponseError() {
|
||||
error.value = "Email not found in our records.";
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!error.value) error.value = "Network error: Connection to server failed.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gray-100 dark:bg-gray-950 flex flex-col items-center justify-center p-6 transition-colors duration-300">
|
||||
<Transition appear enter-active-class="transition duration-700 ease-out"
|
||||
enter-from-class="translate-y-8 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||
|
||||
<div
|
||||
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-lg p-8 border dark:border-gray-800 transition-colors">
|
||||
<h1 class="text-3xl font-bold text-gray-800 dark:text-gray-100! mb-8 text-center">Reset Password</h1>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleResetRequest">
|
||||
<div>
|
||||
<label for="email"
|
||||
class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input id="email" v-model="email" type="email" :class="[
|
||||
'w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white',
|
||||
error ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
]" placeholder="[email protected]">
|
||||
<p v-if="error" class="mt-1.5 text-xs text-red-500! ml-1 font-medium">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 pt-2">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-bold py-3 rounded-lg shadow-md hover:shadow-lg transform active:scale-95 transition-all duration-200">
|
||||
{{ loading ? 'SENDING...' : 'SEND RESET LINK' }}
|
||||
</button>
|
||||
|
||||
<nuxt-link to="/auth/login"
|
||||
class="text-sm text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors">
|
||||
Back to Login
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gray-100 dark:bg-gray-950 flex flex-col items-center justify-center p-6 transition-colors duration-300">
|
||||
<Transition appear enter-active-class="transition duration-700 ease-out" enter-from-class="translate-y-8 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100">
|
||||
|
||||
<div
|
||||
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-lg p-8 border dark:border-gray-800 transition-colors">
|
||||
<h1 class="text-3xl font-bold text-gray-800 dark:text-white! mb-8 text-center capitalize">Login</h1>
|
||||
|
||||
<div v-if="globalError"
|
||||
class="mb-6 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm text-center">
|
||||
{{ globalError }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Email</label>
|
||||
<input v-model="loginFormData.email" type="email" :class="[
|
||||
'w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white',
|
||||
errors.email ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
]" placeholder="[email protected]">
|
||||
<p v-if="errors.email" class="mt-1 text-xs text-red-500! ml-1">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Password</label>
|
||||
<input v-model="loginFormData.password" type="password" :class="[
|
||||
'w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white',
|
||||
errors.password ? 'border-red-500 ring-1 ring-red-500' : 'border-gray-200 dark:border-gray-700'
|
||||
]" placeholder="••••••••" @keyup.enter="handleLogin">
|
||||
<p v-if="errors.password" class="mt-1 text-xs text-red-500! ml-1">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 pt-2">
|
||||
<button :disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-bold py-3 rounded-lg shadow-md hover:shadow-lg transform active:scale-95 transition-all duration-200"
|
||||
@click="handleLogin">
|
||||
{{ loading ? 'AUTHENTICATING...' : 'LOGIN' }}
|
||||
</button>
|
||||
|
||||
<nuxt-link to="/auth/forgot-password" class="text-xs text-blue-600 hover:underline dark:text-blue-400">
|
||||
Forgot Password?
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from "~/stores/auth-store.js";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { token, user } = storeToRefs(authStore);
|
||||
|
||||
const loginFormData = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const errors = reactive({ email: null, password: null });
|
||||
const globalError = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
function validateForm() {
|
||||
errors.email = !loginFormData.email ? "Email is required" : null;
|
||||
errors.password = !loginFormData.password ? "Password is required" : null;
|
||||
|
||||
if (loginFormData.email && !/^\S+@\S+\.\S+$/.test(loginFormData.email)) {
|
||||
errors.email = "Please enter a valid email address";
|
||||
}
|
||||
|
||||
return !errors.email && !errors.password;
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
globalError.value = null;
|
||||
if (!validateForm()) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/auth/login`, {
|
||||
method: "POST",
|
||||
body: loginFormData,
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
console.log(response)
|
||||
token.value = response._data.token;
|
||||
user.value = response._data.user;
|
||||
if (response._data.user.mustChangePassword === true) {
|
||||
router.push("/profile/update-password");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
},
|
||||
onResponseError({ response }) {
|
||||
console.log(response.status)
|
||||
if (response.status === 401) {
|
||||
globalError.value = "Invalid email or password.";
|
||||
} else {
|
||||
globalError.value = "An unexpected error occurred. Please try again.";
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!globalError.value) globalError.value = "Network error: Connection to server failed.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+44
-2
@@ -1,5 +1,47 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1> This will be the home page </h1>
|
||||
<div
|
||||
class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center p-6 transition-colors duration-300">
|
||||
<div class="w-full max-w-2xl animate-[fadeInUp_0.6s_ease-out]">
|
||||
|
||||
<header class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-gray-900 dark:text-white! mb-4 tracking-tight">
|
||||
Academic Management System
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||
Streamline your educational workflow, manage student records, and oversee courses in one place.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<nuxt-link to="/auth/login"
|
||||
class="no-underline text-current block group p-8 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300">
|
||||
<div class="text-3xl mb-4 group-hover:scale-110 transition-transform">🔐</div>
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100! mb-2">Access Portal</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Sign in to manage your personalized dashboard and academic
|
||||
data.</p>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/auth-test"
|
||||
class="no-underline text-current block group p-8 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300">
|
||||
<div class="text-3xl mb-4 group-hover:scale-110 transition-transform">🛠️</div>
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100! mb-2">System Test</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Verify API connections and authentication state
|
||||
persistence.</p>
|
||||
</nuxt-link>
|
||||
|
||||
</nav>
|
||||
|
||||
<footer class="mt-16 text-center">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-gray-200 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<title> Profile - {{user.name}}</title>
|
||||
<title> Profile - {{ user.name }}</title>
|
||||
<div class="min-h-screen bg-slate-50 font-sans text-slate-900 selection:bg-blue-100 pb-12">
|
||||
|
||||
<div class="container mx-auto max-w-7xl px-4 py-8 md:py-12">
|
||||
@@ -13,11 +13,10 @@
|
||||
<div
|
||||
class="h-32 w-32 rounded-full border-4 border-white bg-slate-100 overflow-hidden shadow-md ring-1 ring-slate-200">
|
||||
<img :src="user.avatarUrl" :alt="user.name"
|
||||
class="h-full w-full object-cover transition-transform hover:scale-105" />
|
||||
class="h-full w-full object-cover transition-transform hover:scale-105">
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2 h-5 w-5 rounded-full border-[3px] border-white shadow-sm"
|
||||
:class="user.isActive ? 'bg-emerald-500' : 'bg-gray-400'" title="Online Status">
|
||||
</div>
|
||||
:class="user.isActive ? 'bg-emerald-500' : 'bg-gray-400'" title="Online Status" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">{{ user.name }}</h1>
|
||||
@@ -139,7 +138,7 @@
|
||||
class="mt-4 pt-4 border-t border-slate-50 flex flex-wrap items-center gap-4 text-xs font-medium z-10 relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500" />
|
||||
{{ pub.field }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5 text-slate-500">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
const config = useRuntimeConfig();
|
||||
const api = config.public.apiBase;
|
||||
const authStore = useAuthStore();
|
||||
const notifications = useNotificationStore();
|
||||
const router = useRouter();
|
||||
|
||||
const passwordData = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmNewPassword: ""
|
||||
});
|
||||
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
async function handleUpdatePassword() {
|
||||
error.value = null;
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmNewPassword) {
|
||||
error.value = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await $fetch(`${api}/users/me/password`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.token}`
|
||||
},
|
||||
body: {
|
||||
oldPassword: passwordData.oldPassword,
|
||||
newPassword: passwordData.newPassword,
|
||||
confirmNewPassword: passwordData.confirmNewPassword
|
||||
},
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
notifications.success("Password updated successfully.");
|
||||
authStore.user.mustChangePassword = false;
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = "Failed to update password. Please check your current password";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gray-100 dark:bg-gray-950 flex flex-col items-center justify-center p-6 transition-colors duration-300">
|
||||
<Transition appear enter-active-class="transition duration-700 ease-out"
|
||||
enter-from-class="translate-y-8 opacity-0" enter-to-class="translate-y-0 opacity-100">
|
||||
|
||||
<div
|
||||
class="w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-lg p-8 border dark:border-gray-800 transition-colors">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-white! mb-2 text-center">Password Update</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8 text-center">Please update your password to
|
||||
secure your account.</p>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="handleUpdatePassword">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Current
|
||||
Password</label>
|
||||
<input v-model="passwordData.oldPassword" type="password" required
|
||||
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-800 my-2">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">New
|
||||
Password</label>
|
||||
<input v-model="passwordData.newPassword" type="password" required
|
||||
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-gray-400 mb-1.5 ml-1">Confirm
|
||||
New Password</label>
|
||||
<input v-model="passwordData.confirmNewPassword" type="password" required
|
||||
class="w-full px-4 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<div v-if="error"
|
||||
class="p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-xs text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 pt-4">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-bold py-3 rounded-lg shadow-md hover:shadow-lg transform active:scale-95 transition-all duration-200 capitalize">
|
||||
{{ loading ? 'Updating...' : 'Update Password' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useAuthStore = defineStore("authStore", () => {
|
||||
const token = useCookie("auth_token", { maxAge: 60 * 15 });
|
||||
const user = useCookie("auth_user", { maxAge: 60 * 15 });
|
||||
|
||||
if (process.client) {
|
||||
token.value = localStorage.getItem("auth_token");
|
||||
|
||||
const savedUser = localStorage.getItem("auth_user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
user.value = JSON.parse(savedUser);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse user from storage", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([token, user], ([newToken, newUser]) => {
|
||||
if (!process.client) return;
|
||||
|
||||
if (newToken) {
|
||||
localStorage.setItem("auth_token", newToken);
|
||||
} else {
|
||||
localStorage.removeItem("auth_token");
|
||||
}
|
||||
|
||||
if (newUser) {
|
||||
localStorage.setItem("auth_user", JSON.stringify(newUser));
|
||||
} else {
|
||||
localStorage.removeItem("auth_user");
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
return { token, user, logout };
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
notifications: []
|
||||
}),
|
||||
actions: {
|
||||
add(message, type = 'success', duration = 3000) {
|
||||
const id = Date.now()
|
||||
this.notifications.push({ id, message, type })
|
||||
|
||||
setTimeout(() => {
|
||||
this.remove(id)
|
||||
}, duration)
|
||||
},
|
||||
remove(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id)
|
||||
},
|
||||
error(msg) { this.add(msg, 'error') },
|
||||
success(msg) { this.add(msg, 'success') }
|
||||
}
|
||||
})
|
||||
+8
-5
@@ -1,8 +1,7 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-07-15",
|
||||
compatibilityDate: "2026-01-01",
|
||||
devtools: { enabled: true },
|
||||
|
||||
css: [
|
||||
@@ -13,18 +12,22 @@ export default defineNuxtConfig({
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: "http://localhost:8080/academics/api/v1",
|
||||
},
|
||||
},
|
||||
|
||||
modules: [
|
||||
"@nuxt/fonts",
|
||||
"@nuxt/icon",
|
||||
"@nuxt/image",
|
||||
"@nuxt/eslint",
|
||||
"shadcn-nuxt",
|
||||
"@pinia/nuxt",
|
||||
],
|
||||
|
||||
shadcn: {
|
||||
/**
|
||||
* Prefix for all the imported component
|
||||
*/
|
||||
prefix: "",
|
||||
/**
|
||||
* Directory that the component lives in.
|
||||
|
||||
Generated
+68
-21
@@ -11,6 +11,7 @@
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/icon": "^2.1.0",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -18,6 +19,7 @@
|
||||
"eslint": "^9.39.0",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"nuxt": "^4.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.6.0",
|
||||
"shadcn-nuxt": "^2.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -3439,6 +3441,21 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinia/nuxt": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.3.tgz",
|
||||
"integrity": "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pinia": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -5106,12 +5123,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
|
||||
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.7",
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
@@ -5127,9 +5144,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
|
||||
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
@@ -7006,9 +7023,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz",
|
||||
"integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
@@ -8409,9 +8426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/h3": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz",
|
||||
"integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==",
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz",
|
||||
"integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-es": "^1.2.2",
|
||||
@@ -8419,9 +8436,9 @@
|
||||
"defu": "^6.1.4",
|
||||
"destr": "^2.0.5",
|
||||
"iron-webcrypto": "^1.2.1",
|
||||
"node-mock-http": "^1.0.2",
|
||||
"node-mock-http": "^1.0.4",
|
||||
"radix3": "^1.1.2",
|
||||
"ufo": "^1.6.1",
|
||||
"ufo": "^1.6.3",
|
||||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
@@ -10181,9 +10198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-mock-http": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.3.tgz",
|
||||
"integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz",
|
||||
"integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
@@ -10814,6 +10831,36 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
@@ -13273,9 +13320,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
|
||||
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ultrahtml": {
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.39.0",
|
||||
"lucide-vue-next": "^0.552.0",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"nuxt": "^4.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.6.0",
|
||||
"shadcn-nuxt": "^2.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
Reference in New Issue
Block a user