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": {
|
"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
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>
|
<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>
|
||||||
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>
|
<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>
|
||||||
<div>
|
|
||||||
<label for="password">Password:</label>
|
<div class="form-group">
|
||||||
<input type="password" id="password" v-model="password" required />
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Login</button>
|
|
||||||
|
<button type="submit" :disabled="auth.loading" class="btn-primary">
|
||||||
|
{{ auth.loading ? 'Logging in...' : 'Login' }}
|
||||||
|
</button>
|
||||||
</form>
|
</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>
|
</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();
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
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
|
async function handleLogin() {
|
||||||
this.$router.push('/logged-in');
|
try {
|
||||||
|
await auth.login(form.username, form.password);
|
||||||
|
router.push('/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error.response.data);
|
console.error('Login error:', error);
|
||||||
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>
|
||||||
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 { 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')
|
||||||
@@ -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
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({
|
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