feat: add Vue.js frontend with JWT auth, Pinia store, and Docker
This commit is contained in:
33
src/App.vue
33
src/App.vue
@@ -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>
|
||||
177
src/components/PasswordReset.vue
Normal file
177
src/components/PasswordReset.vue
Normal 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>
|
||||
155
src/components/UserDashboard.vue
Normal file
155
src/components/UserDashboard.vue
Normal 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>
|
||||
@@ -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>
|
||||
282
src/components/UserProfile.vue
Normal file
282
src/components/UserProfile.vue
Normal 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>
|
||||
222
src/components/UserSignup.vue
Normal file
222
src/components/UserSignup.vue
Normal 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>
|
||||
19
src/composables/useAuth.js
Normal file
19
src/composables/useAuth.js
Normal 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,
|
||||
}
|
||||
}
|
||||
16
src/main.js
16
src/main.js
@@ -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')
|
||||
@@ -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
61
src/services/api.js
Normal 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
173
src/stores/auth.js
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user