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

46
docker/Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG VUE_APP_API_URL=http://127.0.0.1:5000
ENV VUE_APP_API_URL=$VUE_APP_API_URL
RUN npm run build
FROM nginx:alpine
RUN rm -rf /etc/nginx/conf.d/*
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
# Fix permissions and create necessary directories
RUN mkdir -p /var/run/nginx && \
mkdir -p /var/cache/nginx/client_temp && \
mkdir -p /var/cache/nginx/proxy_temp && \
mkdir -p /var/cache/nginx/fastcgi_temp && \
mkdir -p /var/cache/nginx/uwsgi_temp && \
mkdir -p /var/cache/nginx/scgi_temp && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/run/nginx.pid && \
chown -R nginx:nginx /var/log/nginx && \
chmod -R 755 /var/run/nginx && \
chmod -R 755 /var/cache/nginx
USER nginx
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

44
docker/nginx.conf Normal file
View File

@@ -0,0 +1,44 @@
# Don't set user - let Docker/container do that
# user nginx;
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Gzip compression
gzip on;
gzip_types text/plain text/css text/javascript application/javascript application/json;
gzip_min_length 1000;
# Cache control
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML - don't cache
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# SPA - route all requests to index.html
location / {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /index.html;
}

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"axios": "^1.12.0", "axios": "^1.12.0",
"core-js": "^3.8.3", "core-js": "^3.8.3",
"pinia": "^2.1.0",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },

173
scripts/build-frontend.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default values
IMAGE_NAME="rideaware-frontend"
IMAGE_TAG="latest"
NO_CACHE=false
RUN_CONTAINER=false
CONTAINER_NAME="rideaware-frontend"
API_URL="http://127.0.0.1:5000"
# Help function
show_help() {
cat << EOF
Usage: $0 [OPTIONS]
OPTIONS:
-t, --tag TAG Image tag (default: latest)
-n, --name NAME Image name (default: rideaware-frontend)
-r, --run Run container after build
-c, --container NAME Container name (default: rideaware-frontend)
-a, --api-url URL Backend API URL (default: http://127.0.0.1:5000)
--no-cache Build without cache
-h, --help Show this help message
EXAMPLES:
$0 # Build as rideaware-frontend:latest
$0 -t v1.0 # Build as rideaware-frontend:v1.0
$0 -t dev --run # Build and run
$0 --no-cache -t prod --run # Build without cache and run
EOF
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
IMAGE_TAG="$2"
shift 2
;;
-n|--name)
IMAGE_NAME="$2"
shift 2
;;
-r|--run)
RUN_CONTAINER=true
shift
;;
-c|--container)
CONTAINER_NAME="$2"
shift 2
;;
-a|--api-url)
API_URL="$2"
shift 2
;;
--no-cache)
NO_CACHE=true
shift
;;
-h|--help)
show_help
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
show_help
;;
esac
done
FULL_IMAGE="$IMAGE_NAME:$IMAGE_TAG"
BUILD_ARGS=""
if [ "$NO_CACHE" = true ]; then
BUILD_ARGS="--no-cache"
fi
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Building Frontend Podman Image ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo -e "${YELLOW}Image: $FULL_IMAGE${NC}"
echo -e "${YELLOW}API URL: $API_URL${NC}"
echo ""
if ! podman build $BUILD_ARGS -f docker/Dockerfile -t "$FULL_IMAGE" \
--build-arg VUE_API_URL="$API_URL" .; then
echo -e "${RED}✗ Build failed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Image built successfully${NC}"
echo ""
# Show image info
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Image Details ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
podman images "$IMAGE_NAME:$IMAGE_TAG" --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.Created}}"
echo ""
if [ "$RUN_CONTAINER" = true ]; then
echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Starting Container ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
echo ""
# Check if container already exists
if podman ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}\$"; then
echo -e "${YELLOW}Removing existing container: $CONTAINER_NAME${NC}"
# Stop if running
if podman ps --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}\$"; then
echo " Stopping container..."
podman kill "$CONTAINER_NAME" 2>/dev/null || true
fi
# Remove
echo " Removing container..."
if podman rm "$CONTAINER_NAME" 2>/dev/null; then
echo -e "${GREEN} ✓ Container removed${NC}"
else
echo -e "${RED} ✗ Failed to remove container${NC}"
exit 1
fi
fi
echo ""
echo "Starting new container: $CONTAINER_NAME"
echo ""
if podman run -d \
--name "$CONTAINER_NAME" \
-p 3000:3000 \
-e VUE_API_URL="$API_URL" \
"$FULL_IMAGE"; then
echo -e "${GREEN}✓ Container started: $CONTAINER_NAME${NC}"
sleep 2
echo ""
echo -e "${YELLOW}Container logs:${NC}"
podman logs "$CONTAINER_NAME" 2>/dev/null || echo "No logs yet"
echo ""
echo -e "${GREEN}Frontend available at: http://localhost:3000${NC}"
echo -e "${YELLOW}API configured at: $API_URL${NC}"
echo ""
echo -e "${YELLOW}To view logs:${NC}"
echo " podman logs -f $CONTAINER_NAME"
echo ""
echo -e "${YELLOW}To stop:${NC}"
echo " podman kill $CONTAINER_NAME"
else
echo -e "${RED}✗ Failed to start container${NC}"
exit 1
fi
else
echo -e "${YELLOW}To run the container:${NC}"
echo " podman run -d --name $CONTAINER_NAME -p 3000:3000 -e VUE_API_URL='$API_URL' $FULL_IMAGE"
echo ""
echo -e "${YELLOW}Or use this script with --run:${NC}"
echo " $0 -t $IMAGE_TAG --run -a '$API_URL'"
fi
echo ""
echo -e "${GREEN}✓ Done!${NC}"

View File

@@ -1,28 +1,31 @@
<template> <template>
<Login /> <router-view />
<LoggedinPage />
</template> </template>
<script> <script>
import Login from './components/UserLogin.vue';
import LoggedinPage from './components/LoggedinPage.vue';
export default { export default {
name: 'App', name: 'App',
components: {
Login,
LoggedinPage
}
} }
</script> </script>
<style> <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; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-align: center; background-color: #f5f5f5;
color: #2c3e50; }
margin-top: 60px;
#app {
width: 100%;
min-height: 100vh;
} }
</style> </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> <template>
<div class="login"> <div class="login-container">
<h2>Login to RideAware</h2> <div class="login-card">
<form @submit.prevent="login"> <h1>Login to RideAware</h1>
<div> <p class="subtitle">Train with Focus. Ride with Awareness</p>
<label for="username">Username:</label>
<input type="text" id="username" v-model="username" required /> <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>
<div>
<label for="password">Password:</label> <div class="links">
<input type="password" id="password" v-model="password" required /> <RouterLink to="/signup">Don't have an account? Sign up</RouterLink>
<RouterLink to="/password-reset">Forgot password?</RouterLink>
</div> </div>
<button type="submit">Login</button> </div>
</form>
<p v-if="error" class="error">{{ error }}</p>
</div> </div>
</template> </template>
<script> <script setup>
import axios from 'axios'; import { reactive } from 'vue';
export default { import { useRouter } from 'vue-router';
name: 'UserLogin', import { useAuth } from '@/composables/useAuth';
data() {
return { const router = useRouter();
username: '', const auth = useAuth();
password: '',
error: null, const form = reactive({
}; username: '',
}, password: '',
methods: { });
async login() {
try { async function handleLogin() {
const response = await axios.post('http://127.0.0.1:5000/login', { try {
username: this.username, await auth.login(form.username, form.password);
password: this.password, router.push('/dashboard');
}); } catch (error) {
console.log('Login successful:', response.data); console.error('Login error:', error);
// 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> </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 { createApp } from 'vue'
import App from './App.vue'; import { createPinia } from 'pinia'
import router from './router'; import App from './App.vue'
import router from './router'
const app = createApp(App); const app = createApp(App)
app.use(router);
app.mount('#app'); app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -1,16 +1,66 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router'
import Login from '../components/UserLogin.vue'; import { useAuthStore } from '@/stores/auth'
import LoggedinPage from '@/components/LoggedinPage.vue';
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 = [ const routes = [
{ path: '/', component: Login }, {
{ path: '/logged-in', component: LoggedinPage} path: '/login',
//{ path: '/dashboard', component: () => import('../components/Dashboard.vue') }, // Placeholder for a dashboard page 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({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(process.env.BASE_URL),
routes, 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,
}
})

View File

@@ -1,4 +1,15 @@
const { defineConfig } = require('@vue/cli-service') const { defineConfig } = require('@vue/cli-service');
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true transpileDependencies: true,
}) productionSourceMap: false,
devServer: {
port: 3000,
proxy: {
'/api': {
target: process.env.VUE_APP_API_URL || 'http://127.0.0.1:5000',
changeOrigin: true,
},
},
},
});