init
This commit is contained in:
15
backend/db.js
Normal file
15
backend/db.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// trackaccess-backend/db.js
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
17
backend/middleware/auth.js
Normal file
17
backend/middleware/auth.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// trackaccess-backend/middleware/auth.js
|
||||
import jwt from 'jsonwebtoken';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) return res.sendStatus(401);
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, payload) => {
|
||||
if (err) return res.sendStatus(403);
|
||||
req.user = payload;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
1075
backend/package-lock.json
generated
Normal file
1075
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
backend/package.json
Normal file
20
backend/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "trackaccess-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.14.1"
|
||||
}
|
||||
}
|
||||
180
backend/server.js
Normal file
180
backend/server.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// trackaccess-backend/server.js
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import dotenv from 'dotenv';
|
||||
import { pool } from './db.js';
|
||||
import { authenticateToken } from './middleware/auth.js';
|
||||
|
||||
dotenv.config();
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// ——— AUTH ———
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (username === process.env.ADMIN_USER && password === process.env.ADMIN_PASS) {
|
||||
const token = jwt.sign({ username }, process.env.JWT_SECRET, { expiresIn: '8h' });
|
||||
return res.json({ token });
|
||||
}
|
||||
res.status(401).json({ message: 'Invalid credentials' });
|
||||
});
|
||||
|
||||
// ——— DEPARTMENT ROLES ———
|
||||
app.get('/api/departmentroles', authenticateToken, async (_, res) => {
|
||||
const [rows] = await pool.query('SELECT * FROM DepartmentRoles');
|
||||
res.json(rows);
|
||||
});
|
||||
app.post('/api/departmentroles', authenticateToken, async (req, res) => {
|
||||
const { department, role } = req.body;
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO DepartmentRoles (department, role) VALUES (?,?)',
|
||||
[department, role]
|
||||
);
|
||||
res.json({ DepartmentRoleId: result.insertId, department, role });
|
||||
});
|
||||
app.put('/api/departmentroles/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { department, role } = req.body;
|
||||
await pool.query(
|
||||
'UPDATE DepartmentRoles SET department=?, role=? WHERE DepartmentRoleId=?',
|
||||
[department, role, id]
|
||||
);
|
||||
res.json({ DepartmentRoleId: Number(id), department, role });
|
||||
});
|
||||
app.delete('/api/departmentroles/:id', authenticateToken, async (req, res) => {
|
||||
await pool.query('DELETE FROM DepartmentRoles WHERE DepartmentRoleId = ?', [req.params.id]);
|
||||
res.json({ message: 'Deleted' });
|
||||
});
|
||||
|
||||
// ——— ACCESS LEVELS ———
|
||||
app.get('/api/accesslevels', authenticateToken, async (_, res) => {
|
||||
const [rows] = await pool.query('SELECT * FROM AccessLevels');
|
||||
res.json(rows);
|
||||
});
|
||||
app.post('/api/accesslevels', authenticateToken, async (req, res) => {
|
||||
const { access_level } = req.body;
|
||||
await pool.query('INSERT INTO AccessLevels (access_level) VALUES (?)', [access_level]);
|
||||
res.json({ access_level });
|
||||
});
|
||||
app.put('/api/accesslevels/:level', authenticateToken, async (req, res) => {
|
||||
const { level } = req.params;
|
||||
const { access_level } = req.body;
|
||||
await pool.query('UPDATE AccessLevels SET access_level=? WHERE access_level=?', [
|
||||
access_level,
|
||||
level
|
||||
]);
|
||||
res.json({ access_level });
|
||||
});
|
||||
app.delete('/api/accesslevels/:level', authenticateToken, async (req, res) => {
|
||||
await pool.query('DELETE FROM AccessLevels WHERE access_level=?', [req.params.level]);
|
||||
res.json({ message: 'Deleted' });
|
||||
});
|
||||
|
||||
// ——— USERS ———
|
||||
app.get('/api/users', authenticateToken, async (_, res) => {
|
||||
const [rows] = await pool.query('SELECT * FROM Users');
|
||||
res.json(rows);
|
||||
});
|
||||
app.post('/api/users', authenticateToken, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
const [result] = await pool.query('INSERT INTO Users (name) VALUES (?)', [name]);
|
||||
res.json({ UserId: result.insertId, name });
|
||||
});
|
||||
app.put('/api/users/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
await pool.query('UPDATE Users SET name=? WHERE UserId=?', [name, id]);
|
||||
res.json({ UserId: Number(id), name });
|
||||
});
|
||||
app.delete('/api/users/:id', authenticateToken, async (req, res) => {
|
||||
await pool.query('DELETE FROM Users WHERE UserId=?', [req.params.id]);
|
||||
res.json({ message: 'Deleted' });
|
||||
});
|
||||
|
||||
// ——— USER ROLES ———
|
||||
// list all assignments
|
||||
app.get('/api/userroles', authenticateToken, async (_, res) => {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT
|
||||
ur.UserRoleId,
|
||||
ur.UserId,
|
||||
u.name AS userName,
|
||||
ur.DepartmentRoleId,
|
||||
dr.department,
|
||||
dr.role
|
||||
FROM UserRoles ur
|
||||
JOIN Users u ON ur.UserId = u.UserId
|
||||
JOIN DepartmentRoles dr ON ur.DepartmentRoleId = dr.DepartmentRoleId
|
||||
`);
|
||||
res.json(rows);
|
||||
});
|
||||
// create assignment
|
||||
app.post('/api/userroles', authenticateToken, async (req, res) => {
|
||||
const { UserId, DepartmentRoleId } = req.body;
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO UserRoles (UserId, DepartmentRoleId) VALUES (?,?)',
|
||||
[UserId, DepartmentRoleId]
|
||||
);
|
||||
res.json({ UserRoleId: result.insertId });
|
||||
});
|
||||
// remove assignment
|
||||
app.delete('/api/userroles/:id', authenticateToken, async (req, res) => {
|
||||
await pool.query('DELETE FROM UserRoles WHERE UserRoleId=?', [req.params.id]);
|
||||
res.json({ message: 'Deleted' });
|
||||
});
|
||||
|
||||
// ——— ACCESS RECORDS ———
|
||||
app.get('/api/accessrecords', authenticateToken, async (_, res) => {
|
||||
// Use UserRoleId as the reference
|
||||
const [rows] = await pool.query(`
|
||||
SELECT ar.RecordId,
|
||||
ar.UserRoleId,
|
||||
u.name AS userName,
|
||||
dr.department,
|
||||
dr.role,
|
||||
ar.system_name,
|
||||
ar.access_level,
|
||||
ar.local_account,
|
||||
ar.additional_access
|
||||
FROM AccessRecords ar
|
||||
JOIN UserRoles ur ON ar.UserRoleId = ur.UserRoleId
|
||||
JOIN Users u ON ur.UserId = u.UserId
|
||||
JOIN DepartmentRoles dr ON ur.DepartmentRoleId = dr.DepartmentRoleId
|
||||
`);
|
||||
res.json(rows);
|
||||
});
|
||||
app.post('/api/accessrecords', authenticateToken, async (req, res) => {
|
||||
const p = req.body;
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO AccessRecords
|
||||
(UserRoleId, system_name, access_level, local_account, additional_access)
|
||||
VALUES (?,?,?,?,?)`,
|
||||
[p.UserRoleId, p.system_name, p.access_level, p.local_account, p.additional_access]
|
||||
);
|
||||
res.json({ RecordId: result.insertId });
|
||||
});
|
||||
app.put('/api/accessrecords/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const p = req.body;
|
||||
await pool.query(
|
||||
`UPDATE AccessRecords
|
||||
SET UserRoleId=?, system_name=?, access_level=?, local_account=?, additional_access=?
|
||||
WHERE RecordId=?`,
|
||||
[p.UserRoleId, p.system_name, p.access_level, p.local_account, p.additional_access, id]
|
||||
);
|
||||
res.json({ RecordId: Number(id) });
|
||||
});
|
||||
app.delete('/api/accessrecords/:id', authenticateToken, async (req, res) => {
|
||||
await pool.query('DELETE FROM AccessRecords WHERE RecordId=?', [req.params.id]);
|
||||
res.json({ message: 'Deleted' });
|
||||
});
|
||||
|
||||
// ——— START SERVER ———
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Backend listening on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
159
db_schema.txt
Normal file
159
db_schema.txt
Normal file
File diff suppressed because one or more lines are too long
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
33
frontend/eslint.config.js
Normal file
33
frontend/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TrackAdmin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3831
frontend/package-lock.json
generated
Normal file
3831
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
87
frontend/src/App.jsx
Normal file
87
frontend/src/App.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// src/App.jsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
Outlet
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Login from './components/Login';
|
||||
import DepartmentRoles from './components/DepartmentRoles';
|
||||
import AccessLevels from './components/AccessLevels';
|
||||
import Users from './components/Users';
|
||||
import UserRoles from './components/UserRoles';
|
||||
import AccessRecords from './components/AccessRecords';
|
||||
import Report from './components/Report';
|
||||
import NavTabs from './components/NavTabs';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
|
||||
function ProtectedLayout({ onLogout }) {
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<button
|
||||
className="btn btn-outline-danger float-end"
|
||||
onClick={onLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<h1>TrackAccess Admin</h1>
|
||||
<NavTabs />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
|
||||
const handleLogin = tok => {
|
||||
localStorage.setItem('token', tok);
|
||||
setToken(tok);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public route */}
|
||||
<Route
|
||||
path="login"
|
||||
element={<Login onLogin={handleLogin} />}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
{token && (
|
||||
<Route element={<ProtectedLayout onLogout={handleLogout} />}>
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="departmentroles" replace />}
|
||||
/>
|
||||
<Route path="departmentroles" element={<DepartmentRoles />} />
|
||||
<Route path="accesslevels" element={<AccessLevels />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="userroles" element={<UserRoles />} />
|
||||
<Route path="accessrecords" element={<AccessRecords />} />
|
||||
<Route path="report" element={<Report />} />
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Fallback: if no token, go to login; if token, go to main */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
token
|
||||
? <Navigate to="/departmentroles" replace />
|
||||
: <Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
56
frontend/src/api.js
Normal file
56
frontend/src/api.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// trackaccess-frontend/src/api.js
|
||||
const API_URL = '';
|
||||
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!res.ok) throw new Error('Login failed');
|
||||
return (await res.json()).token;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchData(path) {
|
||||
const res = await fetch(`/api/${path}`, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error('Fetch error');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createData(path, payload) {
|
||||
const res = await fetch(`/api/${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('Create error');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateData(path, id, payload) {
|
||||
const res = await fetch(`/api/${path}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('Update error');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteData(path, id) {
|
||||
const res = await fetch(`/api/${path}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Delete error');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
117
frontend/src/components/AccessLevels.jsx
Normal file
117
frontend/src/components/AccessLevels.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
// src/components/AccessLevels.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchData, createData, updateData, deleteData } from '../api';
|
||||
|
||||
export default function AccessLevels() {
|
||||
const [levels, setLevels] = useState([]);
|
||||
const [form, setForm] = useState({ access_level: '' });
|
||||
const [editKey, setEditKey] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
const data = await fetchData('accesslevels');
|
||||
setLevels(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (editKey) {
|
||||
// rename existing level
|
||||
await updateData('accesslevels', editKey, { access_level: form.access_level });
|
||||
} else {
|
||||
// create new
|
||||
await createData('accesslevels', form);
|
||||
}
|
||||
setForm({ access_level: '' });
|
||||
setEditKey(null);
|
||||
load();
|
||||
};
|
||||
|
||||
const startEdit = (lvl) => {
|
||||
setEditKey(lvl.access_level);
|
||||
setForm({ access_level: lvl.access_level });
|
||||
};
|
||||
|
||||
const handleDelete = async (lvl) => {
|
||||
if (!window.confirm(`Delete "${lvl.access_level}"?`)) return;
|
||||
await deleteData('accesslevels', lvl.access_level);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Access Levels</h2>
|
||||
<form className="row g-3 mb-4" onSubmit={handleSubmit}>
|
||||
<div className="col-auto">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Access Level"
|
||||
value={form.access_level}
|
||||
onChange={e => setForm({ access_level: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-success">
|
||||
{editKey ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{editKey && (
|
||||
<div className="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setEditKey(null);
|
||||
setForm({ access_level: '' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Access Level</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{levels.map(lvl => (
|
||||
<tr key={lvl.access_level}>
|
||||
<td>{lvl.access_level}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-primary me-2"
|
||||
onClick={() => startEdit(lvl)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(lvl)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{levels.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="2" className="text-center">
|
||||
No access levels defined.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
445
frontend/src/components/AccessRecords.jsx
Normal file
445
frontend/src/components/AccessRecords.jsx
Normal file
@@ -0,0 +1,445 @@
|
||||
// src/components/AccessRecords.jsx
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
fetchData,
|
||||
createData,
|
||||
updateData,
|
||||
deleteData
|
||||
} from '../api';
|
||||
|
||||
export default function AccessRecords() {
|
||||
const [records, setRecords] = useState([]);
|
||||
const [userRoles, setUserRoles] = useState([]);
|
||||
const [levels, setLevels] = useState([]);
|
||||
const [systems, setSystems] = useState([]);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
|
||||
// --- FILTER STATE ---
|
||||
const [filters, setFilters] = useState({
|
||||
userName: '',
|
||||
deptRole: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: ''
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
UserRoleId: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: 'No'
|
||||
});
|
||||
const [editId, setEditId] = useState(null);
|
||||
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'ascending' });
|
||||
|
||||
// Autocomplete for User/Dept/Role
|
||||
const [userRoleInput, setUserRoleInput] = useState('');
|
||||
const [filteredUserRoles, setFilteredUserRoles] = useState([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const userRoleInputRef = useRef();
|
||||
|
||||
// Load all data
|
||||
const loadAll = async () => {
|
||||
const [userRolesData, levelsData, recordsData] = await Promise.all([
|
||||
fetchData('userroles'),
|
||||
fetchData('accesslevels'),
|
||||
fetchData('accessrecords')
|
||||
]);
|
||||
setUserRoles(userRolesData);
|
||||
setLevels(levelsData);
|
||||
setRecords(recordsData);
|
||||
setSystems(Array.from(new Set(recordsData.map(r => r.system_name))));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
// Update filtered user roles when userRoles changes
|
||||
useEffect(() => {
|
||||
setFilteredUserRoles(userRoles);
|
||||
}, [userRoles]);
|
||||
|
||||
// --- AUTOCOMPLETE HANDLERS FOR USER/DEPT/ROLE ---
|
||||
const handleUserRoleInput = (e) => {
|
||||
const val = e.target.value;
|
||||
setUserRoleInput(val);
|
||||
setForm(f => ({ ...f, UserRoleId: '' })); // reset selection if typing
|
||||
setDropdownOpen(true);
|
||||
if (!val) {
|
||||
setFilteredUserRoles(userRoles);
|
||||
return;
|
||||
}
|
||||
const lowerVal = val.toLowerCase();
|
||||
setFilteredUserRoles(userRoles.filter(ur => {
|
||||
const display = `${ur.userName} — ${ur.department} / ${ur.role}`;
|
||||
return display.toLowerCase().includes(lowerVal);
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUserRoleSelect = (ur) => {
|
||||
setForm({ ...form, UserRoleId: ur.UserRoleId });
|
||||
setUserRoleInput(`${ur.userName} — ${ur.department} / ${ur.role}`);
|
||||
setDropdownOpen(false);
|
||||
setFilteredUserRoles(userRoles);
|
||||
};
|
||||
|
||||
const handleUserRoleFocus = () => {
|
||||
setDropdownOpen(true);
|
||||
if (!userRoleInput) setFilteredUserRoles(userRoles);
|
||||
};
|
||||
|
||||
const handleUserRoleBlur = () => {
|
||||
setTimeout(() => setDropdownOpen(false), 100); // allow onMouseDown to finish
|
||||
// Restore value if selected, or clear input
|
||||
if (!form.UserRoleId) {
|
||||
setUserRoleInput('');
|
||||
} else {
|
||||
const sel = userRoles.find(ur => ur.UserRoleId === form.UserRoleId);
|
||||
if (sel) setUserRoleInput(`${sel.userName} — ${sel.department} / ${sel.role}`);
|
||||
}
|
||||
setFilteredUserRoles(userRoles);
|
||||
};
|
||||
|
||||
// --- FILTER HANDLERS ---
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(current => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const clearFilters = () => setFilters({
|
||||
userName: '',
|
||||
deptRole: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: ''
|
||||
});
|
||||
|
||||
const applyFilters = rec => {
|
||||
const deptRole = `${rec.department} / ${rec.role}`;
|
||||
return (
|
||||
(!filters.userName || rec.userName.toLowerCase().includes(filters.userName.toLowerCase())) &&
|
||||
(!filters.deptRole || deptRole.toLowerCase().includes(filters.deptRole.toLowerCase())) &&
|
||||
(!filters.system_name || rec.system_name.toLowerCase().includes(filters.system_name.toLowerCase())) &&
|
||||
(!filters.access_level || rec.access_level.toLowerCase().includes(filters.access_level.toLowerCase())) &&
|
||||
(!filters.local_account || (rec.local_account || '').toLowerCase().includes(filters.local_account.toLowerCase())) &&
|
||||
(!filters.additional_access || rec.additional_access.toLowerCase().includes(filters.additional_access.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
const filteredRecords = useMemo(() => records.filter(applyFilters), [records, filters]);
|
||||
|
||||
const sortedRecords = useMemo(() => {
|
||||
const sortableItems = [...filteredRecords];
|
||||
if (sortConfig.key) {
|
||||
sortableItems.sort((a, b) => {
|
||||
let aVal = sortConfig.key === 'deptRole'
|
||||
? `${a.department} / ${a.role}`
|
||||
: a[sortConfig.key] || '';
|
||||
let bVal = sortConfig.key === 'deptRole'
|
||||
? `${b.department} / ${b.role}`
|
||||
: b[sortConfig.key] || '';
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
const comp = aVal.localeCompare(bVal);
|
||||
return sortConfig.direction === 'ascending' ? comp : -comp;
|
||||
}
|
||||
if (aVal < bVal) return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||
if (aVal > bVal) return sortConfig.direction === 'ascending' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return sortableItems;
|
||||
}, [filteredRecords, sortConfig]);
|
||||
|
||||
const requestSort = key => {
|
||||
let direction = 'ascending';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'ascending') {
|
||||
direction = 'descending';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!form.UserRoleId) return; // require a valid selection
|
||||
if (editId) await updateData('accessrecords', editId, form);
|
||||
else await createData('accessrecords', form);
|
||||
setForm({ UserRoleId: '', system_name: '', access_level: '', local_account: '', additional_access: 'No' });
|
||||
setUserRoleInput('');
|
||||
setEditId(null);
|
||||
setSuggestions([]);
|
||||
setFilteredUserRoles(userRoles);
|
||||
setDropdownOpen(false);
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const startEdit = rec => {
|
||||
setEditId(rec.RecordId);
|
||||
setForm({
|
||||
UserRoleId: rec.UserRoleId ? String(rec.UserRoleId) : '',
|
||||
system_name: rec.system_name,
|
||||
access_level: rec.access_level,
|
||||
local_account: rec.local_account || '',
|
||||
additional_access: rec.additional_access
|
||||
});
|
||||
const sel = userRoles.find(ur => ur.UserRoleId === rec.UserRoleId);
|
||||
if (sel) setUserRoleInput(`${sel.userName} — ${sel.department} / ${sel.role}`);
|
||||
setSuggestions([]);
|
||||
setFilteredUserRoles(userRoles);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async rec => {
|
||||
if (!window.confirm(`Delete record #${rec.RecordId}?`)) return;
|
||||
await deleteData('accessrecords', rec.RecordId);
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleSystemChange = e => {
|
||||
const val = e.target.value;
|
||||
setForm({ ...form, system_name: val });
|
||||
if (val) {
|
||||
const matches = systems.filter(s => s.toLowerCase().includes(val.toLowerCase()));
|
||||
setSuggestions(matches);
|
||||
} else setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleSystemKeyDown = e => {
|
||||
if (e.key === 'Tab' && suggestions.length) {
|
||||
e.preventDefault();
|
||||
setForm({ ...form, system_name: suggestions[0] });
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIndicator = key =>
|
||||
sortConfig.key === key ? (sortConfig.direction === 'ascending' ? ' ▲' : ' ▼') : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Access Records</h2>
|
||||
<form onSubmit={handleSubmit} className="mb-4">
|
||||
{/* First row: UserRole (autocomplete), System, Access Level */}
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-md-4 position-relative">
|
||||
<input
|
||||
ref={userRoleInputRef}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Select User/Dept/Role…"
|
||||
value={userRoleInput}
|
||||
onChange={handleUserRoleInput}
|
||||
onFocus={handleUserRoleFocus}
|
||||
onBlur={handleUserRoleBlur}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
{dropdownOpen && filteredUserRoles.length > 0 && userRoleInput && (
|
||||
<ul className="list-group position-absolute w-100" style={{ zIndex: 1000, maxHeight: 200, overflowY: 'auto' }}>
|
||||
{filteredUserRoles.map(ur => (
|
||||
<li
|
||||
key={ur.UserRoleId}
|
||||
className="list-group-item list-group-item-action"
|
||||
onMouseDown={() => handleUserRoleSelect(ur)}
|
||||
>
|
||||
{ur.userName} — {ur.department} / {ur.role}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-4 position-relative">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="System Name"
|
||||
value={form.system_name}
|
||||
onChange={handleSystemChange}
|
||||
onKeyDown={handleSystemKeyDown}
|
||||
onBlur={() => setTimeout(() => setSuggestions([]), 100)}
|
||||
required
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<ul className="list-group position-absolute w-100" style={{ zIndex: 1000 }}>
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="list-group-item list-group-item-action"
|
||||
onMouseDown={() => {
|
||||
setForm({ ...form, system_name: s });
|
||||
setSuggestions([]);
|
||||
}}
|
||||
>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.access_level}
|
||||
onChange={e => setForm({ ...form, access_level: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Access Level…</option>
|
||||
{levels.map(l => (
|
||||
<option key={l.access_level} value={l.access_level}>{l.access_level}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second row: Local Account, Additional, Update/Cancel */}
|
||||
<div className="row g-3 align-items-end">
|
||||
<div className="col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Local Account"
|
||||
value={form.local_account}
|
||||
onChange={e => setForm({ ...form, local_account: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.additional_access}
|
||||
onChange={e => setForm({ ...form, additional_access: e.target.value })}
|
||||
>
|
||||
<option value="No">Additional: No</option>
|
||||
<option value="Yes">Additional: Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-success">
|
||||
{editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{editId && (
|
||||
<div className="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setEditId(null);
|
||||
setForm({ UserRoleId: '', system_name: '', access_level: '', local_account: '', additional_access: 'No' });
|
||||
setUserRoleInput('');
|
||||
setSuggestions([]);
|
||||
setFilteredUserRoles(userRoles);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table className="table table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
User{getSortIndicator('userName')}
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.userName}
|
||||
onChange={e => handleFilterChange('userName', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Dept / Role{getSortIndicator('deptRole')}
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.deptRole}
|
||||
onChange={e => handleFilterChange('deptRole', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
System{getSortIndicator('system_name')}
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.system_name}
|
||||
onChange={e => handleFilterChange('system_name', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Access Level{getSortIndicator('access_level')}
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.access_level}
|
||||
onChange={e => handleFilterChange('access_level', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Local Account{getSortIndicator('local_account')}
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.local_account}
|
||||
onChange={e => handleFilterChange('local_account', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Additional?{getSortIndicator('additional_access')}
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.additional_access}
|
||||
onChange={e => handleFilterChange('additional_access', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ width: '150px' }}>
|
||||
Actions
|
||||
<br />
|
||||
<button className="btn btn-secondary btn-sm mt-1" onClick={clearFilters}>
|
||||
Clear
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRecords.map(rec => (
|
||||
<tr key={rec.RecordId}>
|
||||
<td>{rec.userName}</td>
|
||||
<td>{rec.department} / {rec.role}</td>
|
||||
<td>{rec.system_name}</td>
|
||||
<td>{rec.access_level}</td>
|
||||
<td>{rec.local_account || '—'}</td>
|
||||
<td>{rec.additional_access}</td>
|
||||
<td>
|
||||
<button className="btn btn-primary btn-sm me-1" onClick={() => startEdit(rec)}>Edit</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(rec)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedRecords.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center">No records found.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
169
frontend/src/components/DepartmentRoles.jsx
Normal file
169
frontend/src/components/DepartmentRoles.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
// src/components/DepartmentRoles.jsx
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { fetchData, createData, updateData, deleteData } from '../api';
|
||||
|
||||
export default function DepartmentRoles() {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [form, setForm] = useState({ department: '', role: '' });
|
||||
const [editId, setEditId] = useState(null);
|
||||
|
||||
// FILTERS
|
||||
const [filters, setFilters] = useState({ department: '', role: '' });
|
||||
|
||||
const load = async () => {
|
||||
const data = await fetchData('departmentroles');
|
||||
setRoles(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const submit = async e => {
|
||||
e.preventDefault();
|
||||
if (editId) {
|
||||
await updateData('departmentroles', editId, form);
|
||||
setEditId(null);
|
||||
} else {
|
||||
await createData('departmentroles', form);
|
||||
}
|
||||
setForm({ department: '', role: '' });
|
||||
load();
|
||||
};
|
||||
|
||||
const startEdit = r => {
|
||||
setEditId(r.DepartmentRoleId);
|
||||
setForm({ department: r.department, role: r.role });
|
||||
};
|
||||
|
||||
const doDelete = id => {
|
||||
if (!window.confirm('Delete?')) return;
|
||||
deleteData('departmentroles', id).then(load);
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(f => ({ ...f, [field]: value }));
|
||||
};
|
||||
|
||||
// Apply filters to roles list
|
||||
const filteredRoles = useMemo(() => {
|
||||
return roles.filter(r =>
|
||||
(!filters.department || r.department.toLowerCase().includes(filters.department.toLowerCase())) &&
|
||||
(!filters.role || r.role.toLowerCase().includes(filters.role.toLowerCase()))
|
||||
);
|
||||
}, [roles, filters]);
|
||||
|
||||
// Reset filters
|
||||
const clearFilters = () => setFilters({ department: '', role: '' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Department Roles</h2>
|
||||
<form className="row g-3 mb-3" onSubmit={submit}>
|
||||
<div className="col">
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Department"
|
||||
value={form.department}
|
||||
onChange={e => setForm({ ...form, department: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col">
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Role"
|
||||
value={form.role}
|
||||
onChange={e => setForm({ ...form, role: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-success">
|
||||
{editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{editId && (
|
||||
<div className="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setEditId(null);
|
||||
setForm({ department: '', role: '' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Department
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.department}
|
||||
onChange={e => handleFilterChange('department', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.role}
|
||||
onChange={e => handleFilterChange('role', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
<br />
|
||||
<button className="btn btn-secondary btn-sm mt-1" onClick={clearFilters}>
|
||||
Clear
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRoles.map(r => (
|
||||
<tr key={r.DepartmentRoleId}>
|
||||
<td>{r.department}</td>
|
||||
<td>{r.role}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-primary me-2"
|
||||
onClick={() => startEdit(r)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => doDelete(r.DepartmentRoleId)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredRoles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="3" className="text-center">
|
||||
No department roles found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
54
frontend/src/components/Login.jsx
Normal file
54
frontend/src/components/Login.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/components/Login.jsx
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login } from '../api';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [u, setU] = useState('');
|
||||
const [p, setP] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const tok = await login(u, p);
|
||||
onLogin(tok); // store token
|
||||
navigate('/departmentroles', { replace: true });
|
||||
} catch {
|
||||
setErr('Invalid credentials');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-4">
|
||||
<h2>Admin Login</h2>
|
||||
{err && <div className="alert alert-danger">{err}</div>}
|
||||
<form onSubmit={submit}>
|
||||
<div className="mb-3">
|
||||
<label>Username</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={u}
|
||||
onChange={e => setU(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={p}
|
||||
onChange={e => setP(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
47
frontend/src/components/NavTabs.jsx
Normal file
47
frontend/src/components/NavTabs.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// src/components/NavTabs.jsx
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
export default function NavTabs() {
|
||||
return (
|
||||
<nav className="nav nav-tabs my-3">
|
||||
<NavLink
|
||||
to="accesslevels"
|
||||
className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')}
|
||||
>
|
||||
Access Levels
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="departmentroles"
|
||||
className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')}
|
||||
>
|
||||
Dept Roles
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="users"
|
||||
className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')}
|
||||
>
|
||||
Users
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="userroles"
|
||||
className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')}
|
||||
>
|
||||
User Roles
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="accessrecords"
|
||||
className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')}
|
||||
>
|
||||
Access Records
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="report"
|
||||
className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')}
|
||||
>
|
||||
Report & Export
|
||||
</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
208
frontend/src/components/Report.jsx
Normal file
208
frontend/src/components/Report.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
// src/components/Report.jsx
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { fetchData } from '../api';
|
||||
|
||||
export default function Report() {
|
||||
const [data, setData] = useState([]);
|
||||
const [filters, setFilters] = useState({
|
||||
userName: '',
|
||||
department: '',
|
||||
role: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: ''
|
||||
});
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'ascending' });
|
||||
|
||||
const load = async () => {
|
||||
const rows = await fetchData('accessrecords');
|
||||
setData(rows);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(current => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
userName: '',
|
||||
department: '',
|
||||
role: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: ''
|
||||
});
|
||||
setSortConfig({ key: null, direction: 'ascending' });
|
||||
};
|
||||
|
||||
// UPDATED: skip empty filters and treat null/undefined as ''
|
||||
const applyFilters = row =>
|
||||
Object.entries(filters).every(([key, val]) => {
|
||||
if (!val) return true; // don’t filter when empty
|
||||
const cell = (key === 'department' || key === 'role')
|
||||
? row[key] ?? ''
|
||||
: row[key] ?? '';
|
||||
return cell
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes(val.toLowerCase());
|
||||
});
|
||||
|
||||
const filtered = data.filter(applyFilters);
|
||||
|
||||
const requestSort = key => {
|
||||
let direction = 'ascending';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'ascending') {
|
||||
direction = 'descending';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
const sortableItems = [...filtered];
|
||||
if (sortConfig.key) {
|
||||
sortableItems.sort((a, b) => {
|
||||
const aVal = (a[sortConfig.key] ?? '').toString();
|
||||
const bVal = (b[sortConfig.key] ?? '').toString();
|
||||
const comp = aVal.localeCompare(bVal);
|
||||
return sortConfig.direction === 'ascending' ? comp : -comp;
|
||||
});
|
||||
}
|
||||
return sortableItems;
|
||||
}, [filtered, sortConfig]);
|
||||
|
||||
const getSortIndicator = key =>
|
||||
sortConfig.key === key ? (sortConfig.direction === 'ascending' ? ' ▲' : ' ▼') : '';
|
||||
|
||||
const exportCSV = () => {
|
||||
const header = ['User', 'Department', 'Role', 'System', 'Access Level', 'Local Account', 'Additional'];
|
||||
const rows = sortedData.map(r => [
|
||||
r.userName,
|
||||
r.department,
|
||||
r.role,
|
||||
r.system_name,
|
||||
r.access_level,
|
||||
r.local_account || '',
|
||||
r.additional_access
|
||||
]);
|
||||
const csvContent = [header, ...rows]
|
||||
.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'access_report.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Report & Export</h2>
|
||||
<button className="btn btn-secondary me-2" onClick={clearFilters}>
|
||||
Clear All Filters
|
||||
</button>
|
||||
<button className="btn btn-primary me-2" onClick={exportCSV}>
|
||||
Export to CSV
|
||||
</button>
|
||||
|
||||
<table className="table table-bordered table-hover mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onClick={() => requestSort('userName')} style={{ cursor: 'pointer' }}>
|
||||
User{getSortIndicator('userName')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.userName}
|
||||
onChange={e => handleFilterChange('userName', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('department')} style={{ cursor: 'pointer' }}>
|
||||
Department{getSortIndicator('department')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.department}
|
||||
onChange={e => handleFilterChange('department', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('role')} style={{ cursor: 'pointer' }}>
|
||||
Role{getSortIndicator('role')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.role}
|
||||
onChange={e => handleFilterChange('role', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('system_name')} style={{ cursor: 'pointer' }}>
|
||||
System{getSortIndicator('system_name')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.system_name}
|
||||
onChange={e => handleFilterChange('system_name', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('access_level')} style={{ cursor: 'pointer' }}>
|
||||
Access Level{getSortIndicator('access_level')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.access_level}
|
||||
onChange={e => handleFilterChange('access_level', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('local_account')} style={{ cursor: 'pointer' }}>
|
||||
Local Account{getSortIndicator('local_account')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.local_account}
|
||||
onChange={e => handleFilterChange('local_account', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('additional_access')} style={{ cursor: 'pointer' }}>
|
||||
Additional{getSortIndicator('additional_access')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.additional_access}
|
||||
onChange={e => handleFilterChange('additional_access', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map(r => (
|
||||
<tr key={r.RecordId}>
|
||||
<td>{r.userName}</td>
|
||||
<td>{r.department}</td>
|
||||
<td>{r.role}</td>
|
||||
<td>{r.system_name}</td>
|
||||
<td>{r.access_level}</td>
|
||||
<td>{r.local_account || '—'}</td>
|
||||
<td>{r.additional_access}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="7" className="text-center">
|
||||
No matching records.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
177
frontend/src/components/UserRoles.jsx
Normal file
177
frontend/src/components/UserRoles.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
// src/components/UserRoles.jsx
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { fetchData, createData, deleteData } from '../api';
|
||||
|
||||
export default function UserRoles() {
|
||||
const [assignments, setAssignments] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [form, setForm] = useState({ UserId: '', DepartmentRoleId: '' });
|
||||
const [filters, setFilters] = useState({
|
||||
userName: '',
|
||||
department: '',
|
||||
role: ''
|
||||
});
|
||||
|
||||
const loadAll = async () => {
|
||||
setUsers(await fetchData('users'));
|
||||
setRoles(await fetchData('departmentroles'));
|
||||
setAssignments(await fetchData('userroles'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
await createData('userroles', form);
|
||||
setForm({ UserId: '', DepartmentRoleId: '' });
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleDelete = async id => {
|
||||
if (!window.confirm('Remove this assignment?')) return;
|
||||
await deleteData('userroles', id);
|
||||
loadAll();
|
||||
};
|
||||
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(f => ({ ...f, [field]: value }));
|
||||
};
|
||||
|
||||
const filteredAssignments = useMemo(() => {
|
||||
return assignments.filter(a =>
|
||||
(!filters.userName || a.userName.toLowerCase().includes(filters.userName.toLowerCase())) &&
|
||||
(!filters.department || a.department.toLowerCase().includes(filters.department.toLowerCase())) &&
|
||||
(!filters.role || a.role.toLowerCase().includes(filters.role.toLowerCase()))
|
||||
);
|
||||
}, [assignments, filters]);
|
||||
|
||||
const clearFilters = () =>
|
||||
setFilters({ userName: '', department: '', role: '' });
|
||||
|
||||
// --- Sorting for select options ---
|
||||
const sortedUsers = [...users].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const sortedRoles = [...roles].sort((a, b) => {
|
||||
// Sort by department first, then by role
|
||||
const deptComp = a.department.localeCompare(b.department);
|
||||
if (deptComp !== 0) return deptComp;
|
||||
return a.role.localeCompare(b.role);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Assign Dept / Role to User</h2>
|
||||
|
||||
<form className="row g-3 mb-4" onSubmit={handleSubmit}>
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.UserId}
|
||||
onChange={e => setForm({ ...form, UserId: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Select User…</option>
|
||||
{sortedUsers.map(u => (
|
||||
<option key={u.UserId} value={u.UserId}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.DepartmentRoleId}
|
||||
onChange={e => setForm({ ...form, DepartmentRoleId: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">Select Dept / Role…</option>
|
||||
{sortedRoles.map(r => (
|
||||
<option key={r.DepartmentRoleId} value={r.DepartmentRoleId}>
|
||||
{r.department} / {r.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-md-2">
|
||||
<button className="btn btn-success w-100">Assign</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ...rest of your table code (unchanged)... */}
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
User
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.userName}
|
||||
onChange={e => handleFilterChange('userName', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Department
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.department}
|
||||
onChange={e => handleFilterChange('department', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Role
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.role}
|
||||
onChange={e => handleFilterChange('role', e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
<br />
|
||||
<button className="btn btn-secondary btn-sm mt-1" onClick={clearFilters}>
|
||||
Clear
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAssignments.map(a => (
|
||||
<tr key={a.UserRoleId}>
|
||||
<td>{a.userName}</td>
|
||||
<td>{a.department}</td>
|
||||
<td>{a.role}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(a.UserRoleId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredAssignments.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="4" className="text-center">No assignments.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
144
frontend/src/components/Users.jsx
Normal file
144
frontend/src/components/Users.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
// src/components/Users.jsx
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { fetchData, createData, updateData, deleteData } from '../api';
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [form, setForm] = useState({ name: '' });
|
||||
const [editId, setEditId] = useState(null);
|
||||
|
||||
// FILTER state
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
const data = await fetchData('users');
|
||||
setUsers(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (editId) {
|
||||
await updateData('users', editId, form);
|
||||
} else {
|
||||
await createData('users', form);
|
||||
}
|
||||
setForm({ name: '' });
|
||||
setEditId(null);
|
||||
load();
|
||||
};
|
||||
|
||||
const startEdit = (u) => {
|
||||
setEditId(u.UserId);
|
||||
setForm({ name: u.name });
|
||||
};
|
||||
|
||||
const handleDelete = async (u) => {
|
||||
if (!window.confirm(`Delete user "${u.name}"?`)) return;
|
||||
await deleteData('users', u.UserId);
|
||||
load();
|
||||
};
|
||||
|
||||
// FILTERED USERS
|
||||
const filteredUsers = useMemo(() => {
|
||||
return users.filter(u =>
|
||||
!filter || u.name.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
}, [users, filter]);
|
||||
|
||||
const clearFilter = () => setFilter('');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<form className="row g-3 mb-4" onSubmit={handleSubmit}>
|
||||
<div className="col-auto">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Name"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-success">
|
||||
{editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{editId && (
|
||||
<div className="col-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setEditId(null);
|
||||
setForm({ name: '' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Name
|
||||
<br />
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
<br />
|
||||
<button className="btn btn-secondary btn-sm mt-1" onClick={clearFilter}>
|
||||
Clear
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(u => (
|
||||
<tr key={u.UserId}>
|
||||
<td>{u.name}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-sm btn-primary me-2"
|
||||
onClick={() => startEdit(u)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(u)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="2" className="text-center">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
223
frontend/src/components/bkup_Report.jsx
Normal file
223
frontend/src/components/bkup_Report.jsx
Normal file
@@ -0,0 +1,223 @@
|
||||
// src/components/Report.jsx
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { fetchData } from '../api';
|
||||
|
||||
export default function Report() {
|
||||
const [data, setData] = useState([]);
|
||||
const [filters, setFilters] = useState({
|
||||
userName: '',
|
||||
department: '',
|
||||
role: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: ''
|
||||
});
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'ascending' });
|
||||
|
||||
const load = async () => {
|
||||
const rows = await fetchData('accessrecords');
|
||||
setData(rows);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters(current => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
userName: '',
|
||||
department: '',
|
||||
role: '',
|
||||
system_name: '',
|
||||
access_level: '',
|
||||
local_account: '',
|
||||
additional_access: ''
|
||||
});
|
||||
setSortConfig({ key: null, direction: 'ascending' });
|
||||
};
|
||||
|
||||
// UPDATED: skip empty filters and treat null/undefined as ''
|
||||
const applyFilters = row =>
|
||||
Object.entries(filters).every(([key, val]) => {
|
||||
if (!val) return true; // don’t filter when empty
|
||||
const cell = (key === 'department' || key === 'role')
|
||||
? row[key] ?? ''
|
||||
: row[key] ?? '';
|
||||
return cell
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes(val.toLowerCase());
|
||||
});
|
||||
|
||||
const filtered = data.filter(applyFilters);
|
||||
|
||||
const requestSort = key => {
|
||||
let direction = 'ascending';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'ascending') {
|
||||
direction = 'descending';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
const sortableItems = [...filtered];
|
||||
if (sortConfig.key) {
|
||||
sortableItems.sort((a, b) => {
|
||||
const aVal = (a[sortConfig.key] ?? '').toString();
|
||||
const bVal = (b[sortConfig.key] ?? '').toString();
|
||||
const comp = aVal.localeCompare(bVal);
|
||||
return sortConfig.direction === 'ascending' ? comp : -comp;
|
||||
});
|
||||
}
|
||||
return sortableItems;
|
||||
}, [filtered, sortConfig]);
|
||||
|
||||
const getSortIndicator = key =>
|
||||
sortConfig.key === key ? (sortConfig.direction === 'ascending' ? ' ▲' : ' ▼') : '';
|
||||
|
||||
const exportCSV = () => {
|
||||
const header = ['User', 'Department', 'Role', 'System', 'Access Level', 'Local Account', 'Additional'];
|
||||
const rows = sortedData.map(r => [
|
||||
r.userName,
|
||||
r.department,
|
||||
r.role,
|
||||
r.system_name,
|
||||
r.access_level,
|
||||
r.local_account || '',
|
||||
r.additional_access
|
||||
]);
|
||||
const csvContent = [header, ...rows]
|
||||
.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'access_report.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// --- NEW: Export to JSON ---
|
||||
const exportJSON = () => {
|
||||
const jsonContent = JSON.stringify(sortedData, null, 2);
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'access_report.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Report & Export</h2>
|
||||
<button className="btn btn-secondary me-2" onClick={clearFilters}>
|
||||
Clear All Filters
|
||||
</button>
|
||||
<button className="btn btn-primary me-2" onClick={exportCSV}>
|
||||
Export to CSV
|
||||
</button>
|
||||
<button className="btn btn-outline-primary" onClick={exportJSON}>
|
||||
Export to JSON
|
||||
</button>
|
||||
|
||||
<table className="table table-bordered table-hover mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onClick={() => requestSort('userName')} style={{ cursor: 'pointer' }}>
|
||||
User{getSortIndicator('userName')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.userName}
|
||||
onChange={e => handleFilterChange('userName', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('department')} style={{ cursor: 'pointer' }}>
|
||||
Department{getSortIndicator('department')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.department}
|
||||
onChange={e => handleFilterChange('department', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('role')} style={{ cursor: 'pointer' }}>
|
||||
Role{getSortIndicator('role')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.role}
|
||||
onChange={e => handleFilterChange('role', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('system_name')} style={{ cursor: 'pointer' }}>
|
||||
System{getSortIndicator('system_name')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.system_name}
|
||||
onChange={e => handleFilterChange('system_name', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('access_level')} style={{ cursor: 'pointer' }}>
|
||||
Access Level{getSortIndicator('access_level')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.access_level}
|
||||
onChange={e => handleFilterChange('access_level', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('local_account')} style={{ cursor: 'pointer' }}>
|
||||
Local Account{getSortIndicator('local_account')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.local_account}
|
||||
onChange={e => handleFilterChange('local_account', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
<th onClick={() => requestSort('additional_access')} style={{ cursor: 'pointer' }}>
|
||||
Additional{getSortIndicator('additional_access')}<br/>
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Filter"
|
||||
value={filters.additional_access}
|
||||
onChange={e => handleFilterChange('additional_access', e.target.value)}
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map(r => (
|
||||
<tr key={r.RecordId}>
|
||||
<td>{r.userName}</td>
|
||||
<td>{r.department}</td>
|
||||
<td>{r.role}</td>
|
||||
<td>{r.system_name}</td>
|
||||
<td>{r.access_level}</td>
|
||||
<td>{r.local_account || '—'}</td>
|
||||
<td>{r.additional_access}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="7" className="text-center">
|
||||
No matching records.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
68
frontend/src/index.css
Normal file
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
11
frontend/src/main.jsx
Normal file
11
frontend/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM
|
||||
.createRoot(document.getElementById('root'))
|
||||
.render(
|
||||
<App />
|
||||
);
|
||||
|
||||
19
frontend/trackaccess.crt
Normal file
19
frontend/trackaccess.crt
Normal file
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDKTCCAhGgAwIBAgIUFcHUJx4e+HvaUh+Z+TJZS9EpYDAwDQYJKoZIhvcNAQEL
|
||||
BQAwJDEiMCAGA1UEAwwZdHJhY2thY2Nlc3MuZ2FsbGF1ZGV0LmVkdTAeFw0yNTA1
|
||||
MDcxNjU0MDVaFw0zNTA1MDUxNjU0MDVaMCQxIjAgBgNVBAMMGXRyYWNrYWNjZXNz
|
||||
LmdhbGxhdWRldC5lZHUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE
|
||||
stmrvoh0Z+Zc9taqc8767R2yMCqphA3pgpZ6u5B5iSCKSTvoWPBLVuvl/p/IkB7W
|
||||
eYarK2Xq31q/GCslLKn8tBBhfeK09DFn/u0RLWazawMBkrxyodoTE8jKoxCZB4OZ
|
||||
Ho6ZEY388/URhVH5+1UabOVf6q/L9D/mQKsvc6o/M2fKGYXY6pdLXLtHOTtF8u6x
|
||||
XLG2xPov23Dj44Wh9Q0xKfOF7T7knoggiWNww4NbnCscrS5U9GEH0Cb/DzaEP3e/
|
||||
l8iA4SeuoWnmtcHpMvriv5d2cEzEH0f6xGboKkMqnPy5jVr98G0kFqpkySpxEFO+
|
||||
1Ux50P5EhNPcaF7fqRExAgMBAAGjUzBRMB0GA1UdDgQWBBRlEyP0nTEOGUUtwZgG
|
||||
BI3sBFdX8TAfBgNVHSMEGDAWgBRlEyP0nTEOGUUtwZgGBI3sBFdX8TAPBgNVHRMB
|
||||
Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCWsSuRaYWuI73seykZgPGXe/IL
|
||||
12JhDTFqcfFZaTrmfmAZ8yTVYF7r7zdNb+HgHWUPFOEFQ337RNyndpOGeMaTxvbb
|
||||
a0xKnjC94cbuaiYfAUo3dBY0Ln7E7xCRUTYPoAzL/1Z+QhAQwxiP11LHvClMMxaH
|
||||
eszrXYwtGB5yzKSjg1K33uZ/oXpZ5AyiKn72nXKRJZ8St7Y1VDLobWmkhYSCXqda
|
||||
6DslYiwh3ufoOHXJIFhSK3U+kmhz05DuSk2bM9YVj3D+Nu4OArj1FUJlBS77KmKK
|
||||
Sy9TEBYe8DGtzwJb3sAzRPvJSFApdxcIODkqagytHnJtlezW2lMYDZMTGwkv
|
||||
-----END CERTIFICATE-----
|
||||
28
frontend/trackaccess.key
Normal file
28
frontend/trackaccess.key
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEstmrvoh0Z+Zc
|
||||
9taqc8767R2yMCqphA3pgpZ6u5B5iSCKSTvoWPBLVuvl/p/IkB7WeYarK2Xq31q/
|
||||
GCslLKn8tBBhfeK09DFn/u0RLWazawMBkrxyodoTE8jKoxCZB4OZHo6ZEY388/UR
|
||||
hVH5+1UabOVf6q/L9D/mQKsvc6o/M2fKGYXY6pdLXLtHOTtF8u6xXLG2xPov23Dj
|
||||
44Wh9Q0xKfOF7T7knoggiWNww4NbnCscrS5U9GEH0Cb/DzaEP3e/l8iA4SeuoWnm
|
||||
tcHpMvriv5d2cEzEH0f6xGboKkMqnPy5jVr98G0kFqpkySpxEFO+1Ux50P5EhNPc
|
||||
aF7fqRExAgMBAAECggEAI54cPtZ1WJjQ1MAoznxCfFsP/8priz8bks9gBtDsgCl1
|
||||
0DyjIbdrKcVPWZoiHlwEjYM7gMbOwXlY8hYDCAv+qwEDH1g/g1Ndl+6ISI4/VNlZ
|
||||
lcEXqS1IMyCnwRPGh2NeL82h9jNA0g9t7tKErd7WQf76iQQS+bxcjsnihajs6N0J
|
||||
kJ04x1RnIyEAkA3uWR4N/I21DwD9Wje5G8G6sA+L2nmiPacoyYeFEyYrQ5wtxa96
|
||||
Y6xw5DwT1gf/5MKs7Gc3BlnO54bC+Librf7d7AlwyXWRb3H7KFA4g6a+nsE6+N9k
|
||||
Zh0CVHSxMkI/+jdIfXDg+qJtK+nRHdM0eQi36Ve/rQKBgQDmLztnR7qfCskW8WXf
|
||||
EomAsgD6fPUg+L6TFSPFt0J0mIt5GMBAjjAj4QCPktWI5+1B4z+GFANTGRO49wcb
|
||||
FszfRDVTSwGsEijhN+y3M8VSC7O+yLfBQepsE87Drm4pZneKSeOvz/coGurfF3bf
|
||||
o8pdYz+uFtGn3jOC1Iemo6sH1QKBgQDawjbR/ayfxMsySHN9s3h2jch1Jtab6J25
|
||||
mBbSHKR6pJ0EeFopPt+WmVKTcm1Jsa5b/UXuWReUl9/pdIgkxDkqzaGgOR8SWeSC
|
||||
WWGA51PJqfgoqsy3yh6JJOG1xEMt8Gz5THQyyCHHT14QAm8ihwxufeWOze4QJyMe
|
||||
fkbf+rkN7QKBgQCtASjrqjy4lpmnFc5USBFy2dbkbZCrtGlAEO5vBxr6mUCSxqiQ
|
||||
nI3QGaebQWge2vo2wD8ZXedVyI5LQddkY9GdqR1POhvKoWd6RtcypsWSsdrp9OAv
|
||||
b4RqsMSBzJNdqHcGSBzKIkuIKBsJjBA7bFaHtDXDecEgI2Ch320JMRA19QKBgQCp
|
||||
lz0WqLnGFrOMpNxcC+GMzzgjklt3/Osh8cVnWGsu5SURTRhgt2xw/SYmRuRw6D2K
|
||||
9RvcvtboDKG7A+tzzWegRlBRvVbYTDY5038ihrPPOGS5akhRB3GK0rvkxWVrXOOu
|
||||
lVXT9JEzSdVbRffQZa/+jL2FayJvBVhVkIHzrBUAtQKBgQCBnpSB/XL//xTjsSVH
|
||||
aOC7vCOjs4ZkO51a9yMGP1ldETXuS3kXaZ+occ3FJfyY0p57U07UzEIR2lsXjqDf
|
||||
dOI3cFDU1sGyhdr1Bnxl9OGs/E3OM26oRgs0C8rFhqsXI5u977bp+/OdZtJt1Mk3
|
||||
BzBZ0ym7Hsr8jTInkRQhvusoVw==
|
||||
-----END PRIVATE KEY-----
|
||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// frontend/vite.config.js
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: ('trackaccess.gallaudet.edu', 'localhost'), // bind 0.0.0.0 so external hosts can reach it
|
||||
port: 8080,
|
||||
strictPort: true, // fail if 5173 in use
|
||||
https: {
|
||||
key: fs.readFileSync('trackaccess.key'),
|
||||
cert: fs.readFileSync('trackaccess.crt'),
|
||||
},
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4000'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
73
nginx.conf
Normal file
73
nginx.conf
Normal file
@@ -0,0 +1,73 @@
|
||||
# /etc/nginx/sites-available/trackaccess
|
||||
|
||||
# 1) Redirect plain‐HTTP → HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name trackaccess.gallaudet.edu;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# 2) HTTPS server block
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name trackaccess.gallaudet.edu;
|
||||
|
||||
# SSL certs (self-signed)
|
||||
ssl_certificate /etc/ssl/certs/trackaccess.crt;
|
||||
ssl_certificate_key /etc/ssl/private/trackaccess.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Serve React build
|
||||
root /var/www/trackaccess/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# All non‐/api requests → React SPA
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API calls
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Preserve WebSocket upgrades
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
|
||||
# Forward real IP & host
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Optional: serve favicon/js/css from cache
|
||||
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg)$ {
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
|
||||
location /phpmyadmin {
|
||||
alias /usr/share/phpmyadmin;
|
||||
index index.php index.html index.htm;
|
||||
|
||||
location ~ ^/phpmyadmin/(.+\.php)$ {
|
||||
alias /usr/share/phpmyadmin/$1;
|
||||
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; # Adjust if using different PHP version
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/share/phpmyadmin/$1;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~* ^/phpmyadmin/(.+\.(jpg|jpeg|gif|css|png|js|ico|html|xml|txt))$ {
|
||||
alias /usr/share/phpmyadmin/$1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user