implemented login
This commit is contained in:
+196
-31
@@ -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
@@ -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') ?? ''
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user