feat: add Vue.js frontend with JWT auth, Pinia store, and Docker
This commit is contained in:
46
docker/Dockerfile
Normal file
46
docker/Dockerfile
Normal 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
44
docker/nginx.conf
Normal 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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.12.0",
|
||||
"core-js": "^3.8.3",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.2.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
|
||||
173
scripts/build-frontend.sh
Executable file
173
scripts/build-frontend.sh
Executable 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}"
|
||||
31
src/App.vue
31
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;
|
||||
}
|
||||
|
||||
#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>
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" v-model="password" required />
|
||||
|
||||
<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">Login</button>
|
||||
|
||||
<button type="submit" :disabled="auth.loading" class="btn-primary">
|
||||
{{ auth.loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div v-if="auth.error" class="error-message">
|
||||
{{ auth.error }}
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<RouterLink to="/signup">Don't have an account? Sign up</RouterLink>
|
||||
<RouterLink to="/password-reset">Forgot password?</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
export default {
|
||||
name: 'UserLogin',
|
||||
data() {
|
||||
return {
|
||||
<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: '',
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async login() {
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
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');
|
||||
await auth.login(form.username, form.password);
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error.response.data);
|
||||
this.error = error.response.data.error || 'An error occurred';
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,15 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
const { defineConfig } = require('@vue/cli-service');
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user