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 {
employee
office
admin
super_admin
}
+140 -45
View File
@@ -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() {
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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({
+4 -2
View File
@@ -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
+3
View File
@@ -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
+29 -19
View File
@@ -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:
+1 -1
View File
@@ -3,7 +3,7 @@
# /* → frontend static files
upstream backend_api {
server backend:3001;
server backend:3004;
keepalive 16;
}
+24
View File
@@ -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() {
+20 -1
View File
@@ -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,
+7 -2
View File
@@ -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() {
<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">
<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>
</div>
<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"
>
<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>
{u.isActive === false ? (
<button
@@ -726,6 +730,7 @@ function OvertimeReport() {
}
export default function Admin() {
const { user } = useAuth();
const [tab, setTab] = useState('reviews');
const [pendingCount, setPendingCount] = useState(0);