diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a63b79f..e10bfc9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { enum Role { employee + office admin super_admin } diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index 5dc930b..71b0602 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -4,52 +4,147 @@ const bcrypt = require('bcryptjs'); const prisma = new PrismaClient(); const DEFAULT_HOMEOWNERS = [ - 'Anderson, 217', - 'Bakos', - 'Beckstead, 111', - 'Bentley, 310', - 'Best, 103', - 'Caraway, 132', - 'Carmichael, M, 216', - 'Casa Blanca', - 'Chapin, 106', - 'Conner, 309', - 'Cook, 118', - 'Coyle, 109', - 'Davis, 114a', - 'Dimmitt, 213', - 'Dockery, 502', - 'Fassett, 303C', - 'Gypsy Wind', - 'Hager, 108', - 'Hanford, 308', - 'Hitchcox – Clarry, 218', - 'Hughes, 215', - 'Kaufman 129 (Blue View)', - 'Kuchman, 104', - 'Lockhart, 301A', - 'Lokey, 136', - 'McColgan, 312', - 'Mercurio, 523', - 'Moff – Dean Elect', - 'Rogers, 501', - 'Rusten, 204A', - 'Ryan, 301B', - 'Salas, 144', - 'Sear 128 (Twin Shores)', - 'Shimp, 517', - 'Sipprelle, 202', - 'Trino, 131', - 'Useppa Fire', - 'Vogt', - 'Weinsz, 141', - 'Wendorf, 306', - 'White (Rogan)', - 'Williams, Bob, 140', + 'Freeman, 101', + 'Williamson (Clam Shell Cottage), 102', + 'Best, Steve (Hog Heaven), 103', + 'Kuchman, Jeff & Cindy (Easter Cottage), 104', + 'Parsons, David & Donna (Ibis Cottage), 105A', + 'Carroll / Young (Egret Cottage), 105B', + 'Chapin, Kay & Charlie (Morning Glory), 106', + 'Young (Sanibel Cottage), 107', + 'Hager A (Banyan Cottage), 108', + 'Coyle (Cayo Costa), 109', + 'Bound, Simon (Mondongo), 110', + 'Beckstead (A Gasparilla, B Sunset I), 111AB', + 'Symonds (Captiva Cottage), 112', + 'Kerr (Shell Cottage), 113', + 'Davis, Beth (White Sands Cottage), 114A', + 'UIDC (Turtle Grass Cottage), 114B', + 'Fletemeyer, John, 115', + 'Kimberg (Cottage 16 North), 116A', + 'Corey (Bamboo Cottage), 116B', + 'Wilson, Clyde (High Tide), 117', + 'Cook, Randy & Beata (Cottage 18), 118', + 'Miller (Folly Cottage), 119', + 'Miller (Honeymoon Cottage), 120', + 'Miller (Conch Out Cottage), 121', + 'Miller - gazebo, 122', + 'Simpson (North Point Cottage), 123A', + 'Amsler, Virginia (Ginny) (Whelk Cottage), 123B', + 'Prosser (Sea Mystic), 124', + 'Strickland, Bonnie, 125', + 'Beisswenger/ Kruzi, 126', + 'Albert, Michael (Cottage 27), 127', + 'Sear (Twin Shore), 128', + 'Kaufman, Steve (Blue View), 129', + 'Fetter, Tim & Megan, 130A', + 'Ullrich, David & Anja, 130B', + 'Trino, Vic & Suzy, 131', + 'Caraway, Jim (Dolphin Cottage), 132', + 'Escobar, Rick (Little Mermaid), 133', + 'Vance, Paul (Serne Mermaid), 134', + 'Lokey (lot), 135', + 'Lokey, Phil & Shannon, 136', + 'Wright, Clark (Fish Hawk), 137A', 'Williams, Dan, 137B', - 'Williamson-Whetstone, 102', - 'Wilson, George, 516', - 'Wright, 137A', + 'Preckwinkle, George, 138A', + 'Preckwinkle, George (Calusa South), 138B', + 'Collier - lot, 139', + 'Williams, Bob, 140', + 'Weinsz, Steve, 141', + 'Lawler, Michael (Sea level), 142A', + 'Lawler (Sea Level Cottage), 142B', + 'Ketay, 143', + 'Salas, Joe (West Wind), 144', + 'Buchanan (Eagles Nest), 145', + 'Buchanan (Eagles Nest), 146', + 'Nutting - lot, 147', + 'Firehouse, 200', + 'Smith, Sawyer (Alibi Cottage), 201', + 'Sipprelle, Dwight (The Aerie), 202', + 'Sipprelle -lot, 203', + 'Rusten, Brendt (Take Five), 204A', + 'Useppa One LLC (Rusten), 204B', + 'Useppa One LLC (Rusten)- gazebo, 205', + 'Mezyninski, Steve (Hill Tide), 206', + 'Sullivan, 207', + 'Sullivan, 208', + 'Nutting (A Little Piece of Heaven), 209', + 'UIDC - Donald (Live Oak Cottage), 210', + 'Newbold, Carol (Palm Cottage), 211', + 'Dimmitt, Peter (Lookout Cottage), 212', + 'Dimmitt, Peter (Lookout Cottage), 213', + 'Greenwell (Hide-A-Way Cottage), 214', + 'Hughes, Dave (Cottage 15), 215', + 'Carmichael, Michael (8 Palms), 216', + 'Anderson, Donna (Island Time), 217', + 'Hitchcox, Doug, 218', + 'Lockhart, Kim (Coquina Cottage), 301A', + 'Ryan, Doug & Cindy, 301B', + 'Duke - lot, 302', + 'Stuart, Deb, 303A', + 'Judge, James (Osprey Point), 303B', + 'Fassett, Ladd (Cottage 3AN), 303C', + 'Ink (Cottage 3AS), 303D', + 'McGinn (Gaspar Island), 304', + 'McGinn (Gaspar Island), 305', + 'Wendorf, Bruce & Hillary (It\'s a Wendorful Life Cottage), 306', + 'Bennett, 307', + 'Hanford, Ken & Kim, 308', + 'Conner, Drew & Paige (Campfire Lighter), 309', + 'Bentley, Joel & Sue, 310', + 'Covington/Bernard, 311A', + 'O\'Connell (Often Inn), 311B', + 'McColgan, Brian (La Costa Sunset), 312', + 'Preckwinkle, George, 313', + 'Perrone (Lagoona Vista), 314', + 'Thompson, Jim, 315', + 'Nutting - lot, 316', + 'Nutting - lot, 317', + 'Kent / Williamson (Kaos Kattage), 318', + 'Kahane -lot, 319', + 'Kahane (Belvedere Cottage?), 320', + 'Nutting - lot, 321', + 'Molosky, Andrew, 322A', + 'Folkerth, Betty (Living Water Cottage), 322B', + 'Rowars, Chuck (Mangoes), 323', + 'Eldemir, Alex, 324', + 'Paradise Partnership, 325', + 'Ricciardelli - lot, 326', + 'Cardwell - lot, 327', + 'Spencer, Doug & Holly, 328', + 'Walker, Jeff & Alicia (HighSeas), 329', + 'Loeks - lot, 401', + 'Loeks - lot, 402', + 'Loeks - lot, 403', + 'Wilson, George - lot, 404', + 'Nutting - lot, 405', + 'Nutting - lot, 406', + 'Soriero - lot, 407', + 'Alderman, Gary, 501', + 'Dockery, Bob & Susan, 502', + 'Hansen (Starfish Cottage), 503', + 'Berger / Bevis (Castaway), 504', + 'Tinney, John, 505', + 'Matter, John, 506', + 'Stuart (Fancy Free), 507', + 'Fernandez, 508', + 'Wiesen, Matt & Katy, 509', + 'Romine, Dave, 510', + 'Handin (Grandview Cottage), 511', + 'Colgan, Tony (Cottage 12), 512', + 'Cupello (White Rose Cottage), 513', + 'Meyer (Burgee Cottage), 514', + 'Jarvis, Jim & Patty (Laguna Cottage), 515', + 'Wilson, George (Cottage 16), 516', + 'Shimp, Kevin, 517', + 'Dreher, Brucke (Island Escape), 518', + 'Beckman (Mangrove Cottage), 519', + 'Gatewood (Water Wings), 520', + 'Duke, Steve & Janie (Dutchess Cottage), 521', + 'Useppa Island LLC, 522', + 'Mercurio, Michael & Beth (Osprey Nest), 523', + 'Compton, Janie (Dvorak), 524', ]; async function main() { diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 68e87c9..21d6aea 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -72,7 +72,7 @@ function authenticate(req, res, next) { * Admin-only middleware — must be called after authenticate */ function requireAdmin(req, res, next) { - if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'super_admin')) { + if (!req.user || !['office', 'admin', 'super_admin'].includes(req.user.role)) { return res.status(403).json({ error: 'Admin access required' }); } next(); diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js index d700311..191e9ef 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -14,7 +14,7 @@ const registerSchema = z.object({ .min(8, 'Password must be at least 8 characters') .max(128), name: z.string().min(1, 'Name is required').max(100).trim(), - role: z.enum(['employee', 'admin', 'super_admin']).default('employee'), + role: z.enum(['employee', 'office', 'admin', 'super_admin']).default('employee'), }); const refreshSchema = z.object({ diff --git a/docker/.env b/docker/.env index 2c8a46b..c427582 100644 --- a/docker/.env +++ b/docker/.env @@ -1,4 +1,6 @@ DB_PASSWORD=coastal_secret -# Set to "true" to enable daily pg_dump backups +JWT_SECRET=Ts8pLm3QvK5nRw9sYzB4jFc7hE0aGd2U +JWT_REFRESH_SECRET=Rf6kMn2QpT8wLs4vYzA7jFb9hD0eGc5U +NODE_ENV=production +CORS_ORIGINS=https://ts.bizzle.cloud ENABLE_BACKUPS=false -BACKUP_DIR=/backups diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 68f35d1..40c12fb 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -41,6 +41,9 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/src ./src COPY --from=builder /app/package.json ./ +# Fix ownership so non-root user can regenerate Prisma client +RUN chown -R coastal:coastal /app + # Switch to non-root user USER coastal diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7591ab1..c90af8d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,19 +1,16 @@ -version: '3.9' +name: timesheet services: - # ─── PostgreSQL ───────────────────────────────────────── db: image: postgres:16-alpine - container_name: coastal-db - restart: always + container_name: timesheet-db + restart: unless-stopped environment: POSTGRES_USER: coastal POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret} POSTGRES_DB: coastal_timesheet volumes: - pgdata:/var/lib/postgresql/data - ports: - - '127.0.0.1:5432:5432' healthcheck: test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet'] interval: 10s @@ -22,14 +19,17 @@ services: start_period: 10s networks: - coastal + deploy: + resources: + limits: + memory: 256M - # ─── Backend API ──────────────────────────────────────── backend: build: context: ../ dockerfile: docker/backend/Dockerfile - container_name: coastal-backend - restart: always + container_name: timesheet-backend + restart: unless-stopped depends_on: db: condition: service_healthy @@ -37,9 +37,9 @@ services: DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026} - PORT: '3001' - NODE_ENV: ${NODE_ENV:-production} - CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://100.94.106.120:8080} + PORT: '3004' + NODE_ENV: production + CORS_ORIGINS: https://ts.bizzle.cloud,http://localhost SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USER: ${SMTP_USER:-} @@ -47,28 +47,33 @@ services: SMTP_FROM: ${SMTP_FROM:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com} ports: - - '127.0.0.1:3001:3001' + - '127.0.0.1:3004:3004' + - '172.18.0.1:3004:3004' healthcheck: - test: ['CMD', 'node', '-e', "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] + test: ['CMD', 'node', '-e', "fetch('http://localhost:3004/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 start_period: 30s networks: - coastal + deploy: + resources: + limits: + memory: 256M - # ─── Frontend (Nginx) ────────────────────────────────── frontend: build: context: ../ dockerfile: docker/frontend/Dockerfile - container_name: coastal-frontend - restart: always + container_name: timesheet-frontend + restart: unless-stopped depends_on: backend: condition: service_healthy ports: - - '100.94.106.120:8080:80' + - '127.0.0.1:8083:80' + - '172.18.0.1:8083:80' volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro healthcheck: @@ -79,10 +84,15 @@ services: start_period: 10s networks: - coastal + deploy: + resources: + limits: + memory: 128M volumes: pgdata: - driver: local + external: true + name: coastal_pgdata networks: coastal: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index f64b80d..aac9bc1 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -3,7 +3,7 @@ # /* → frontend static files upstream backend_api { - server backend:3001; + server backend:3004; keepalive 16; } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index dba555e..1038933 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -18,11 +18,35 @@ function getRefreshToken() { return localStorage.getItem('refreshToken'); } +function parseJwt(token) { + try { + return JSON.parse(atob(token.split('.')[1])); + } catch (e) { + return null; + } +} + function setTokens(accessToken, refreshToken) { localStorage.setItem('accessToken', accessToken); if (refreshToken) { localStorage.setItem('refreshToken', refreshToken); } + + // Sync the 'user' object in localStorage with the data from the new access token + const payload = parseJwt(accessToken); + if (payload) { + const existingUser = JSON.parse(localStorage.getItem('user') || '{}'); + // Ensure we don't overwrite user-specific static data if missing from JWT + const updatedUser = { ...existingUser, ...payload }; + // The user ID usually comes as 'sub' in JWT, but frontend expects 'id' + if (payload.sub && !updatedUser.id) updatedUser.id = payload.sub; + + // Explicitly update role and email which are crucial for the UI + if (payload.role) updatedUser.role = payload.role; + if (payload.email) updatedUser.email = payload.email; + + localStorage.setItem('user', JSON.stringify(updatedUser)); + } } function clearTokens() { diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 805ee3a..1de14d9 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,4 +1,5 @@ import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; import api, { setTokens, clearTokens, getAccessToken } from '../api/client'; const AuthContext = createContext(null); @@ -13,6 +14,17 @@ export function AuthProvider({ children }) { } }); const [loading, setLoading] = useState(true); + const location = useLocation(); + + const refreshUser = useCallback(async () => { + try { + const { data } = await api.get('/auth/me'); + setUser(data.user); + localStorage.setItem('user', JSON.stringify(data.user)); + } catch { + /* If /me fails, we might be logged out or server is down */ + } + }, []); /* On mount, verify the stored token is still valid */ useEffect(() => { @@ -36,6 +48,13 @@ export function AuthProvider({ children }) { verify(); }, []); + /* Refresh user profile on navigation to ensure role is up to date */ + useEffect(() => { + if (user) { + refreshUser(); + } + }, [location.pathname, user?.id]); + const login = useCallback(async (email, password) => { const { data } = await api.post('/auth/login', { email, password }); setTokens(data.accessToken, data.refreshToken); @@ -54,7 +73,7 @@ export function AuthProvider({ children }) { setUser(null); }, []); - const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'; + const isAdmin = ['office', 'admin', 'super_admin'].includes(user?.role); const value = { user, diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index a457e30..384abbb 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; import api from '../api/client'; import StatusBadge from '../components/StatusBadge'; import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react'; @@ -192,6 +193,7 @@ function PendingReviews() { } function ManageUsers() { + const { user } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); @@ -244,7 +246,8 @@ function ManageUsers() { setForm({ ...form, password: e.target.value })} required type="password" placeholder="Password (min 8 chars)" minLength={8} className="px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />