From f421154c1d647832afbbc0f619c27753763d8096 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 22 Mar 2025 22:27:50 -0400 Subject: [PATCH] Get user management page working, add permission checking in more places --- inventory-server/auth/permissions.js | 17 +- inventory-server/auth/routes.js | 18 +- inventory/src/App.tsx | 71 ++++--- inventory/src/components/auth/RequireAuth.tsx | 13 ++ .../src/components/layout/AppSidebar.tsx | 78 +++++--- .../components/settings/UserManagement.tsx | 176 +++++++++++------- inventory/src/contexts/AuthContext.tsx | 47 +++-- inventory/src/hooks/usePagePermission.ts | 101 ++++++++++ inventory/src/pages/Settings.tsx | 40 +++- 9 files changed, 414 insertions(+), 147 deletions(-) create mode 100644 inventory/src/hooks/usePagePermission.ts diff --git a/inventory-server/auth/permissions.js b/inventory-server/auth/permissions.js index 44c67c1..cfdd688 100644 --- a/inventory-server/auth/permissions.js +++ b/inventory-server/auth/permissions.js @@ -1,4 +1,19 @@ -const pool = global.pool; +// Get pool from global or create a new one if not available +let pool; +if (typeof global.pool !== 'undefined') { + pool = global.pool; +} else { + // If global pool is not available, create a new connection + const { Pool } = require('pg'); + pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, + }); + console.log('Created new database pool in permissions.js'); +} /** * Check if a user has a specific permission diff --git a/inventory-server/auth/routes.js b/inventory-server/auth/routes.js index 71aa28b..f492763 100644 --- a/inventory-server/auth/routes.js +++ b/inventory-server/auth/routes.js @@ -1,10 +1,26 @@ const express = require('express'); const router = express.Router(); -const pool = global.pool; const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { requirePermission, getUserPermissions } = require('./permissions'); +// Get pool from global or create a new one if not available +let pool; +if (typeof global.pool !== 'undefined') { + pool = global.pool; +} else { + // If global pool is not available, create a new connection + const { Pool } = require('pg'); + pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, + }); + console.log('Created new database pool in routes.js'); +} + // Authentication middleware const authenticate = async (req, res, next) => { try { diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 44a5407..dc4fc52 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, useNavigate, Navigate } from 'react-router-dom'; +import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router-dom'; import { MainLayout } from './components/layout/MainLayout'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Products } from './pages/Products'; @@ -16,62 +16,81 @@ import { Vendors } from '@/pages/Vendors'; import { Categories } from '@/pages/Categories'; import { Import } from '@/pages/Import'; import { AiValidationDebug } from "@/pages/AiValidationDebug" +import { AuthProvider } from './contexts/AuthContext'; const queryClient = new QueryClient(); function App() { const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { const checkAuth = async () => { const token = localStorage.getItem('token'); - if (token) { + const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true'; + + // If we have a token but aren't logged in yet, verify the token + if (token && !isLoggedIn) { try { const response = await fetch(`${config.authUrl}/me`, { headers: { Authorization: `Bearer ${token}`, }, }); + if (!response.ok) { localStorage.removeItem('token'); sessionStorage.removeItem('isLoggedIn'); - navigate('/login'); + + // Only navigate to login if we're not already there + if (!location.pathname.includes('/login')) { + navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`); + } + } else { + // If token is valid, set the login flag + sessionStorage.setItem('isLoggedIn', 'true'); } } catch (error) { console.error('Token verification failed:', error); localStorage.removeItem('token'); sessionStorage.removeItem('isLoggedIn'); - navigate('/login'); + + // Only navigate to login if we're not already there + if (!location.pathname.includes('/login')) { + navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`); + } } } }; checkAuth(); - }, [navigate]); + }, [navigate, location.pathname, location.search]); return ( - - - } /> - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + } /> + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/inventory/src/components/auth/RequireAuth.tsx b/inventory/src/components/auth/RequireAuth.tsx index a2aeac4..5bc5b55 100644 --- a/inventory/src/components/auth/RequireAuth.tsx +++ b/inventory/src/components/auth/RequireAuth.tsx @@ -1,9 +1,22 @@ import { Navigate, useLocation } from "react-router-dom" +import { useContext, useEffect } from "react" +import { AuthContext } from "@/contexts/AuthContext" export function RequireAuth({ children }: { children: React.ReactNode }) { const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true" + const { token, fetchCurrentUser } = useContext(AuthContext) const location = useLocation() + // Check if token exists but we're not logged in + useEffect(() => { + if (token && !isLoggedIn) { + // Verify the token and fetch user data + fetchCurrentUser().catch(() => { + // Do nothing - the AuthContext will handle errors + }) + } + }, [token, isLoggedIn, fetchCurrentUser]) + if (!isLoggedIn) { // Redirect to login with the current path in the redirect parameter return { + localStorage.removeItem('token'); sessionStorage.removeItem('isLoggedIn'); - sessionStorage.removeItem('token'); navigate('/login'); }; @@ -98,20 +107,26 @@ export function AppSidebar() { location.pathname === item.url || (item.url !== "/" && location.pathname.startsWith(item.url)); return ( - - - - - - {item.title} - - - - + + + + + + + {item.title} + + + + + ); })} @@ -122,20 +137,25 @@ export function AppSidebar() { - - - - - - Settings - - - - + + + + + + + Settings + + + + + diff --git a/inventory/src/components/settings/UserManagement.tsx b/inventory/src/components/settings/UserManagement.tsx index 455dbab..151b307 100644 --- a/inventory/src/components/settings/UserManagement.tsx +++ b/inventory/src/components/settings/UserManagement.tsx @@ -1,9 +1,13 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useContext } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { UserList } from "./UserList"; import { UserForm } from "./UserForm"; +import config from "@/config"; +import { AuthContext } from "@/contexts/AuthContext"; +import { usePermissions } from "@/hooks/usePermissions"; +import { ShieldAlert } from "lucide-react"; interface User { id: number; @@ -29,6 +33,8 @@ interface PermissionCategory { } export function UserManagement() { + const { token, fetchCurrentUser } = useContext(AuthContext); + const { hasPermission } = usePermissions(); const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [isAddingUser, setIsAddingUser] = useState(false); @@ -37,53 +43,96 @@ export function UserManagement() { const [error, setError] = useState(null); // Fetch users and permissions - useEffect(() => { - const fetchData = async () => { - try { - // Fetch users - const usersResponse = await fetch('/auth-inv/users', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - if (!usersResponse.ok) { - throw new Error('Failed to fetch users'); - } - - const usersData = await usersResponse.json(); - setUsers(usersData); - - // Fetch permissions - const permissionsResponse = await fetch('/auth-inv/permissions/categories', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - if (!permissionsResponse.ok) { - throw new Error('Failed to fetch permissions'); - } - - const permissionsData = await permissionsResponse.json(); - setPermissions(permissionsData); - - setLoading(false); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'An error occurred'; - setError(errorMessage); - setLoading(false); - } - }; + const fetchData = async () => { + if (!token) { + setError("Authentication required. Please log in again."); + setLoading(false); + return; + } + // The PermissionGuard component already handles permission checks, + // so we don't need to duplicate that logic here + + try { + setLoading(true); + setError(null); + + // Fetch users + const usersResponse = await fetch(`${config.authUrl}/users`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!usersResponse.ok) { + if (usersResponse.status === 401) { + throw new Error('Authentication failed. Please log in again.'); + } else if (usersResponse.status === 403) { + throw new Error('You don\'t have permission to access the user list.'); + } else { + // Try to get more detailed error message from response + try { + const errorData = await usersResponse.json(); + throw new Error(errorData.error || `Failed to fetch users (${usersResponse.status})`); + } catch (e) { + throw new Error(`Failed to fetch users (${usersResponse.status})`); + } + } + } + + const usersData = await usersResponse.json(); + setUsers(usersData); + + // Fetch permissions + const permissionsResponse = await fetch(`${config.authUrl}/permissions/categories`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!permissionsResponse.ok) { + if (permissionsResponse.status === 401) { + throw new Error('Authentication failed. Please log in again.'); + } else if (permissionsResponse.status === 403) { + throw new Error('You don\'t have permission to access permissions.'); + } else { + // Try to get more detailed error message from response + try { + const errorData = await permissionsResponse.json(); + throw new Error(errorData.error || `Failed to fetch permissions (${permissionsResponse.status})`); + } catch (e) { + throw new Error(`Failed to fetch permissions (${permissionsResponse.status})`); + } + } + } + + const permissionsData = await permissionsResponse.json(); + setPermissions(permissionsData); + + setLoading(false); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An error occurred'; + setError(errorMessage); + setLoading(false); + + // If authentication error, refresh the token + if (err instanceof Error && err.message.includes('Authentication failed')) { + fetchCurrentUser().catch(() => { + // Handle failed token refresh + }); + } + } + }; + + useEffect(() => { fetchData(); - }, []); + }, [token]); const handleEditUser = async (userId: number) => { try { - const response = await fetch(`/auth-inv/users/${userId}`, { + const response = await fetch(`${config.authUrl}/users/${userId}`, { headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` + 'Authorization': `Bearer ${token}` } }); @@ -110,15 +159,15 @@ export function UserManagement() { setLoading(true); const endpoint = userData.id - ? `/auth-inv/users/${userData.id}` - : '/auth-inv/users'; + ? `${config.authUrl}/users/${userData.id}` + : `${config.authUrl}/users`; const method = userData.id ? 'PUT' : 'POST'; const response = await fetch(endpoint, { method, headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(userData) @@ -129,20 +178,12 @@ export function UserManagement() { throw new Error(errorData.error || 'Failed to save user'); } - // Refresh user list - const usersResponse = await fetch('/auth-inv/users', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const updatedUsers = await usersResponse.json(); - setUsers(updatedUsers); + // Refresh user list after a successful save + await fetchData(); // Reset form state setSelectedUser(null); setIsAddingUser(false); - setLoading(false); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to save user'; setError(errorMessage); @@ -158,10 +199,10 @@ export function UserManagement() { try { setLoading(true); - const response = await fetch(`/auth-inv/users/${userId}`, { + const response = await fetch(`${config.authUrl}/users/${userId}`, { method: 'DELETE', headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` + 'Authorization': `Bearer ${token}` } }); @@ -169,16 +210,8 @@ export function UserManagement() { throw new Error('Failed to delete user'); } - // Refresh user list - const usersResponse = await fetch('/auth-inv/users', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const updatedUsers = await usersResponse.json(); - setUsers(updatedUsers); - setLoading(false); + // Refresh user list after a successful delete + await fetchData(); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to delete user'; setError(errorMessage); @@ -207,9 +240,14 @@ export function UserManagement() { return ( - + + + Permission Error {error} +
+ +
); diff --git a/inventory/src/contexts/AuthContext.tsx b/inventory/src/contexts/AuthContext.tsx index 5bdea0d..bd2c88a 100644 --- a/inventory/src/contexts/AuthContext.tsx +++ b/inventory/src/contexts/AuthContext.tsx @@ -1,4 +1,5 @@ -import { createContext, useState, useEffect, ReactNode } from 'react'; +import { createContext, useState, useEffect, ReactNode, useCallback } from 'react'; +import config from '@/config'; export interface Permission { id: number; @@ -44,52 +45,61 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // Load token from localStorage on init - useEffect(() => { - const storedToken = localStorage.getItem('token'); - if (storedToken) { - setToken(storedToken); - fetchCurrentUser(); - } - }, []); - - const fetchCurrentUser = async () => { + const fetchCurrentUser = useCallback(async () => { if (!token) return; try { setIsLoading(true); setError(null); - const response = await fetch('/auth-inv/me', { + const response = await fetch(`${config.authUrl}/me`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { - throw new Error('Failed to fetch user data'); + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to fetch user data'); } const userData = await response.json(); setUser(userData); + // Ensure we have the sessionStorage isLoggedIn flag set + sessionStorage.setItem('isLoggedIn', 'true'); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred'; setError(errorMessage); + console.error('Auth error:', errorMessage); + // Clear token if authentication failed - if (err instanceof Error && err.message.includes('authentication')) { + if (err instanceof Error && + (err.message.includes('authentication') || + err.message.includes('token') || + err.message.includes('401'))) { logout(); } } finally { setIsLoading(false); } - }; + }, [token]); + + // Load token and fetch user data on init + useEffect(() => { + if (token) { + fetchCurrentUser(); + } else { + // Clear sessionStorage if no token exists + sessionStorage.removeItem('isLoggedIn'); + } + }, [token, fetchCurrentUser]); const login = async (username: string, password: string) => { try { setIsLoading(true); setError(null); - const response = await fetch('/auth-inv/login', { + const response = await fetch(`${config.authUrl}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -98,18 +108,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); if (!response.ok) { - const errorData = await response.json(); + const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Login failed'); } const data = await response.json(); localStorage.setItem('token', data.token); + sessionStorage.setItem('isLoggedIn', 'true'); setToken(data.token); setUser(data.user); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Login failed'; setError(errorMessage); + console.error('Login error:', errorMessage); throw err; } finally { setIsLoading(false); @@ -118,6 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = () => { localStorage.removeItem('token'); + sessionStorage.removeItem('isLoggedIn'); setToken(null); setUser(null); }; diff --git a/inventory/src/hooks/usePagePermission.ts b/inventory/src/hooks/usePagePermission.ts new file mode 100644 index 0000000..87c67ae --- /dev/null +++ b/inventory/src/hooks/usePagePermission.ts @@ -0,0 +1,101 @@ +import { usePermissions } from './usePermissions'; +import { useNavigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; + +interface PagePermissionConfig { + // The permission required to access the specific page + permission?: string; + + // Array of permissions where ANY must be present + anyPermissions?: string[]; + + // Array of permissions where ALL must be present + allPermissions?: string[]; + + // Whether this page is admin-only + adminOnly?: boolean; + + // Page identifier for page-specific access check + page?: string; + + // Redirect path if permission check fails + redirectTo?: string; +} + +/** + * Hook to check if a user has permission to access a specific page + * Will automatically redirect if permission is denied + */ +export function usePagePermission(config: PagePermissionConfig) { + const { + permission, + anyPermissions, + allPermissions, + adminOnly = false, + page, + redirectTo = '/' + } = config; + + const navigate = useNavigate(); + const { + hasPermission, + hasPageAccess, + hasAnyPermission, + hasAllPermissions, + isAdmin + } = usePermissions(); + + const [hasAccess, setHasAccess] = useState(null); + + useEffect(() => { + // Check permissions + let permitted = true; + + // Admin check + if (adminOnly && !isAdmin) { + permitted = false; + } + + // Page access check + if (page && !hasPageAccess(page)) { + permitted = false; + } + + // Single permission check + if (permission && !hasPermission(permission)) { + permitted = false; + } + + // Any permissions check + if (anyPermissions && !hasAnyPermission(anyPermissions)) { + permitted = false; + } + + // All permissions check + if (allPermissions && !hasAllPermissions(allPermissions)) { + permitted = false; + } + + setHasAccess(permitted); + + // Redirect if no permission + if (permitted === false) { + navigate(redirectTo); + } + }, [ + permission, + anyPermissions, + allPermissions, + adminOnly, + page, + redirectTo, + hasPermission, + hasPageAccess, + hasAnyPermission, + hasAllPermissions, + isAdmin, + navigate + ]); + + return hasAccess; +} \ No newline at end of file diff --git a/inventory/src/pages/Settings.tsx b/inventory/src/pages/Settings.tsx index 58a6a4f..3573075 100644 --- a/inventory/src/pages/Settings.tsx +++ b/inventory/src/pages/Settings.tsx @@ -6,8 +6,24 @@ import { CalculationSettings } from "@/components/settings/CalculationSettings"; import { TemplateManagement } from "@/components/settings/TemplateManagement"; import { UserManagement } from "@/components/settings/UserManagement"; import { motion } from 'motion/react'; +import { PermissionGuard } from "@/components/common/PermissionGuard"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { usePagePermission } from "@/hooks/usePagePermission"; +import { useEffect } from "react"; export function Settings() { + // Check if the user has permission to access the Settings page + // This will automatically redirect if permission is denied + const hasAccess = usePagePermission({ + page: 'settings', + redirectTo: '/' + }); + + // Prevent flash of content before redirect + if (hasAccess === false) { + return null; + } + return (
@@ -27,9 +43,14 @@ export function Settings() { Template Management - - User Management - + + + User Management + + @@ -53,7 +74,18 @@ export function Settings() { - + + + You don't have permission to access User Management. + + + } + > + +