feat: add Vue.js frontend with JWT auth, Pinia store, and Docker

This commit is contained in:
Cipher Vance
2025-11-20 19:24:43 -06:00
parent 580a029742
commit 1f2eb1e836
16 changed files with 1623 additions and 75 deletions

View File

@@ -1,28 +1,31 @@
<template>
<Login />
<LoggedinPage />
<router-view />
</template>
<script>
import Login from './components/UserLogin.vue';
import LoggedinPage from './components/LoggedinPage.vue';
export default {
name: 'App',
components: {
Login,
LoggedinPage
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
background-color: #f5f5f5;
}
</style>
#app {
width: 100%;
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div class="password-reset-container">
<div class="password-reset-card">
<h1>Reset Password</h1>
<p class="subtitle">Enter your email to receive a reset link</p>
<form v-if="!resetLinkSent" @submit.prevent="handleRequestReset">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="Enter your email"
required
/>
</div>
<button type="submit" :disabled="auth.loading" class="btn-primary">
{{ auth.loading ? 'Sending...' : 'Send Reset Link' }}
</button>
</form>
<div v-else class="success-section">
<h3>Check your email!</h3>
<p>We've sent a password reset link to {{ email }}</p>
<p class="small-text">Click the link in the email to reset your password</p>
</div>
<div v-if="auth.error" class="error-message">
{{ auth.error }}
</div>
<div class="links">
<RouterLink to="/login">Back to login</RouterLink>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuth } from '@/composables/useAuth'
const auth = useAuth()
const email = ref('')
const resetLinkSent = ref(false)
async function handleRequestReset() {
try {
await auth.requestPasswordReset(email.value)
resetLinkSent.value = true
} catch (error) {
console.error('Password reset request failed:', error)
}
}
</script>
<style scoped>
.password-reset-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.password-reset-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
font-size: 1.8rem;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.success-section {
text-align: center;
}
.success-section h3 {
margin: 0 0 0.5rem 0;
color: #27ae60;
}
.success-section p {
margin: 0.5rem 0;
color: #2c3e50;
}
.small-text {
font-size: 0.9rem;
color: #7f8c8d;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fee;
color: #c33;
border-radius: 4px;
}
.links {
margin-top: 1.5rem;
text-align: center;
}
.links a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.links a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<div class="dashboard">
<nav class="navbar">
<div class="navbar-brand">
<h1>RideAware</h1>
</div>
<div class="navbar-menu">
<RouterLink to="/profile">Profile</RouterLink>
<button @click="handleLogout" class="btn-logout">Logout</button>
</div>
</nav>
<div class="dashboard-content">
<div class="welcome">
<h2>Welcome back, {{ auth.user?.username }}!</h2>
<p>You have successfully logged in to RideAware</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Rides</h3>
<p class="stat-value">0</p>
</div>
<div class="stat-card">
<h3>Total Distance</h3>
<p class="stat-value">0 km</p>
</div>
<div class="stat-card">
<h3>Total Time</h3>
<p class="stat-value">0 hrs</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
const router = useRouter();
const auth = useAuth();
async function handleLogout() {
auth.logout();
router.push('/login');
}
</script>
<style scoped>
.dashboard {
min-height: 100vh;
background: #f5f5f5;
}
.navbar {
background: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand h1 {
margin: 0;
color: #667eea;
font-size: 1.5rem;
}
.navbar-menu {
display: flex;
gap: 1rem;
align-items: center;
}
.navbar-menu a {
color: #2c3e50;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.navbar-menu a:hover {
color: #667eea;
}
.btn-logout {
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.btn-logout:hover {
background: #c0392b;
}
.dashboard-content {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.welcome {
background: white;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.welcome h2 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.welcome p {
margin: 0;
color: #7f8c8d;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.stat-card h3 {
margin: 0 0 1rem 0;
color: #7f8c8d;
font-size: 0.9rem;
text-transform: uppercase;
}
.stat-value {
margin: 0;
font-size: 2rem;
color: #667eea;
font-weight: 700;
}
</style>

View File

@@ -1,47 +1,174 @@
<template>
<div class="login">
<h2>Login to RideAware</h2>
<form @submit.prevent="login">
<div>
<label for="username">Username:</label>
<input type="text" id="username" v-model="username" required />
<div class="login-container">
<div class="login-card">
<h1>Login to RideAware</h1>
<p class="subtitle">Train with Focus. Ride with Awareness</p>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
v-model="form.username"
type="text"
placeholder="Enter your username"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="Enter your password"
required
/>
</div>
<button type="submit" :disabled="auth.loading" class="btn-primary">
{{ auth.loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<div v-if="auth.error" class="error-message">
{{ auth.error }}
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" v-model="password" required />
<div class="links">
<RouterLink to="/signup">Don't have an account? Sign up</RouterLink>
<RouterLink to="/password-reset">Forgot password?</RouterLink>
</div>
<button type="submit">Login</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'UserLogin',
data() {
return {
username: '',
password: '',
error: null,
};
},
methods: {
async login() {
try {
const response = await axios.post('http://127.0.0.1:5000/login', {
username: this.username,
password: this.password,
});
console.log('Login successful:', response.data);
// Redirect to logged-in page on success
this.$router.push('/logged-in');
} catch (error) {
console.error('Login failed:', error.response.data);
this.error = error.response.data.error || 'An error occurred';
}
},
},
};
<script setup>
import { reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
const router = useRouter();
const auth = useAuth();
const form = reactive({
username: '',
password: '',
});
async function handleLogin() {
try {
await auth.login(form.username, form.password);
router.push('/dashboard');
} catch (error) {
console.error('Login error:', error);
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
font-size: 1.8rem;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 1.5rem;
font-style: italic;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fee;
color: #c33;
border-radius: 4px;
font-size: 0.9rem;
}
.links {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1.5rem;
text-align: center;
}
.links a {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
.links a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<div class="profile-page">
<nav class="navbar">
<div class="navbar-brand">
<h1>RideAware</h1>
</div>
<div class="navbar-menu">
<RouterLink to="/dashboard">Dashboard</RouterLink>
<button @click="handleLogout" class="btn-logout">Logout</button>
</div>
</nav>
<div class="profile-content">
<div class="profile-card">
<h2>User Profile</h2>
<form @submit.prevent="handleUpdateProfile">
<div class="form-row">
<div class="form-group">
<label>First Name</label>
<input v-model="form.firstName" type="text" />
</div>
<div class="form-group">
<label>Last Name</label>
<input v-model="form.lastName" type="text" />
</div>
</div>
<div class="form-group">
<label>Bio</label>
<textarea v-model="form.bio" placeholder="Tell us about yourself"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>FTP (watts)</label>
<input v-model.number="form.ftp" type="number" />
</div>
<div class="form-group">
<label>Max HR (bpm)</label>
<input v-model.number="form.maxHr" type="number" />
</div>
<div class="form-group">
<label>Resting HR (bpm)</label>
<input v-model.number="form.restingHr" type="number" />
</div>
</div>
<div class="form-group">
<label>Weight (kg)</label>
<input v-model.number="form.weight" type="number" step="0.1" />
</div>
<button type="submit" :disabled="auth.loading" class="btn-primary">
{{ auth.loading ? 'Saving...' : 'Save Profile' }}
</button>
</form>
<div v-if="auth.error" class="error-message">
{{ auth.error }}
</div>
<div v-if="successMessage" class="success-message">
{{ successMessage }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
const router = useRouter();
const auth = useAuth();
const successMessage = ref('');
const form = reactive({
firstName: '',
lastName: '',
bio: '',
ftp: 0,
maxHr: 0,
restingHr: 0,
weight: 0,
});
onMounted(async () => {
try {
const profile = await auth.fetchProfile();
if (profile.profile) {
form.firstName = profile.profile.first_name;
form.lastName = profile.profile.last_name;
form.bio = profile.profile.bio;
form.ftp = profile.profile.ftp;
form.maxHr = profile.profile.max_hr;
form.restingHr = profile.profile.resting_hr;
form.weight = profile.profile.weight;
}
} catch (error) {
console.error('Failed to load profile:', error);
}
});
async function handleUpdateProfile() {
try {
await auth.updateProfile({
first_name: form.firstName,
last_name: form.lastName,
bio: form.bio,
ftp: form.ftp,
max_hr: form.maxHr,
resting_hr: form.restingHr,
weight: form.weight,
});
successMessage.value = 'Profile updated successfully!';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} catch (error) {
console.error('Failed to update profile:', error);
}
}
function handleLogout() {
auth.logout();
router.push('/login');
}
</script>
<style scoped>
.profile-page {
min-height: 100vh;
background: #f5f5f5;
}
.navbar {
background: white;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand h1 {
margin: 0;
color: #667eea;
font-size: 1.5rem;
}
.navbar-menu {
display: flex;
gap: 1rem;
align-items: center;
}
.navbar-menu a {
color: #2c3e50;
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.navbar-menu a:hover {
color: #667eea;
}
.btn-logout {
padding: 0.5rem 1rem;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.btn-logout:hover {
background: #c0392b;
}
.profile-content {
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
}
.profile-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.profile-card h2 {
margin: 0 0 1.5rem 0;
color: #2c3e50;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
input,
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.3s;
box-sizing: border-box;
}
input:focus,
textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
textarea {
resize: vertical;
min-height: 100px;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fee;
color: #c33;
border-radius: 4px;
}
.success-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #efe;
color: #3c3;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="signup-container">
<div class="signup-card">
<h1>Join RideAware</h1>
<p class="subtitle">Train with Focus. Ride with Awareness</p>
<form @submit.prevent="handleSignup">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
v-model="form.username"
type="text"
placeholder="Choose a username"
required
/>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
placeholder="Enter your email"
required
/>
</div>
<div class="form-group">
<label for="firstName">First Name</label>
<input
id="firstName"
v-model="form.firstName"
type="text"
placeholder="Your first name"
/>
</div>
<div class="form-group">
<label for="lastName">Last Name</label>
<input
id="lastName"
v-model="form.lastName"
type="text"
placeholder="Your last name"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="At least 8 characters"
required
/>
<small v-if="form.password.length < 8" class="password-hint">
Password must be at least 8 characters
</small>
</div>
<button type="submit" :disabled="auth.loading || form.password.length < 8" class="btn-primary">
{{ auth.loading ? 'Creating account...' : 'Sign Up' }}
</button>
</form>
<div v-if="auth.error" class="error-message">
{{ auth.error }}
</div>
<div class="links">
<p>Already have an account? <RouterLink to="/login">Login here</RouterLink></p>
</div>
</div>
</div>
</template>
<script setup>
import { reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
const router = useRouter();
const auth = useAuth();
const form = reactive({
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
});
async function handleSignup() {
try {
await auth.signup(
form.username,
form.password,
form.email,
form.firstName,
form.lastName
);
router.push('/dashboard');
} catch (error) {
console.error('Signup error:', error);
}
}
</script>
<style scoped>
.signup-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.signup-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
font-size: 1.8rem;
}
.subtitle {
color: #7f8c8d;
margin-bottom: 1.5rem;
font-style: italic;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.password-hint {
display: block;
margin-top: 0.25rem;
color: #e74c3c;
font-size: 0.85rem;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fee;
color: #c33;
border-radius: 4px;
font-size: 0.9rem;
}
.links {
margin-top: 1.5rem;
text-align: center;
color: #7f8c8d;
}
.links a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.links a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,19 @@
import { useAuthStore } from '@/stores/auth'
export function useAuth() {
const authStore = useAuthStore()
return {
user: authStore.user,
isAuthenticated: authStore.isAuthenticated,
error: authStore.error,
loading: authStore.loading,
signup: authStore.signup,
login: authStore.login,
logout: authStore.logout,
requestPasswordReset: authStore.requestPasswordReset,
resetPassword: authStore.resetPassword,
fetchProfile: authStore.fetchProfile,
updateProfile: authStore.updateProfile,
}
}

View File

@@ -1,7 +1,11 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App);
app.use(router);
app.mount('#app');
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -1,16 +1,66 @@
import { createRouter, createWebHistory } from 'vue-router';
import Login from '../components/UserLogin.vue';
import LoggedinPage from '@/components/LoggedinPage.vue';
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import UserLogin from '@/components/UserLogin.vue'
import UserSignup from '@/components/UserSignup.vue'
import UserDashboard from '@/components/UserDashboard.vue'
import UserProfile from '@/components/UserProfile.vue'
import PasswordReset from '@/components/PasswordReset.vue'
const routes = [
{ path: '/', component: Login },
{ path: '/logged-in', component: LoggedinPage}
//{ path: '/dashboard', component: () => import('../components/Dashboard.vue') }, // Placeholder for a dashboard page
];
{
path: '/login',
name: 'Login',
component: UserLogin,
meta: { requiresAuth: false },
},
{
path: '/signup',
name: 'Signup',
component: UserSignup,
meta: { requiresAuth: false },
},
{
path: '/password-reset',
name: 'PasswordReset',
component: PasswordReset,
meta: { requiresAuth: false },
},
{
path: '/dashboard',
name: 'Dashboard',
component: UserDashboard,
meta: { requiresAuth: true },
},
{
path: '/profile',
name: 'Profile',
component: UserProfile,
meta: { requiresAuth: true },
},
{
path: '/',
redirect: '/dashboard',
},
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(process.env.BASE_URL),
routes,
});
})
export default router;
// Navigation guard
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const requiresAuth = to.meta.requiresAuth
if (requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (!requiresAuth && authStore.isAuthenticated && (to.path === '/login' || to.path === '/signup')) {
next('/dashboard')
} else {
next()
}
})
export default router

61
src/services/api.js Normal file
View File

@@ -0,0 +1,61 @@
import axios from 'axios'
const API_BASE_URL = process.env.VUE_APP_API_URL || 'http://127.0.0.1:5000'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
throw new Error('No refresh token')
}
// Note: You'll need to implement this endpoint on the backend
const { data } = await axios.post(
`${API_BASE_URL}/api/refresh-token`,
{ refresh_token: refreshToken }
)
localStorage.setItem('access_token', data.access_token)
originalRequest.headers.Authorization = `Bearer ${data.access_token}`
return api(originalRequest)
} catch (refreshError) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
export default api

173
src/stores/auth.js Normal file
View File

@@ -0,0 +1,173 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/services/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const accessToken = ref(localStorage.getItem('access_token'))
const refreshToken = ref(localStorage.getItem('refresh_token'))
const error = ref(null)
const loading = ref(false)
const isAuthenticated = computed(() => !!accessToken.value)
const signup = async (username, password, email, firstName, lastName) => {
loading.value = true
error.value = null
try {
const { data } = await api.post('/api/signup', {
username,
password,
email,
first_name: firstName,
last_name: lastName,
})
accessToken.value = data.access_token
refreshToken.value = data.refresh_token
user.value = {
id: data.user_id,
username: data.username,
email: data.email,
}
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
localStorage.setItem('user', JSON.stringify(user.value))
return data
} catch (err) {
error.value = err.response?.data?.error || 'Signup failed'
throw error.value
} finally {
loading.value = false
}
}
const login = async (username, password) => {
loading.value = true
error.value = null
try {
const { data } = await api.post('/api/login', {
username,
password,
})
accessToken.value = data.access_token
refreshToken.value = data.refresh_token
user.value = {
id: data.user_id,
username: data.username,
email: data.email,
}
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
localStorage.setItem('user', JSON.stringify(user.value))
return data
} catch (err) {
error.value = err.response?.data?.error || 'Login failed'
throw error.value
} finally {
loading.value = false
}
}
const logout = () => {
user.value = null
accessToken.value = null
refreshToken.value = null
error.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
}
const requestPasswordReset = async (email) => {
loading.value = true
error.value = null
try {
const { data } = await api.post('/api/password-reset/request', { email })
return data
} catch (err) {
error.value = err.response?.data?.error || 'Password reset request failed'
throw error.value
} finally {
loading.value = false
}
}
const resetPassword = async (token, newPassword) => {
loading.value = true
error.value = null
try {
const { data } = await api.post('/api/password-reset/confirm', {
token,
new_password: newPassword,
})
return data
} catch (err) {
error.value = err.response?.data?.error || 'Password reset failed'
throw error.value
} finally {
loading.value = false
}
}
const fetchProfile = async () => {
loading.value = true
error.value = null
try {
const { data } = await api.get('/api/protected/profile')
return data
} catch (err) {
error.value = err.response?.data?.error || 'Failed to fetch profile'
throw error.value
} finally {
loading.value = false
}
}
const updateProfile = async (profileData) => {
loading.value = true
error.value = null
try {
const { data } = await api.put('/api/protected/profile', profileData)
return data
} catch (err) {
error.value = err.response?.data?.error || 'Failed to update profile'
throw error.value
} finally {
loading.value = false
}
}
// Initialize from localStorage
if (!user.value && localStorage.getItem('user')) {
user.value = JSON.parse(localStorage.getItem('user'))
}
return {
user,
accessToken,
refreshToken,
error,
loading,
isAuthenticated,
signup,
login,
logout,
requestPasswordReset,
resetPassword,
fetchProfile,
updateProfile,
}
})