auth modal
Build and Push Docker Image / build (push) Successful in 18s

This commit is contained in:
2026-05-27 09:07:23 -05:00
parent 2d4920bd15
commit 97be2d2908
2656 changed files with 497146 additions and 8 deletions
+65 -3
View File
@@ -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>
);
+30
View File
@@ -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);
}
);
+103
View File
@@ -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>
);
}