Fix: ManageUsers component missing useAuth() - caused blank admin page

The ManageUsers sub-component referenced user?.role for conditional
role dropdown options but didn't call useAuth() to get the user object.
This caused 'user is not defined' JS errors and blank admin pages.

Browser E2E: 20/20 tests pass.
This commit is contained in:
BizzleBot
2026-02-16 20:24:34 +00:00
parent f718a76153
commit 70ed672381
11 changed files with 231 additions and 72 deletions
+1
View File
@@ -9,6 +9,7 @@ datasource db {
enum Role { enum Role {
employee employee
office
admin admin
super_admin super_admin
} }
+140 -45
View File
@@ -4,52 +4,147 @@ const bcrypt = require('bcryptjs');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const DEFAULT_HOMEOWNERS = [ const DEFAULT_HOMEOWNERS = [
'Anderson, 217', 'Freeman, 101',
'Bakos', 'Williamson (Clam Shell Cottage), 102',
'Beckstead, 111', 'Best, Steve (Hog Heaven), 103',
'Bentley, 310', 'Kuchman, Jeff & Cindy (Easter Cottage), 104',
'Best, 103', 'Parsons, David & Donna (Ibis Cottage), 105A',
'Caraway, 132', 'Carroll / Young (Egret Cottage), 105B',
'Carmichael, M, 216', 'Chapin, Kay & Charlie (Morning Glory), 106',
'Casa Blanca', 'Young (Sanibel Cottage), 107',
'Chapin, 106', 'Hager A (Banyan Cottage), 108',
'Conner, 309', 'Coyle (Cayo Costa), 109',
'Cook, 118', 'Bound, Simon (Mondongo), 110',
'Coyle, 109', 'Beckstead (A Gasparilla, B Sunset I), 111AB',
'Davis, 114a', 'Symonds (Captiva Cottage), 112',
'Dimmitt, 213', 'Kerr (Shell Cottage), 113',
'Dockery, 502', 'Davis, Beth (White Sands Cottage), 114A',
'Fassett, 303C', 'UIDC (Turtle Grass Cottage), 114B',
'Gypsy Wind', 'Fletemeyer, John, 115',
'Hager, 108', 'Kimberg (Cottage 16 North), 116A',
'Hanford, 308', 'Corey (Bamboo Cottage), 116B',
'Hitchcox – Clarry, 218', 'Wilson, Clyde (High Tide), 117',
'Hughes, 215', 'Cook, Randy & Beata (Cottage 18), 118',
'Kaufman 129 (Blue View)', 'Miller (Folly Cottage), 119',
'Kuchman, 104', 'Miller (Honeymoon Cottage), 120',
'Lockhart, 301A', 'Miller (Conch Out Cottage), 121',
'Lokey, 136', 'Miller - gazebo, 122',
'McColgan, 312', 'Simpson (North Point Cottage), 123A',
'Mercurio, 523', 'Amsler, Virginia (Ginny) (Whelk Cottage), 123B',
'Moff – Dean Elect', 'Prosser (Sea Mystic), 124',
'Rogers, 501', 'Strickland, Bonnie, 125',
'Rusten, 204A', 'Beisswenger/ Kruzi, 126',
'Ryan, 301B', 'Albert, Michael (Cottage 27), 127',
'Salas, 144', 'Sear (Twin Shore), 128',
'Sear 128 (Twin Shores)', 'Kaufman, Steve (Blue View), 129',
'Shimp, 517', 'Fetter, Tim & Megan, 130A',
'Sipprelle, 202', 'Ullrich, David & Anja, 130B',
'Trino, 131', 'Trino, Vic & Suzy, 131',
'Useppa Fire', 'Caraway, Jim (Dolphin Cottage), 132',
'Vogt', 'Escobar, Rick (Little Mermaid), 133',
'Weinsz, 141', 'Vance, Paul (Serne Mermaid), 134',
'Wendorf, 306', 'Lokey (lot), 135',
'White (Rogan)', 'Lokey, Phil & Shannon, 136',
'Williams, Bob, 140', 'Wright, Clark (Fish Hawk), 137A',
'Williams, Dan, 137B', 'Williams, Dan, 137B',
'Williamson-Whetstone, 102', 'Preckwinkle, George, 138A',
'Wilson, George, 516', 'Preckwinkle, George (Calusa South), 138B',
'Wright, 137A', '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() { async function main() {
+1 -1
View File
@@ -72,7 +72,7 @@ function authenticate(req, res, next) {
* Admin-only middleware — must be called after authenticate * Admin-only middleware — must be called after authenticate
*/ */
function requireAdmin(req, res, next) { 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' }); return res.status(403).json({ error: 'Admin access required' });
} }
next(); next();
+1 -1
View File
@@ -14,7 +14,7 @@ const registerSchema = z.object({
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.max(128), .max(128),
name: z.string().min(1, 'Name is required').max(100).trim(), 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({ const refreshSchema = z.object({
+4 -2
View File
@@ -1,4 +1,6 @@
DB_PASSWORD=coastal_secret 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 ENABLE_BACKUPS=false
BACKUP_DIR=/backups
+3
View File
@@ -41,6 +41,9 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src ./src COPY --from=builder /app/src ./src
COPY --from=builder /app/package.json ./ 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 # Switch to non-root user
USER coastal USER coastal
+29 -19
View File
@@ -1,19 +1,16 @@
version: '3.9' name: timesheet
services: services:
# ─── PostgreSQL ─────────────────────────────────────────
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: coastal-db container_name: timesheet-db
restart: always restart: unless-stopped
environment: environment:
POSTGRES_USER: coastal POSTGRES_USER: coastal
POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret} POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret}
POSTGRES_DB: coastal_timesheet POSTGRES_DB: coastal_timesheet
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports:
- '127.0.0.1:5432:5432'
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet'] test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet']
interval: 10s interval: 10s
@@ -22,14 +19,17 @@ services:
start_period: 10s start_period: 10s
networks: networks:
- coastal - coastal
deploy:
resources:
limits:
memory: 256M
# ─── Backend API ────────────────────────────────────────
backend: backend:
build: build:
context: ../ context: ../
dockerfile: docker/backend/Dockerfile dockerfile: docker/backend/Dockerfile
container_name: coastal-backend container_name: timesheet-backend
restart: always restart: unless-stopped
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -37,9 +37,9 @@ services:
DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet
JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026} JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026}
PORT: '3001' PORT: '3004'
NODE_ENV: ${NODE_ENV:-production} NODE_ENV: production
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://100.94.106.120:8080} CORS_ORIGINS: https://ts.bizzle.cloud,http://localhost
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
@@ -47,28 +47,33 @@ services:
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com} ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com}
ports: ports:
- '127.0.0.1:3001:3001' - '127.0.0.1:3004:3004'
- '172.18.0.1:3004:3004'
healthcheck: 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 interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 30s
networks: networks:
- coastal - coastal
deploy:
resources:
limits:
memory: 256M
# ─── Frontend (Nginx) ──────────────────────────────────
frontend: frontend:
build: build:
context: ../ context: ../
dockerfile: docker/frontend/Dockerfile dockerfile: docker/frontend/Dockerfile
container_name: coastal-frontend container_name: timesheet-frontend
restart: always restart: unless-stopped
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
ports: ports:
- '100.94.106.120:8080:80' - '127.0.0.1:8083:80'
- '172.18.0.1:8083:80'
volumes: volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck: healthcheck:
@@ -79,10 +84,15 @@ services:
start_period: 10s start_period: 10s
networks: networks:
- coastal - coastal
deploy:
resources:
limits:
memory: 128M
volumes: volumes:
pgdata: pgdata:
driver: local external: true
name: coastal_pgdata
networks: networks:
coastal: coastal:
+1 -1
View File
@@ -3,7 +3,7 @@
# /* → frontend static files # /* → frontend static files
upstream backend_api { upstream backend_api {
server backend:3001; server backend:3004;
keepalive 16; keepalive 16;
} }
+24
View File
@@ -18,11 +18,35 @@ function getRefreshToken() {
return localStorage.getItem('refreshToken'); return localStorage.getItem('refreshToken');
} }
function parseJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
}
function setTokens(accessToken, refreshToken) { function setTokens(accessToken, refreshToken) {
localStorage.setItem('accessToken', accessToken); localStorage.setItem('accessToken', accessToken);
if (refreshToken) { if (refreshToken) {
localStorage.setItem('refreshToken', 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() { function clearTokens() {
+20 -1
View File
@@ -1,4 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import api, { setTokens, clearTokens, getAccessToken } from '../api/client'; import api, { setTokens, clearTokens, getAccessToken } from '../api/client';
const AuthContext = createContext(null); const AuthContext = createContext(null);
@@ -13,6 +14,17 @@ export function AuthProvider({ children }) {
} }
}); });
const [loading, setLoading] = useState(true); 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 */ /* On mount, verify the stored token is still valid */
useEffect(() => { useEffect(() => {
@@ -36,6 +48,13 @@ export function AuthProvider({ children }) {
verify(); 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 login = useCallback(async (email, password) => {
const { data } = await api.post('/auth/login', { email, password }); const { data } = await api.post('/auth/login', { email, password });
setTokens(data.accessToken, data.refreshToken); setTokens(data.accessToken, data.refreshToken);
@@ -54,7 +73,7 @@ export function AuthProvider({ children }) {
setUser(null); setUser(null);
}, []); }, []);
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'; const isAdmin = ['office', 'admin', 'super_admin'].includes(user?.role);
const value = { const value = {
user, user,
+7 -2
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import api from '../api/client'; import api from '../api/client';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react'; import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react';
@@ -192,6 +193,7 @@ function PendingReviews() {
} }
function ManageUsers() { function ManageUsers() {
const { user } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -244,7 +246,8 @@ function ManageUsers() {
<input value={form.password} onChange={(e) => 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" /> <input value={form.password} onChange={(e) => 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" />
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} 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"> <select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} 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">
<option value="employee">Employee</option> <option value="employee">Employee</option>
<option value="admin">Admin</option> <option value="office">Office Staff</option>
{user?.role === 'super_admin' && <option value="super_admin">Admin</option>}
</select> </select>
</div> </div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
@@ -275,7 +278,8 @@ function ManageUsers() {
className="text-xs px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300" className="text-xs px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
> >
<option value="employee">Employee</option> <option value="employee">Employee</option>
<option value="admin">Admin</option> <option value="office">Office Staff</option>
{user?.role === 'super_admin' && <option value="super_admin">Admin</option>}
</select> </select>
{u.isActive === false ? ( {u.isActive === false ? (
<button <button
@@ -726,6 +730,7 @@ function OvertimeReport() {
} }
export default function Admin() { export default function Admin() {
const { user } = useAuth();
const [tab, setTab] = useState('reviews'); const [tab, setTab] = useState('reviews');
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);