implemented login

This commit is contained in:
2026-08-03 10:50:58 -04:00
parent 2a2eaa204c
commit 54f0de305f
14 changed files with 976 additions and 126 deletions
+196 -31
View File
@@ -244,6 +244,15 @@ function sanitizeSettings(saved = {}) {
// ---------------------------------------------------------------------------
export default function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isAuthBootstrapping, setIsAuthBootstrapping] = useState(true)
const [userRole, setUserRole] = useState(null)
const [authUsername, setAuthUsername] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState(null)
const [isLoggingIn, setIsLoggingIn] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [daytimeTestingToday, setDaytimeTestingToday] = useState(false)
@@ -264,6 +273,10 @@ export default function App() {
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const isAdmin = userRole === 'admin'
const isViewer = userRole === 'viewer'
const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
const snapshotShiftKeys = useMemo(
() => collectSnapshotShiftKeys(scheduleData, previousScheduleData, scheduleStartShift),
@@ -300,7 +313,26 @@ export default function App() {
[daytimeTestingToday],
)
// Fetch schedule for the given weekStart (Monday)
const clearAuthSession = useCallback(() => {
sessionStorage.removeItem('auth_token')
sessionStorage.removeItem('auth_role')
sessionStorage.removeItem('auth_username')
setIsAuthenticated(false)
setUserRole(null)
setAuthUsername('')
setSettingsOpen(false)
setFailedTests([])
}, [])
const handleApiError = useCallback((requestError) => {
if (requestError?.status === 401) {
clearAuthSession()
setError('Your session has expired. Please sign in again.')
return true
}
return false
}, [clearAuthSession])
const fetchSchedule = useCallback(async (start) => {
try {
const data = await api.getScheduleWeek(toKey(start))
@@ -328,60 +360,120 @@ export default function App() {
setScheduleWindows(data.windows ?? [])
setCompletionDate(data.completion_date ?? null)
} catch (e) {
if (handleApiError(e)) return
console.error('Failed to fetch schedule:', e)
}
}, [])
}, [handleApiError])
const fetchRerunTests = useCallback(async () => {
try {
const data = await api.getRerunTests()
setFailedTests(data.tests ?? [])
} catch (e) {
if (handleApiError(e)) return
console.error('Failed to fetch rerun tests:', e)
}
}, [])
}, [handleApiError])
// On mount: load settings + holidays + schedule for today's week
useEffect(() => {
async function restoreSession() {
const token = sessionStorage.getItem('auth_token')
const role = sessionStorage.getItem('auth_role')
const storedUsername = sessionStorage.getItem('auth_username') ?? ''
if (!token || !role) {
setIsAuthBootstrapping(false)
return
}
try {
const user = await api.me()
const resolvedRole = user.role ?? role
const resolvedUsername = user.username ?? storedUsername
sessionStorage.setItem('auth_role', resolvedRole)
sessionStorage.setItem('auth_username', resolvedUsername)
setUserRole(resolvedRole)
setAuthUsername(resolvedUsername)
setIsAuthenticated(true)
} catch (_restoreError) {
clearAuthSession()
} finally {
setIsAuthBootstrapping(false)
}
}
restoreSession()
}, [clearAuthSession])
useEffect(() => {
if (!isAuthenticated) return
async function init() {
try {
const [saved, holidayData] = await Promise.all([
api.getSettings(),
api.getHolidays(),
])
setSettings(() => ({
...sanitizeSettings(saved),
holidays: (holidayData.dates ?? []).join(', '),
}))
const holidayData = await api.getHolidays()
if (isAdmin) {
const saved = await api.getSettings()
setSettings(() => ({
...sanitizeSettings(saved),
holidays: (holidayData.dates ?? []).join(', '),
}))
} else {
setSettings(() => ({
...DEFAULT_SETTINGS,
holidays: (holidayData.dates ?? []).join(', '),
}))
}
} catch (e) {
if (handleApiError(e)) return
console.warn('Backend not reachable on load:', e.message)
}
await fetchSchedule(getMondayOfWeek(new Date()))
await fetchRerunTests()
}
init()
}, [fetchSchedule])
}, [isAuthenticated, isAdmin, fetchSchedule, fetchRerunTests, handleApiError])
// Refetch whenever the displayed week changes
useEffect(() => {
if (!isAuthenticated) return
fetchSchedule(weekStart)
}, [weekStart, fetchSchedule])
}, [isAuthenticated, weekStart, fetchSchedule])
// Keep calendar statuses fresh when backend watcher marks tests from new result folders.
useEffect(() => {
if (!isAuthenticated) return
const timer = setInterval(() => {
fetchSchedule(weekStart)
fetchRerunTests()
}, 5000)
return () => clearInterval(timer)
}, [weekStart, fetchSchedule, fetchRerunTests])
}, [isAuthenticated, weekStart, fetchSchedule, fetchRerunTests])
// Load rerun tests on mount
useEffect(() => {
fetchRerunTests()
}, [fetchRerunTests])
async function handleLogin(event) {
event.preventDefault()
setIsLoggingIn(true)
setLoginError(null)
try {
const response = await api.login(username.trim(), password)
sessionStorage.setItem('auth_token', response.access_token)
sessionStorage.setItem('auth_role', response.role)
sessionStorage.setItem('auth_username', response.username)
setUserRole(response.role)
setAuthUsername(response.username)
setIsAuthenticated(true)
setUsername('')
setPassword('')
setError(null)
} catch (e) {
setLoginError(e.message)
} finally {
setIsLoggingIn(false)
}
}
// -------------------------------------------------------------------------
function handleLogout() {
clearAuthSession()
}
async function handleSaveSettings(newSettings) {
setLoading(true)
@@ -406,6 +498,7 @@ export default function App() {
setSettings(sanitizedSettings)
} catch (e) {
if (handleApiError(e)) return
setError(e.message)
} finally {
setLoading(false)
@@ -437,6 +530,7 @@ export default function App() {
await fetchSchedule(weekStart)
await fetchRerunTests()
} catch (e) {
if (handleApiError(e)) return
setError(e.message)
throw e
} finally {
@@ -481,6 +575,7 @@ export default function App() {
setStartDateOverride('')
} catch (e) {
if (handleApiError(e)) return
setError(e.message)
} finally {
setLoading(false)
@@ -534,11 +629,76 @@ export default function App() {
setSelectedWindowId(null)
}
// -------------------------------------------------------------------------
if (isAuthBootstrapping) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-4">
<p className="text-sm text-slate-400">Restoring session...</p>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-4">
<form
onSubmit={handleLogin}
className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-xl p-6 space-y-4"
>
<div>
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
</div>
{loginError && (
<div className="bg-red-950/50 border border-red-700 rounded-lg px-3 py-2 text-red-300 text-sm">
{loginError}
</div>
)}
<label className="block text-sm text-slate-300">
Username
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
autoComplete="username"
required
/>
</label>
<label className="block text-sm text-slate-300">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={isLoggingIn}
className="w-full rounded-lg bg-cyan-700 hover:bg-cyan-600 disabled:opacity-60 px-4 py-2 text-sm font-medium transition-colors"
>
{isLoggingIn ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
)
}
return (
<div className="flex flex-col min-h-screen bg-gray-950 text-gray-100">
<Header onOpenSettings={() => setSettingsOpen(true)} />
<Header
onOpenSettings={() => {
if (isAdmin) setSettingsOpen(true)
}}
showSettings={isAdmin}
username={authUsername}
onLogout={handleLogout}
/>
{error && (
<div className="px-6 py-2 bg-orange-900/60 border-b border-orange-700 text-orange-200 text-sm flex items-center justify-between">
@@ -560,6 +720,7 @@ export default function App() {
daytimeDateKey={daytimeDateKey}
weekStart={weekStart}
onWeekChange={setWeekStart}
isViewer={isViewer}
dualDeviceWeekendWeekEnabled={dualDeviceWeekendWeekEnabled}
onDualDeviceWeekendWeekEnabledChange={(enabled) => {
setDualDeviceWeekendWeekSelections((prev) => ({
@@ -592,22 +753,26 @@ export default function App() {
onRemakeSchedule={handleRemakeSchedule}
loading={loading}
tonightConfigRows={tonightConfigRows}
isViewer={isViewer}
/>
<TestWindowDetailsPanel
isOpen={Boolean(selectedWindow)}
windowDetails={selectedWindow}
onClose={handleCloseWindowDetails}
isViewer={isViewer}
/>
</main>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={settings}
onSave={handleSaveSettings}
onRestart={handleRestartAndClearData}
/>
{isAdmin ? (
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={settings}
onSave={handleSaveSettings}
onRestart={handleRestartAndClearData}
/>
) : null}
</div>
)
}
+27 -4
View File
@@ -1,14 +1,24 @@
const BASE = '/api'
function buildAuthHeaders() {
const token = sessionStorage.getItem('auth_token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function request(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...buildAuthHeaders(),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || `HTTP ${res.status}`)
const error = new Error(err.detail || `HTTP ${res.status}`)
error.status = res.status
throw error
}
return res.json()
}
@@ -17,6 +27,13 @@ export const api = {
// Health
health: () => request('GET', '/health'),
// Auth
login: (username, password) => request('POST', '/auth/login', { username, password }),
me: () => request('GET', '/auth/me'),
listUsers: () => request('GET', '/users'),
createUser: (username, password, role) => request('POST', '/users', { username, password, role }),
deleteUser: (username) => request('DELETE', `/users/${encodeURIComponent(username)}`),
// Settings
getSettings: () => request('GET', '/settings'),
saveSettings: (settings) => request('POST', '/settings', { settings }),
@@ -42,10 +59,16 @@ export const api = {
exportWindow: async (windowId) => {
const params = new URLSearchParams({ window_id: windowId })
const res = await fetch(`${BASE}/schedule/export?${params}`)
const res = await fetch(`${BASE}/schedule/export?${params}`, {
headers: {
...buildAuthHeaders(),
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || `HTTP ${res.status}`)
const error = new Error(err.detail || `HTTP ${res.status}`)
error.status = res.status
throw error
}
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') ?? ''
+21 -18
View File
@@ -54,6 +54,7 @@ export default function Calendar({
daytimeDateKey = null,
weekStart,
onWeekChange,
isViewer = false,
dualDeviceWeekendWeekEnabled = false,
onDualDeviceWeekendWeekEnabledChange,
windowLookup = new Map(),
@@ -155,25 +156,27 @@ export default function Calendar({
>
Today
</button>
<div className="ml-auto flex items-center gap-2 text-xs text-gray-300 select-none">
<span>Run both devices on weekend this week</span>
<button
type="button"
role="switch"
aria-checked={dualDeviceWeekendWeekEnabled}
onClick={() => onDualDeviceWeekendWeekEnabledChange?.(!dualDeviceWeekendWeekEnabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/70 ${
dualDeviceWeekendWeekEnabled ? 'bg-blue-600' : 'bg-gray-600'
}`}
title="Toggle dual-device weekend-start scheduling for this week"
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
dualDeviceWeekendWeekEnabled ? 'translate-x-4' : 'translate-x-0'
{!isViewer ? (
<div className="ml-auto flex items-center gap-2 text-xs text-gray-300 select-none">
<span>Run both devices on weekend this week</span>
<button
type="button"
role="switch"
aria-checked={dualDeviceWeekendWeekEnabled}
onClick={() => onDualDeviceWeekendWeekEnabledChange?.(!dualDeviceWeekendWeekEnabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/70 ${
dualDeviceWeekendWeekEnabled ? 'bg-blue-600' : 'bg-gray-600'
}`}
/>
</button>
</div>
title="Toggle dual-device weekend-start scheduling for this week"
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform ${
dualDeviceWeekendWeekEnabled ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
) : null}
</div>
{/* 7-day grid */}
+41 -26
View File
@@ -1,35 +1,50 @@
export default function Header({ onOpenSettings }) {
export default function Header({ onOpenSettings, showSettings = true, username = '', onLogout }) {
return (
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0">
<h1 className="text-white font-semibold text-lg tracking-wide">
NJTH Scheduler
</h1>
<button
onClick={onOpenSettings}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 hover:text-white transition-colors"
title="Settings"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.8}
<div className="flex items-center gap-2">
{username ? (
<span className="text-xs text-gray-400">{username}</span>
) : null}
{showSettings ? (
<button
onClick={onOpenSettings}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 hover:text-white transition-colors"
title="Settings"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.8}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</button>
) : null}
<button
type="button"
onClick={onLogout}
className="px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 hover:text-white transition-colors"
title="Sign out"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</button>
Logout
</button>
</div>
</header>
)
}
+20
View File
@@ -34,10 +34,30 @@ export default function RightPanel({
onRemakeSchedule,
tonightConfigRows = [],
loading = false,
isViewer = false,
}) {
const formattedCompletionDate = formatCompletionDate(completionDate)
const [topPriorityOpen, setTopPriorityOpen] = useState(false)
if (isViewer) {
return (
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
<div className="flex justify-center mb-2">
<img src={cgw453Image} alt="CGW453 Device" className="h-32 object-contain" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1">
Est. Completion
</p>
<p className="text-lg font-bold text-white">
{formattedCompletionDate ?? <span className="text-gray-500 text-sm font-normal">Not calculated</span>}
</p>
</div>
</aside>
)
}
return (
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
{/* Device Image */}
+229 -9
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
function Field({ label, hint, required = false, children }) {
return (
@@ -18,24 +19,42 @@ const INPUT_CLS =
export default function SettingsModal({ isOpen, onClose, settings, onSave, onRestart }) {
const [form, setForm] = useState({ ...settings })
const [activeTab, setActiveTab] = useState('general')
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [restartBusy, setRestartBusy] = useState(false)
const [restartError, setRestartError] = useState(null)
const [users, setUsers] = useState([])
const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState(null)
const [usersMessage, setUsersMessage] = useState(null)
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newRole, setNewRole] = useState('viewer')
const [creatingUser, setCreatingUser] = useState(false)
const [deletingUsername, setDeletingUsername] = useState('')
// Sync if parent settings change while modal is closed
useEffect(() => {
if (!isOpen) {
setForm({ ...settings })
setActiveTab('general')
setConfirmOpen(false)
setConfirmText('')
setRestartBusy(false)
setRestartError(null)
setUsers([])
setUsersLoading(false)
setUsersError(null)
setUsersMessage(null)
setNewUsername('')
setNewPassword('')
setNewRole('viewer')
setCreatingUser(false)
setDeletingUsername('')
}
}, [isOpen, settings])
if (!isOpen) return null
function set(key, value) {
setForm((f) => ({ ...f, [key]: value }))
}
@@ -69,6 +88,73 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave, onRes
}
}
async function loadUsers() {
setUsersLoading(true)
setUsersError(null)
try {
const response = await api.listUsers()
setUsers(response.users ?? [])
} catch (err) {
setUsersError(err?.message || 'Failed to load users.')
} finally {
setUsersLoading(false)
}
}
async function handleCreateUser() {
const username = newUsername.trim()
if (!username) {
setUsersError('Username is required.')
return
}
if (newPassword.length < 8) {
setUsersError('Password must be at least 8 characters.')
return
}
setCreatingUser(true)
setUsersError(null)
setUsersMessage(null)
try {
await api.createUser(username, newPassword, newRole)
setUsersMessage(`Created user ${username}.`)
setNewUsername('')
setNewPassword('')
setNewRole('viewer')
await loadUsers()
} catch (err) {
setUsersError(err?.message || 'Failed to create user.')
} finally {
setCreatingUser(false)
}
}
async function handleDeleteUser(username) {
const confirmed = window.confirm(`Delete user "${username}"?`)
if (!confirmed) return
setDeletingUsername(username)
setUsersError(null)
setUsersMessage(null)
try {
await api.deleteUser(username)
setUsersMessage(`Deleted user ${username}.`)
await loadUsers()
} catch (err) {
setUsersError(err?.message || 'Failed to delete user.')
} finally {
setDeletingUsername('')
}
}
useEffect(() => {
if (isOpen && activeTab === 'users') {
loadUsers()
}
}, [isOpen, activeTab])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -89,7 +175,27 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave, onRes
</div>
{/* Scrollable body */}
<div className="flex flex-col gap-5 overflow-y-auto px-5 py-5">
<div className="overflow-y-auto">
<div className="px-4 py-4 sm:px-5">
<div className="inline-flex max-w-full flex-wrap rounded-lg border border-slate-700 overflow-hidden">
<button
onClick={() => setActiveTab('general')}
className={`px-3 py-1.5 text-sm ${activeTab === 'general' ? 'bg-slate-700 text-slate-100' : 'bg-slate-900 text-slate-300 hover:bg-slate-800'}`}
>
General
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-3 py-1.5 text-sm border-l border-slate-700 ${activeTab === 'users' ? 'bg-slate-700 text-slate-100' : 'bg-slate-900 text-slate-300 hover:bg-slate-800'}`}
>
User Management
</button>
</div>
</div>
<div className="flex flex-col gap-5 px-5 pb-5">
{activeTab === 'general' ? (
<>
{/* Section: Paths */}
<div>
@@ -363,6 +469,118 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave, onRes
</div>
</div>
</div>
</>
) : (
<div className="flex flex-col gap-4">
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
Current Users
</p>
{usersMessage && (
<div className="mb-3 rounded-md border border-green-700 bg-green-900/30 px-3 py-2 text-xs text-green-200">
{usersMessage}
</div>
)}
{usersError && (
<div className="mb-3 rounded-md border border-red-700 bg-red-900/30 px-3 py-2 text-xs text-red-200">
{usersError}
</div>
)}
<div className="rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-800/80 text-gray-300">
<tr>
<th className="text-left px-3 py-2 font-semibold">Username</th>
<th className="text-left px-3 py-2 font-semibold">Role</th>
<th className="text-right px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{usersLoading ? (
<tr>
<td colSpan={3} className="px-3 py-3 text-gray-400">Loading users...</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={3} className="px-3 py-3 text-gray-400">No users found.</td>
</tr>
) : (
users.map((user) => (
<tr key={user.username} className="border-t border-gray-700">
<td className="px-3 py-2 text-gray-100 font-mono">{user.username}</td>
<td className="px-3 py-2 text-gray-300 capitalize">{user.role}</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => handleDeleteUser(user.username)}
disabled={deletingUsername === user.username || creatingUser}
className="px-2.5 py-1 text-xs font-semibold text-white bg-red-700 hover:bg-red-600 border border-red-500 rounded-md transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deletingUsername === user.username ? 'Deleting...' : 'Delete'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<hr className="border-gray-700" />
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
Create User
</p>
<div className="flex flex-col gap-3">
<Field label="Username" required>
<input
type="text"
className={INPUT_CLS}
placeholder="new username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
</Field>
<Field label="Password" hint="(minimum 8 characters)" required>
<input
type="password"
className={INPUT_CLS}
placeholder="new password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</Field>
<Field label="Role" required>
<select
className={INPUT_CLS}
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</Field>
<div className="pt-1">
<button
onClick={handleCreateUser}
disabled={creatingUser || deletingUsername !== ''}
className="px-4 py-1.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-md transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{creatingUser ? 'Creating...' : 'Create User'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
@@ -373,12 +591,14 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave, onRes
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-1.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-md transition-colors"
>
Save
</button>
{activeTab === 'general' && (
<button
onClick={handleSave}
className="px-4 py-1.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-500 border border-blue-500 rounded-md transition-colors"
>
Save
</button>
)}
</div>
</div>
</div>
@@ -100,9 +100,14 @@ function formatConfigEntries(config) {
function getConfigMapImages(configKey) {
if (!configKey) return []
const fileName = `${configKey.toUpperCase()}.png`
const key = configKey.toUpperCase()
return Object.entries(CONFIG_MAP_IMAGE_MODULES)
.filter(([path]) => path.endsWith(`/TC maps/${fileName}`))
.filter(([path]) => {
const filename = path.split('/').pop() ?? ''
const nameWithoutExt = filename.replace(/\.[^.]+$/, '').toUpperCase()
return nameWithoutExt === key
})
.sort(([pathA], [pathB]) => pathA.localeCompare(pathB, undefined, { numeric: true }))
.map(([, assetUrl]) => assetUrl)
}
@@ -181,7 +186,7 @@ function TestRow({ test }) {
)
}
export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) {
export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose, isViewer = false }) {
const [showConfigMap, setShowConfigMap] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [exportError, setExportError] = useState(null)
@@ -264,24 +269,26 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
</h2>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleExport}
disabled={isExporting || !windowDetails?.tests?.length}
className="inline-flex items-center gap-1.5 rounded-full border border-gray-700 px-3 py-2 text-xs font-semibold text-gray-300 hover:border-gray-500 hover:text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Export .ini files for this window"
>
{isExporting ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v3m0 12v3M4.22 4.22l2.12 2.12m11.32 11.32 2.12 2.12M3 12h3m12 0h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
)}
{isExporting ? 'Exporting…' : 'Export Tests'}
</button>
{!isViewer ? (
<button
type="button"
onClick={handleExport}
disabled={isExporting || !windowDetails?.tests?.length}
className="inline-flex items-center gap-1.5 rounded-full border border-gray-700 px-3 py-2 text-xs font-semibold text-gray-300 hover:border-gray-500 hover:text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Export .ini files for this window"
>
{isExporting ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v3m0 12v3M4.22 4.22l2.12 2.12m11.32 11.32 2.12 2.12M3 12h3m12 0h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
)}
{isExporting ? 'Exporting…' : 'Export Tests'}
</button>
) : null}
<button
type="button"
onClick={onClose}