This commit is contained in:
+65
-3
@@ -1,8 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import ViolationForm from './components/ViolationForm';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ReadmeModal from './components/ReadmeModal';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import UserManagementModal from './components/UserManagementModal';
|
||||
import ToastProvider from './components/ToastProvider';
|
||||
import { getToken, clearToken, setUnauthorizedHandler } from './auth';
|
||||
import './styles/mobile.css';
|
||||
|
||||
const REPO_URL = 'https://git.alwisp.com/jason/cpas';
|
||||
@@ -146,6 +150,28 @@ const s = {
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
navBtn: {
|
||||
background: 'none',
|
||||
border: '1px solid #2a2b3a',
|
||||
color: '#9ca0b8',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 14px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
userBadge: {
|
||||
fontSize: '12px',
|
||||
color: '#c0c2d6',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
},
|
||||
main: { flex: 1 },
|
||||
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
|
||||
};
|
||||
@@ -229,7 +255,10 @@ const sf = {
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [showReadme, setShowReadme] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
const [version, setVersion] = useState(null);
|
||||
const [user, setUser] = useState(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,6 +268,25 @@ export default function App() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Validate any stored session on load; fall back to the login screen on 401.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
if (!getToken()) { setAuthChecked(true); return; }
|
||||
axios.get('/api/auth/me')
|
||||
.then(r => setUser(r.data.user))
|
||||
.catch(() => clearToken())
|
||||
.finally(() => setAuthChecked(true));
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try { await axios.post('/api/auth/logout'); } catch { /* token already gone */ }
|
||||
clearToken();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
if (!authChecked) return null;
|
||||
if (!user) return <LoginModal onSuccess={setUser} />;
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{/* TODO [MAJOR #9]: Inline <style> tags re-inject on every render and duplicate
|
||||
@@ -260,9 +308,22 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button style={s.docsBtn} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
|
||||
<span>?</span> Docs
|
||||
</button>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={s.userBadge} title={`Signed in as ${user.username} (${user.role})`}>
|
||||
👤 {user.username}
|
||||
</span>
|
||||
{user.role === 'admin' && (
|
||||
<button style={s.navBtn} onClick={() => setShowUsers(true)} title="Manage user accounts">
|
||||
Users
|
||||
</button>
|
||||
)}
|
||||
<button style={{ ...s.docsBtn, marginLeft: 0 }} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
|
||||
<span>?</span> Docs
|
||||
</button>
|
||||
<button style={s.navBtn} onClick={handleLogout} title="Sign out">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div style={s.main}>
|
||||
@@ -274,6 +335,7 @@ export default function App() {
|
||||
<AppFooter version={version} />
|
||||
|
||||
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
|
||||
{showUsers && <UserManagementModal currentUser={user} onClose={() => setShowUsers(false)} />}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'cpas_token';
|
||||
|
||||
export function getToken() { return localStorage.getItem(TOKEN_KEY); }
|
||||
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t); }
|
||||
export function clearToken() { localStorage.removeItem(TOKEN_KEY); }
|
||||
|
||||
// Attach the session token to every outgoing request.
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
// When the server rejects a token (expired / invalid), drop it and notify the
|
||||
// app so it can fall back to the login screen.
|
||||
let onUnauthorized = null;
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response && err.response.status === 401) {
|
||||
clearToken();
|
||||
if (onUnauthorized) onUnauthorized();
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { setToken } from '../auth';
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: '#050608',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 3000, fontFamily: "'Segoe UI', Arial, sans-serif",
|
||||
},
|
||||
modal: {
|
||||
width: '380px', maxWidth: '92vw', background: '#111217', borderRadius: '12px',
|
||||
boxShadow: '0 16px 40px rgba(0,0,0,0.8)', color: '#f8f9fa',
|
||||
overflow: 'hidden', border: '1px solid #2a2b3a',
|
||||
},
|
||||
header: {
|
||||
padding: '24px', borderBottom: '1px solid #222', textAlign: 'center',
|
||||
background: 'linear-gradient(135deg, #000000, #151622)',
|
||||
},
|
||||
logo: { height: '34px', marginBottom: '12px' },
|
||||
title: { fontSize: '18px', fontWeight: 800, letterSpacing: '0.5px' },
|
||||
subtitle: { fontSize: '12px', color: '#c0c2d6', marginTop: '4px' },
|
||||
body: { padding: '22px 24px 8px 24px' },
|
||||
label: { fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: '#e5e7f1' },
|
||||
input: {
|
||||
width: '100%', padding: '10px 12px', borderRadius: '6px',
|
||||
border: '1px solid #333544', background: '#050608', color: '#f8f9fa',
|
||||
fontSize: '14px', fontFamily: 'inherit', marginBottom: '16px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
error: {
|
||||
background: '#3a1414', borderRadius: '6px', padding: '9px 11px',
|
||||
fontSize: '12px', color: '#ff9b9b', border: '1px solid #c0392b', marginBottom: '14px',
|
||||
},
|
||||
footer: { padding: '0 24px 22px 24px' },
|
||||
btn: {
|
||||
width: '100%', padding: '11px', borderRadius: '6px', border: 'none',
|
||||
background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)',
|
||||
color: '#000', fontWeight: 700, fontSize: '14px',
|
||||
cursor: 'pointer', textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
},
|
||||
};
|
||||
|
||||
export default function LoginModal({ onSuccess }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/login', { username, password });
|
||||
setToken(data.token);
|
||||
onSuccess(data.user);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Login failed. Please try again.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay}>
|
||||
<form style={s.modal} onSubmit={submit}>
|
||||
<div style={s.header}>
|
||||
<img src="/static/mpm-logo.png" alt="MPM" style={s.logo} />
|
||||
<div style={s.title}>CPAS Tracker</div>
|
||||
<div style={s.subtitle}>Sign in to continue</div>
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
{error && <div style={s.error}>{error}</div>}
|
||||
|
||||
<div style={s.label}>Username</div>
|
||||
<input
|
||||
style={s.input}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<div style={s.label}>Password</div>
|
||||
<input
|
||||
style={s.input}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={s.footer}>
|
||||
<button style={{ ...s.btn, opacity: loading ? 0.7 : 1 }} type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const s = {
|
||||
overlay: {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2500,
|
||||
},
|
||||
modal: {
|
||||
width: '560px', maxWidth: '95vw', maxHeight: '90vh', background: '#111217',
|
||||
borderRadius: '12px', boxShadow: '0 16px 40px rgba(0,0,0,0.8)', color: '#f8f9fa',
|
||||
overflow: 'hidden', border: '1px solid #2a2b3a', display: 'flex', flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
padding: '18px 24px', borderBottom: '1px solid #222',
|
||||
background: 'linear-gradient(135deg, #000000, #151622)',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
},
|
||||
title: { fontSize: '18px', fontWeight: 700 },
|
||||
close: { background: 'none', border: 'none', color: '#9ca0b8', fontSize: '22px', cursor: 'pointer', lineHeight: 1 },
|
||||
body: { padding: '18px 24px', overflowY: 'auto' },
|
||||
error: {
|
||||
background: '#3a1414', borderRadius: '6px', padding: '9px 11px',
|
||||
fontSize: '12px', color: '#ff9b9b', border: '1px solid #c0392b', marginBottom: '14px',
|
||||
},
|
||||
table: { width: '100%', borderCollapse: 'collapse', marginBottom: '20px', fontSize: '13px' },
|
||||
th: { textAlign: 'left', padding: '8px 10px', color: '#9ca0b8', borderBottom: '1px solid #222', fontWeight: 600 },
|
||||
td: { padding: '8px 10px', borderBottom: '1px solid #1a1b22' },
|
||||
roleBadge: (admin) => ({
|
||||
fontSize: '11px', fontWeight: 700, padding: '2px 8px', borderRadius: '10px',
|
||||
background: admin ? '#3b2e00' : '#1a2733', color: admin ? '#ffd666' : '#7fc4ff',
|
||||
border: `1px solid ${admin ? '#d4af37' : '#2a4a66'}`,
|
||||
}),
|
||||
smallBtn: {
|
||||
padding: '4px 10px', borderRadius: '5px', border: '1px solid #333544',
|
||||
background: '#050608', color: '#f8f9fa', fontSize: '12px', cursor: 'pointer', marginRight: '6px',
|
||||
},
|
||||
dangerBtn: {
|
||||
padding: '4px 10px', borderRadius: '5px', border: '1px solid #5a2020',
|
||||
background: '#2a1010', color: '#ff9b9b', fontSize: '12px', cursor: 'pointer',
|
||||
},
|
||||
sectionTitle: { fontSize: '14px', fontWeight: 700, margin: '6px 0 12px 0', color: '#e5e7f1' },
|
||||
row: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'flex-end' },
|
||||
field: { flex: '1 1 140px' },
|
||||
label: { fontSize: '12px', fontWeight: 600, marginBottom: '4px', color: '#e5e7f1' },
|
||||
input: {
|
||||
width: '100%', padding: '8px 10px', borderRadius: '6px',
|
||||
border: '1px solid #333544', background: '#050608', color: '#f8f9fa',
|
||||
fontSize: '13px', fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
},
|
||||
addBtn: {
|
||||
padding: '9px 18px', borderRadius: '6px', border: 'none',
|
||||
background: 'linear-gradient(135deg, #d4af37 0%, #ffdf8a 100%)',
|
||||
color: '#000', fontWeight: 700, fontSize: '13px', cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
|
||||
export default function UserManagementModal({ currentUser, onClose }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('user');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/users');
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load users');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await axios.post('/api/users', { username, password, role });
|
||||
setUsername(''); setPassword(''); setRole('user');
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create user');
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = async (u) => {
|
||||
if (!window.confirm(`Delete user "${u.username}"? They will lose access immediately.`)) return;
|
||||
setError('');
|
||||
try {
|
||||
await axios.delete(`/api/users/${u.id}`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = async (u) => {
|
||||
const pw = window.prompt(`Enter a new password for "${u.username}" (min 6 characters):`);
|
||||
if (pw == null) return;
|
||||
setError('');
|
||||
try {
|
||||
await axios.patch(`/api/users/${u.id}/password`, { password: pw });
|
||||
window.alert('Password updated.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={s.header}>
|
||||
<div style={s.title}>User Management</div>
|
||||
<button style={s.close} onClick={onClose} title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div style={s.body}>
|
||||
{error && <div style={s.error}>{error}</div>}
|
||||
|
||||
<table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>Username</th>
|
||||
<th style={s.th}>Role</th>
|
||||
<th style={s.th}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td style={s.td}>
|
||||
{u.username}{u.id === currentUser?.id && <span style={{ color: '#9ca0b8' }}> (you)</span>}
|
||||
</td>
|
||||
<td style={s.td}><span style={s.roleBadge(u.role === 'admin')}>{u.role}</span></td>
|
||||
<td style={s.td}>
|
||||
<button style={s.smallBtn} onClick={() => resetPassword(u)}>Reset Password</button>
|
||||
{u.id !== currentUser?.id && (
|
||||
<button style={s.dangerBtn} onClick={() => removeUser(u)}>Delete</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={s.sectionTitle}>Add New User</div>
|
||||
<form style={s.row} onSubmit={addUser}>
|
||||
<div style={s.field}>
|
||||
<div style={s.label}>Username</div>
|
||||
<input style={s.input} value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</div>
|
||||
<div style={s.field}>
|
||||
<div style={s.label}>Password</div>
|
||||
<input style={s.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<div style={{ ...s.field, flex: '0 0 110px' }}>
|
||||
<div style={s.label}>Role</div>
|
||||
<select style={s.input} value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button style={s.addBtn} type="submit">Add User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user