From 1f2eb1e8368b597a967f07997b2ab14d4a6616d6 Mon Sep 17 00:00:00 2001 From: Cipher Vance Date: Thu, 20 Nov 2025 19:24:43 -0600 Subject: [PATCH] feat: add Vue.js frontend with JWT auth, Pinia store, and Docker --- docker/Dockerfile | 46 +++++ docker/nginx.conf | 44 +++++ package.json | 3 +- scripts/build-frontend.sh | 173 +++++++++++++++++++ src/App.vue | 33 ++-- src/components/PasswordReset.vue | 177 +++++++++++++++++++ src/components/UserDashboard.vue | 155 +++++++++++++++++ src/components/UserLogin.vue | 207 ++++++++++++++++++----- src/components/UserProfile.vue | 282 +++++++++++++++++++++++++++++++ src/components/UserSignup.vue | 222 ++++++++++++++++++++++++ src/composables/useAuth.js | 19 +++ src/main.js | 16 +- src/router/index.js | 70 ++++++-- src/services/api.js | 61 +++++++ src/stores/auth.js | 173 +++++++++++++++++++ vue.config.js | 17 +- 16 files changed, 1623 insertions(+), 75 deletions(-) create mode 100644 docker/Dockerfile create mode 100644 docker/nginx.conf create mode 100755 scripts/build-frontend.sh create mode 100644 src/components/PasswordReset.vue create mode 100644 src/components/UserDashboard.vue create mode 100644 src/components/UserProfile.vue create mode 100644 src/components/UserSignup.vue create mode 100644 src/composables/useAuth.js create mode 100644 src/services/api.js create mode 100644 src/stores/auth.js diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..3692d4b --- /dev/null +++ b/docker/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..7041e3a --- /dev/null +++ b/docker/nginx.conf @@ -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; +} \ No newline at end of file diff --git a/package.json b/package.json index 72021bc..44e0333 100644 --- a/package.json +++ b/package.json @@ -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" }, @@ -42,4 +43,4 @@ "not dead", "not ie 11" ] -} +} \ No newline at end of file diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh new file mode 100755 index 0000000..f3f5b71 --- /dev/null +++ b/scripts/build-frontend.sh @@ -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}" \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 0065815..48f8df3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,28 +1,31 @@ + +#app { + width: 100%; + min-height: 100vh; +} + \ No newline at end of file diff --git a/src/components/PasswordReset.vue b/src/components/PasswordReset.vue new file mode 100644 index 0000000..e37571a --- /dev/null +++ b/src/components/PasswordReset.vue @@ -0,0 +1,177 @@ + + + + + \ No newline at end of file diff --git a/src/components/UserDashboard.vue b/src/components/UserDashboard.vue new file mode 100644 index 0000000..e77a754 --- /dev/null +++ b/src/components/UserDashboard.vue @@ -0,0 +1,155 @@ + + + + + \ No newline at end of file diff --git a/src/components/UserLogin.vue b/src/components/UserLogin.vue index 00d9617..2a21048 100644 --- a/src/components/UserLogin.vue +++ b/src/components/UserLogin.vue @@ -1,47 +1,174 @@ - + + \ No newline at end of file diff --git a/src/components/UserProfile.vue b/src/components/UserProfile.vue new file mode 100644 index 0000000..04ab741 --- /dev/null +++ b/src/components/UserProfile.vue @@ -0,0 +1,282 @@ + + + + + \ No newline at end of file diff --git a/src/components/UserSignup.vue b/src/components/UserSignup.vue new file mode 100644 index 0000000..c0a859e --- /dev/null +++ b/src/components/UserSignup.vue @@ -0,0 +1,222 @@ + + + + + \ No newline at end of file diff --git a/src/composables/useAuth.js b/src/composables/useAuth.js new file mode 100644 index 0000000..39b5fb3 --- /dev/null +++ b/src/composables/useAuth.js @@ -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, + } +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index e69d4b7..33f7c05 100644 --- a/src/main.js +++ b/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') \ No newline at end of file diff --git a/src/router/index.js b/src/router/index.js index a5868bb..7586f0b 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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 \ No newline at end of file diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..ebc0b28 --- /dev/null +++ b/src/services/api.js @@ -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 \ No newline at end of file diff --git a/src/stores/auth.js b/src/stores/auth.js new file mode 100644 index 0000000..2028e57 --- /dev/null +++ b/src/stores/auth.js @@ -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, + } +}) \ No newline at end of file diff --git a/vue.config.js b/vue.config.js index 910e297..f97ee55 100644 --- a/vue.config.js +++ b/vue.config.js @@ -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, + }, + }, + }, +}); \ No newline at end of file