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
+10
View File
@@ -1 +1,11 @@
HOST_BROWSE_ROOT= HOST_BROWSE_ROOT=
JWT_SECRET=
JWT_EXPIRES_HOURS=8
JWT_ALGORITHM=HS256
DEFAULT_ADMIN_USERNAME=
DEFAULT_ADMIN_PASSWORD=
DEFAULT_VIEWER_USERNAME=
DEFAULT_VIEWER_PASSWORD=
+2 -2
View File
@@ -1,8 +1,8 @@
/backend/.venv /backend/.venv
/backend/__pycache__ **/__pycache__
/backend/output.txt /backend/output.txt
/backend/.env /backend/.env
/backend/tests /backend/tests
/backend/.pytest_cache
*.db *.db
.env .env
**/.pytest_cache
+1
View File
@@ -4,3 +4,4 @@ pydantic==2.11.7
watchdog==6.0.0 watchdog==6.0.0
httpx==0.28.1 httpx==0.28.1
smbprotocol==1.15.0 smbprotocol==1.15.0
PyJWT==2.10.1
+128 -13
View File
@@ -6,10 +6,11 @@ import io
import os import os
import zipfile import zipfile
from fastapi import FastAPI, HTTPException, Response from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import auth
import db as db import db as db
from parser import CsvValidationError from parser import CsvValidationError
from file_manager import resolve_requested_csv_path, read_file_bytes, path_name from file_manager import resolve_requested_csv_path, read_file_bytes, path_name
@@ -30,6 +31,26 @@ DB_PATH = Path(os.getenv("DB_PATH", str(APP_ROOT.parent / "data" / "scheduler.db
DUT = os.getenv("DUT", "CGW453").strip() DUT = os.getenv("DUT", "CGW453").strip()
REF = os.getenv("REF", "CGW452").strip() REF = os.getenv("REF", "CGW452").strip()
def _require_env_value(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"{name} must be set when bootstrapping default users.")
return value
def _bootstrap_default_users() -> None:
if db.count_users(DB_PATH) > 0:
return
admin_username = _require_env_value("DEFAULT_ADMIN_USERNAME")
admin_password = _require_env_value("DEFAULT_ADMIN_PASSWORD")
viewer_username = _require_env_value("DEFAULT_VIEWER_USERNAME")
viewer_password = _require_env_value("DEFAULT_VIEWER_PASSWORD")
db.create_user(admin_username, admin_password, "admin", DB_PATH)
db.create_user(viewer_username, viewer_password, "viewer", DB_PATH)
class LoadTestsRequest(BaseModel): class LoadTestsRequest(BaseModel):
csv_path: str | None = Field(default=None, description="Absolute or backend-relative path to target CSV") csv_path: str | None = Field(default=None, description="Absolute or backend-relative path to target CSV")
csv_paths: list[str] = Field(default_factory=list, description="One or more CSV paths to load together") csv_paths: list[str] = Field(default_factory=list, description="One or more CSV paths to load together")
@@ -40,6 +61,17 @@ class SaveSettingsRequest(BaseModel):
settings: dict[str, Any] settings: dict[str, Any]
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class CreateUserRequest(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=8)
role: str = Field(default="viewer", min_length=1)
def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]: def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]:
return { return {
"username": str(settings.get("smbUsername") or "").strip(), "username": str(settings.get("smbUsername") or "").strip(),
@@ -83,6 +115,15 @@ def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, dict
return overrides return overrides
def _serialize_user_summary(user: db.UserSummary) -> dict[str, str]:
return {
"username": user.username,
"role": user.role,
"created_at": user.created_at,
"updated_at": user.updated_at,
}
@@ -279,7 +320,9 @@ def _build_schedule_windows(week_start: date, all_rows: list[db.ScheduleRow], ho
@asynccontextmanager @asynccontextmanager
async def lifespan(application: FastAPI): async def lifespan(application: FastAPI):
auth.validate_auth_environment()
db.init_db(DB_PATH) db.init_db(DB_PATH)
_bootstrap_default_users()
settings = db.read_settings(DB_PATH) settings = db.read_settings(DB_PATH)
configure_result_watcher(settings) configure_result_watcher(settings)
try: try:
@@ -308,20 +351,92 @@ def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.post("/api/auth/login")
def login(request: LoginRequest) -> dict[str, Any]:
user = db.get_user_by_username(request.username, DB_PATH)
if user is None or not db.verify_password(request.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid username or password")
token, expires_at = auth.create_access_token(user.username, user.role)
return {
"access_token": token,
"token_type": "bearer",
"username": user.username,
"role": user.role,
"expires_at": expires_at.isoformat(),
}
@app.get("/api/auth/me")
def get_current_user(current_user: auth.AuthUser = Depends(auth.get_current_user)) -> dict[str, str]:
return {
"username": current_user.username,
"role": current_user.role,
}
@app.get("/api/users")
def list_users_endpoint(_admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
users = db.list_users(DB_PATH)
return {
"users": [_serialize_user_summary(user) for user in users],
}
@app.post("/api/users")
def create_user_endpoint(request: CreateUserRequest, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
try:
created_user = db.create_user(request.username, request.password, request.role, DB_PATH)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"user": {
"username": created_user.username,
"role": created_user.role,
},
}
@app.delete("/api/users/{username}")
def delete_user_endpoint(username: str, current_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
normalized_username = (username or "").strip()
if not normalized_username:
raise HTTPException(status_code=400, detail="username is required")
if normalized_username == current_user.username:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
target_user = db.get_user_by_username(normalized_username, DB_PATH)
if target_user is None:
raise HTTPException(status_code=404, detail="User not found")
if target_user.role == "admin" and db.count_admin_users(DB_PATH) <= 1:
raise HTTPException(status_code=409, detail="At least one admin user must remain")
deleted_user = db.delete_user(normalized_username, DB_PATH)
if deleted_user is None:
raise HTTPException(status_code=404, detail="User not found")
return {
"status": "deleted",
"username": deleted_user.username,
}
@app.post("/api/settings") @app.post("/api/settings")
def save_settings(request: SaveSettingsRequest) -> dict[str, str]: def save_settings(request: SaveSettingsRequest, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, str]:
db.save_settings(request.settings, DB_PATH) db.save_settings(request.settings, DB_PATH)
configure_result_watcher(db.read_settings(DB_PATH)) configure_result_watcher(db.read_settings(DB_PATH))
return {"status": "saved"} return {"status": "saved"}
@app.get("/api/settings") @app.get("/api/settings")
def get_settings() -> dict[str, Any]: def get_settings(_admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
return db.read_settings(DB_PATH) return db.read_settings(DB_PATH)
@app.post("/api/settings/restart") @app.post("/api/settings/restart")
def restart_and_clear_data() -> dict[str, Any]: def restart_and_clear_data(_admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
deleted_counts = db.reset_all_data(DB_PATH) deleted_counts = db.reset_all_data(DB_PATH)
configure_result_watcher({}) configure_result_watcher({})
return { return {
@@ -331,7 +446,7 @@ def restart_and_clear_data() -> dict[str, Any]:
@app.post("/api/tests/load") @app.post("/api/tests/load")
def load_tests(request: LoadTestsRequest) -> dict[str, Any]: def load_tests(request: LoadTestsRequest, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
requested_paths: list[str] = [] requested_paths: list[str] = []
if request.csv_path and request.csv_path.strip(): if request.csv_path and request.csv_path.strip():
requested_paths.append(request.csv_path.strip()) requested_paths.append(request.csv_path.strip())
@@ -362,14 +477,14 @@ def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
} }
@app.post("/api/schedule/active/remove") @app.post("/api/schedule/active/remove")
def remove_active_tests(request: RemoveActiveTestsRequest) -> dict[str, Any]: def remove_active_tests(request: RemoveActiveTestsRequest, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
# new_scheduler does not keep global mutable active state in app lifecycle. # new_scheduler does not keep global mutable active state in app lifecycle.
# Keep endpoint for compatibility with frontend calls. # Keep endpoint for compatibility with frontend calls.
return {"status": "ok", "removed": len(request.test_ids)} return {"status": "ok", "removed": len(request.test_ids)}
@app.post("/api/schedule/compile") @app.post("/api/schedule/compile")
def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]: def compile_schedule_endpoint(request: CompileScheduleRequest, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
if request.start_date: if request.start_date:
try: try:
datetime.strptime(request.start_date, "%Y-%m-%d") datetime.strptime(request.start_date, "%Y-%m-%d")
@@ -469,7 +584,7 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
@app.get("/api/tests/rerun") @app.get("/api/tests/rerun")
def get_rerun_tests() -> dict[str, Any]: def get_rerun_tests(_current_user: auth.AuthUser = Depends(auth.get_current_user)) -> dict[str, Any]:
db.mark_overdue_as_rerun(DB_PATH) db.mark_overdue_as_rerun(DB_PATH)
tests = db.get_rerun_tests(DB_PATH) tests = db.get_rerun_tests(DB_PATH)
total_minutes = sum(t["estimated_minutes"] for t in tests) total_minutes = sum(t["estimated_minutes"] for t in tests)
@@ -477,24 +592,24 @@ def get_rerun_tests() -> dict[str, Any]:
@app.post("/api/holidays") @app.post("/api/holidays")
def save_holidays(request: SaveHolidaysRequest) -> dict[str, Any]: def save_holidays(request: SaveHolidaysRequest, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> dict[str, Any]:
dates = [d.strip() for d in request.dates if d.strip()] dates = [d.strip() for d in request.dates if d.strip()]
db.upsert_holidays(dates, DB_PATH) db.upsert_holidays(dates, DB_PATH)
return {"status": "saved", "count": len(dates)} return {"status": "saved", "count": len(dates)}
@app.get("/api/holidays") @app.get("/api/holidays")
def get_holidays() -> dict[str, Any]: def get_holidays(_current_user: auth.AuthUser = Depends(auth.get_current_user)) -> dict[str, Any]:
return {"dates": sorted(db.list_holidays(DB_PATH))} return {"dates": sorted(db.list_holidays(DB_PATH))}
@app.get("/api/schedule/versions") @app.get("/api/schedule/versions")
def get_schedule_versions() -> dict[str, Any]: def get_schedule_versions(_current_user: auth.AuthUser = Depends(auth.get_current_user)) -> dict[str, Any]:
return {"versions": db.get_schedule_versions(DB_PATH)} return {"versions": db.get_schedule_versions(DB_PATH)}
@app.get("/api/schedule/week") @app.get("/api/schedule/week")
def get_schedule_week(start: str | None = None, version: int | None = None) -> dict[str, Any]: def get_schedule_week(start: str | None = None, version: int | None = None, _current_user: auth.AuthUser = Depends(auth.get_current_user)) -> dict[str, Any]:
week_start = start or date.today().isoformat() week_start = start or date.today().isoformat()
try: try:
datetime.strptime(week_start, "%Y-%m-%d") datetime.strptime(week_start, "%Y-%m-%d")
@@ -536,7 +651,7 @@ def get_schedule_week(start: str | None = None, version: int | None = None) -> d
} }
@app.get("/api/schedule/export") @app.get("/api/schedule/export")
def export_window(window_id: str, version: int | None = None) -> Response: def export_window(window_id: str, version: int | None = None, _admin_user: auth.AuthUser = Depends(auth.require_admin)) -> Response:
resolved_version = db.resolve_schedule_version(version, DB_PATH) resolved_version = db.resolve_schedule_version(version, DB_PATH)
if version is not None and resolved_version is None: if version is not None and resolved_version is None:
raise HTTPException(status_code=404, detail=f"Schedule version {version} was not found") raise HTTPException(status_code=404, detail=f"Schedule version {version} was not found")
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import os
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@dataclass(frozen=True)
class AuthUser:
username: str
role: str
security = HTTPBearer(auto_error=False)
def _get_secret() -> str:
secret = os.getenv("JWT_SECRET", "").strip()
if not secret:
raise RuntimeError("JWT_SECRET must be set.")
return secret
def _get_algorithm() -> str:
return os.getenv("JWT_ALGORITHM", "HS256").strip() or "HS256"
def _get_expires_hours() -> int:
raw = os.getenv("JWT_EXPIRES_HOURS", "8").strip() or "8"
try:
expires_hours = int(raw)
except ValueError as exc:
raise RuntimeError("JWT_EXPIRES_HOURS must be an integer.") from exc
if expires_hours <= 0:
raise RuntimeError("JWT_EXPIRES_HOURS must be greater than zero.")
return expires_hours
def create_access_token(username: str, role: str) -> tuple[str, datetime]:
now = datetime.now(timezone.utc)
expires_at = now + timedelta(hours=_get_expires_hours())
payload = {
"sub": username,
"role": role,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
}
token = jwt.encode(payload, _get_secret(), algorithm=_get_algorithm())
return token, expires_at
def decode_access_token(token: str) -> AuthUser:
try:
payload = jwt.decode(token, _get_secret(), algorithms=[_get_algorithm()])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=401, detail="Token has expired") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
username = str(payload.get("sub") or "").strip()
role = str(payload.get("role") or "").strip().lower()
if not username or role not in {"admin", "viewer"}:
raise HTTPException(status_code=401, detail="Invalid token payload")
return AuthUser(username=username, role=role)
def validate_auth_environment() -> None:
_get_secret()
_get_algorithm()
_get_expires_hours()
def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(security)) -> AuthUser:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Authorization token is required")
return decode_access_token(credentials.credentials)
def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
+177
View File
@@ -2,6 +2,9 @@ import json
import sqlite3 import sqlite3
import os import os
import re import re
import base64
import hashlib
import hmac
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -18,6 +21,7 @@ REF = os.getenv("REF", "CGW452").strip()
# Device names for the test database (use hardware device names) # Device names for the test database (use hardware device names)
MAX_SCHEDULE_VERSIONS = 50 MAX_SCHEDULE_VERSIONS = 50
PBKDF2_ITERATIONS = 390000
@dataclass(frozen=True) @dataclass(frozen=True)
class TestRecord: class TestRecord:
@@ -53,6 +57,21 @@ class ScheduleRow:
status: str status: str
estimated_minutes: int estimated_minutes: int
@dataclass(frozen=True)
class UserAccount:
username: str
password_hash: str
role: str
@dataclass(frozen=True)
class UserSummary:
username: str
role: str
created_at: str
updated_at: str
@contextmanager @contextmanager
def get_connection(db_path: str | Path = DB_PATH) -> Iterator[sqlite3.Connection]: def get_connection(db_path: str | Path = DB_PATH) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
@@ -122,8 +141,18 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
minutes INTEGER NOT NULL minutes INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'viewer')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tests_status ON tests(status); CREATE INDEX IF NOT EXISTS idx_tests_status ON tests(status);
CREATE INDEX IF NOT EXISTS idx_schedules_date_shift ON schedules(scheduled_date, shift_index); CREATE INDEX IF NOT EXISTS idx_schedules_date_shift ON schedules(scheduled_date, shift_index);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
""" """
) )
@@ -162,6 +191,154 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
""" """
) )
def count_users(db_path: str | Path = DB_PATH) -> int:
with get_connection(db_path) as conn:
row = conn.execute("SELECT COUNT(*) AS count FROM users").fetchone()
return int(row["count"])
def hash_password(password: str) -> str:
if not password:
raise ValueError("password is required")
salt = os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
salt_b64 = base64.b64encode(salt).decode("ascii")
digest_b64 = base64.b64encode(digest).decode("ascii")
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_b64}${digest_b64}"
def verify_password(password: str, password_hash: str) -> bool:
if not password or not password_hash:
return False
parts = password_hash.split("$")
if len(parts) != 4:
return False
algorithm, iterations_raw, salt_b64, expected_digest_b64 = parts
if algorithm != "pbkdf2_sha256":
return False
try:
iterations = int(iterations_raw)
salt = base64.b64decode(salt_b64.encode("ascii"))
expected_digest = base64.b64decode(expected_digest_b64.encode("ascii"))
except (TypeError, ValueError):
return False
computed_digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(computed_digest, expected_digest)
def get_user_by_username(username: str, db_path: str | Path = DB_PATH) -> UserAccount | None:
normalized_username = (username or "").strip()
if not normalized_username:
return None
with get_connection(db_path) as conn:
row = conn.execute(
"SELECT username, password_hash, role FROM users WHERE username = ?",
(normalized_username,),
).fetchone()
if row is None:
return None
return UserAccount(
username=row["username"],
password_hash=row["password_hash"],
role=row["role"],
)
def create_user(username: str, password: str, role: str, db_path: str | Path = DB_PATH) -> UserAccount:
normalized_username = (username or "").strip()
normalized_role = (role or "").strip().lower()
if not normalized_username:
raise ValueError("username is required")
if not password:
raise ValueError("password is required")
if len(password) < 8:
raise ValueError("password must be at least 8 characters")
if normalized_role not in {"admin", "viewer"}:
raise ValueError("role must be 'admin' or 'viewer'")
password_hash = hash_password(password)
try:
with get_connection(db_path) as conn:
conn.execute(
"""
INSERT INTO users(username, password_hash, role, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
""",
(normalized_username, password_hash, normalized_role),
)
except sqlite3.IntegrityError as exc:
raise ValueError("username already exists") from exc
return UserAccount(
username=normalized_username,
password_hash=password_hash,
role=normalized_role,
)
def list_users(db_path: str | Path = DB_PATH) -> list[UserSummary]:
with get_connection(db_path) as conn:
rows = conn.execute(
"""
SELECT username, role, created_at, updated_at
FROM users
ORDER BY username COLLATE NOCASE ASC
"""
).fetchall()
return [
UserSummary(
username=row["username"],
role=row["role"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in rows
]
def count_admin_users(db_path: str | Path = DB_PATH) -> int:
with get_connection(db_path) as conn:
row = conn.execute(
"SELECT COUNT(*) AS count FROM users WHERE role = 'admin'"
).fetchone()
return int(row["count"])
def delete_user(username: str, db_path: str | Path = DB_PATH) -> UserAccount | None:
normalized_username = (username or "").strip()
if not normalized_username:
return None
with get_connection(db_path) as conn:
row = conn.execute(
"SELECT username, password_hash, role FROM users WHERE username = ?",
(normalized_username,),
).fetchone()
if row is None:
return None
conn.execute(
"DELETE FROM users WHERE username = ?",
(normalized_username,),
)
return UserAccount(
username=row["username"],
password_hash=row["password_hash"],
role=row["role"],
)
def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int: def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> int:
if not records: if not records:
return 0 return 0
+7
View File
@@ -9,6 +9,13 @@ services:
HOST_MOUNT_ROOT: /host HOST_MOUNT_ROOT: /host
DB_PATH: /app/data/scheduler.db DB_PATH: /app/data/scheduler.db
PYTHONPATH: /app/src PYTHONPATH: /app/src
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRES_HOURS: ${JWT_EXPIRES_HOURS}
JWT_ALGORITHM: ${JWT_ALGORITHM}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
DEFAULT_VIEWER_USERNAME: ${DEFAULT_VIEWER_USERNAME}
DEFAULT_VIEWER_PASSWORD: ${DEFAULT_VIEWER_PASSWORD}
volumes: volumes:
- ${HOST_BROWSE_ROOT}:/host - ${HOST_BROWSE_ROOT}:/host
- scheduler-db:/app/runtime - scheduler-db:/app/runtime
+196 -31
View File
@@ -244,6 +244,15 @@ function sanitizeSettings(saved = {}) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function App() { 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 [settingsOpen, setSettingsOpen] = useState(false)
const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [daytimeTestingToday, setDaytimeTestingToday] = useState(false) const [daytimeTestingToday, setDaytimeTestingToday] = useState(false)
@@ -264,6 +273,10 @@ export default function App() {
const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date())) const [weekStart, setWeekStart] = useState(() => getMondayOfWeek(new Date()))
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const isAdmin = userRole === 'admin'
const isViewer = userRole === 'viewer'
const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData]) const tonightConfigRows = useMemo(() => toTonightConfigRows(scheduleData), [scheduleData])
const snapshotShiftKeys = useMemo( const snapshotShiftKeys = useMemo(
() => collectSnapshotShiftKeys(scheduleData, previousScheduleData, scheduleStartShift), () => collectSnapshotShiftKeys(scheduleData, previousScheduleData, scheduleStartShift),
@@ -300,7 +313,26 @@ export default function App() {
[daytimeTestingToday], [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) => { const fetchSchedule = useCallback(async (start) => {
try { try {
const data = await api.getScheduleWeek(toKey(start)) const data = await api.getScheduleWeek(toKey(start))
@@ -328,60 +360,120 @@ export default function App() {
setScheduleWindows(data.windows ?? []) setScheduleWindows(data.windows ?? [])
setCompletionDate(data.completion_date ?? null) setCompletionDate(data.completion_date ?? null)
} catch (e) { } catch (e) {
if (handleApiError(e)) return
console.error('Failed to fetch schedule:', e) console.error('Failed to fetch schedule:', e)
} }
}, []) }, [handleApiError])
const fetchRerunTests = useCallback(async () => { const fetchRerunTests = useCallback(async () => {
try { try {
const data = await api.getRerunTests() const data = await api.getRerunTests()
setFailedTests(data.tests ?? []) setFailedTests(data.tests ?? [])
} catch (e) { } catch (e) {
if (handleApiError(e)) return
console.error('Failed to fetch rerun tests:', e) console.error('Failed to fetch rerun tests:', e)
} }
}, []) }, [handleApiError])
// On mount: load settings + holidays + schedule for today's week
useEffect(() => { 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() { async function init() {
try { try {
const [saved, holidayData] = await Promise.all([ const holidayData = await api.getHolidays()
api.getSettings(), if (isAdmin) {
api.getHolidays(), const saved = await api.getSettings()
]) setSettings(() => ({
setSettings(() => ({ ...sanitizeSettings(saved),
...sanitizeSettings(saved), holidays: (holidayData.dates ?? []).join(', '),
holidays: (holidayData.dates ?? []).join(', '), }))
})) } else {
setSettings(() => ({
...DEFAULT_SETTINGS,
holidays: (holidayData.dates ?? []).join(', '),
}))
}
} catch (e) { } catch (e) {
if (handleApiError(e)) return
console.warn('Backend not reachable on load:', e.message) console.warn('Backend not reachable on load:', e.message)
} }
await fetchSchedule(getMondayOfWeek(new Date())) await fetchSchedule(getMondayOfWeek(new Date()))
await fetchRerunTests()
} }
init() init()
}, [fetchSchedule]) }, [isAuthenticated, isAdmin, fetchSchedule, fetchRerunTests, handleApiError])
// Refetch whenever the displayed week changes
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return
fetchSchedule(weekStart) fetchSchedule(weekStart)
}, [weekStart, fetchSchedule]) }, [isAuthenticated, weekStart, fetchSchedule])
// Keep calendar statuses fresh when backend watcher marks tests from new result folders.
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return
const timer = setInterval(() => { const timer = setInterval(() => {
fetchSchedule(weekStart) fetchSchedule(weekStart)
fetchRerunTests() fetchRerunTests()
}, 5000) }, 5000)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [weekStart, fetchSchedule, fetchRerunTests]) }, [isAuthenticated, weekStart, fetchSchedule, fetchRerunTests])
// Load rerun tests on mount async function handleLogin(event) {
useEffect(() => { event.preventDefault()
fetchRerunTests() setIsLoggingIn(true)
}, [fetchRerunTests]) 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) { async function handleSaveSettings(newSettings) {
setLoading(true) setLoading(true)
@@ -406,6 +498,7 @@ export default function App() {
setSettings(sanitizedSettings) setSettings(sanitizedSettings)
} catch (e) { } catch (e) {
if (handleApiError(e)) return
setError(e.message) setError(e.message)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -437,6 +530,7 @@ export default function App() {
await fetchSchedule(weekStart) await fetchSchedule(weekStart)
await fetchRerunTests() await fetchRerunTests()
} catch (e) { } catch (e) {
if (handleApiError(e)) return
setError(e.message) setError(e.message)
throw e throw e
} finally { } finally {
@@ -481,6 +575,7 @@ export default function App() {
setStartDateOverride('') setStartDateOverride('')
} catch (e) { } catch (e) {
if (handleApiError(e)) return
setError(e.message) setError(e.message)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -534,11 +629,76 @@ export default function App() {
setSelectedWindowId(null) 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 ( return (
<div className="flex flex-col min-h-screen bg-gray-950 text-gray-100"> <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 && ( {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"> <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} daytimeDateKey={daytimeDateKey}
weekStart={weekStart} weekStart={weekStart}
onWeekChange={setWeekStart} onWeekChange={setWeekStart}
isViewer={isViewer}
dualDeviceWeekendWeekEnabled={dualDeviceWeekendWeekEnabled} dualDeviceWeekendWeekEnabled={dualDeviceWeekendWeekEnabled}
onDualDeviceWeekendWeekEnabledChange={(enabled) => { onDualDeviceWeekendWeekEnabledChange={(enabled) => {
setDualDeviceWeekendWeekSelections((prev) => ({ setDualDeviceWeekendWeekSelections((prev) => ({
@@ -592,22 +753,26 @@ export default function App() {
onRemakeSchedule={handleRemakeSchedule} onRemakeSchedule={handleRemakeSchedule}
loading={loading} loading={loading}
tonightConfigRows={tonightConfigRows} tonightConfigRows={tonightConfigRows}
isViewer={isViewer}
/> />
<TestWindowDetailsPanel <TestWindowDetailsPanel
isOpen={Boolean(selectedWindow)} isOpen={Boolean(selectedWindow)}
windowDetails={selectedWindow} windowDetails={selectedWindow}
onClose={handleCloseWindowDetails} onClose={handleCloseWindowDetails}
isViewer={isViewer}
/> />
</main> </main>
<SettingsModal {isAdmin ? (
isOpen={settingsOpen} <SettingsModal
onClose={() => setSettingsOpen(false)} isOpen={settingsOpen}
settings={settings} onClose={() => setSettingsOpen(false)}
onSave={handleSaveSettings} settings={settings}
onRestart={handleRestartAndClearData} onSave={handleSaveSettings}
/> onRestart={handleRestartAndClearData}
/>
) : null}
</div> </div>
) )
} }
+27 -4
View File
@@ -1,14 +1,24 @@
const BASE = '/api' const BASE = '/api'
function buildAuthHeaders() {
const token = sessionStorage.getItem('auth_token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function request(method, path, body) { async function request(method, path, body) {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
method, method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...buildAuthHeaders(),
},
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })) 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() return res.json()
} }
@@ -17,6 +27,13 @@ export const api = {
// Health // Health
health: () => request('GET', '/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 // Settings
getSettings: () => request('GET', '/settings'), getSettings: () => request('GET', '/settings'),
saveSettings: (settings) => request('POST', '/settings', { settings }), saveSettings: (settings) => request('POST', '/settings', { settings }),
@@ -42,10 +59,16 @@ export const api = {
exportWindow: async (windowId) => { exportWindow: async (windowId) => {
const params = new URLSearchParams({ window_id: 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) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })) 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 blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') ?? '' const disposition = res.headers.get('Content-Disposition') ?? ''
+21 -18
View File
@@ -54,6 +54,7 @@ export default function Calendar({
daytimeDateKey = null, daytimeDateKey = null,
weekStart, weekStart,
onWeekChange, onWeekChange,
isViewer = false,
dualDeviceWeekendWeekEnabled = false, dualDeviceWeekendWeekEnabled = false,
onDualDeviceWeekendWeekEnabledChange, onDualDeviceWeekendWeekEnabledChange,
windowLookup = new Map(), windowLookup = new Map(),
@@ -155,25 +156,27 @@ export default function Calendar({
> >
Today Today
</button> </button>
<div className="ml-auto flex items-center gap-2 text-xs text-gray-300 select-none"> {!isViewer ? (
<span>Run both devices on weekend this week</span> <div className="ml-auto flex items-center gap-2 text-xs text-gray-300 select-none">
<button <span>Run both devices on weekend this week</span>
type="button" <button
role="switch" type="button"
aria-checked={dualDeviceWeekendWeekEnabled} role="switch"
onClick={() => onDualDeviceWeekendWeekEnabledChange?.(!dualDeviceWeekendWeekEnabled)} aria-checked={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 ${ onClick={() => onDualDeviceWeekendWeekEnabledChange?.(!dualDeviceWeekendWeekEnabled)}
dualDeviceWeekendWeekEnabled ? 'bg-blue-600' : 'bg-gray-600' 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'
}`} }`}
/> title="Toggle dual-device weekend-start scheduling for this week"
</button> >
</div> <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> </div>
{/* 7-day grid */} {/* 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 ( return (
<header className="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-700 shrink-0"> <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"> <h1 className="text-white font-semibold text-lg tracking-wide">
NJTH Scheduler NJTH Scheduler
</h1> </h1>
<button <div className="flex items-center gap-2">
onClick={onOpenSettings} {username ? (
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" <span className="text-xs text-gray-400">{username}</span>
title="Settings" ) : null}
> {showSettings ? (
<svg <button
xmlns="http://www.w3.org/2000/svg" onClick={onOpenSettings}
className="w-4 h-4" 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"
fill="none" title="Settings"
viewBox="0 0 24 24" >
stroke="currentColor" <svg
strokeWidth={1.8} 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 Logout
strokeLinecap="round" </button>
strokeLinejoin="round" </div>
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>
</header> </header>
) )
} }
+20
View File
@@ -34,10 +34,30 @@ export default function RightPanel({
onRemakeSchedule, onRemakeSchedule,
tonightConfigRows = [], tonightConfigRows = [],
loading = false, loading = false,
isViewer = false,
}) { }) {
const formattedCompletionDate = formatCompletionDate(completionDate) const formattedCompletionDate = formatCompletionDate(completionDate)
const [topPriorityOpen, setTopPriorityOpen] = useState(false) 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 ( return (
<aside className="flex flex-col gap-4 w-60 shrink-0 pt-8"> <aside className="flex flex-col gap-4 w-60 shrink-0 pt-8">
{/* Device Image */} {/* Device Image */}
+229 -9
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { api } from '../api'
function Field({ label, hint, required = false, children }) { function Field({ label, hint, required = false, children }) {
return ( return (
@@ -18,24 +19,42 @@ const INPUT_CLS =
export default function SettingsModal({ isOpen, onClose, settings, onSave, onRestart }) { export default function SettingsModal({ isOpen, onClose, settings, onSave, onRestart }) {
const [form, setForm] = useState({ ...settings }) const [form, setForm] = useState({ ...settings })
const [activeTab, setActiveTab] = useState('general')
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmText, setConfirmText] = useState('') const [confirmText, setConfirmText] = useState('')
const [restartBusy, setRestartBusy] = useState(false) const [restartBusy, setRestartBusy] = useState(false)
const [restartError, setRestartError] = useState(null) 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 // Sync if parent settings change while modal is closed
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setForm({ ...settings }) setForm({ ...settings })
setActiveTab('general')
setConfirmOpen(false) setConfirmOpen(false)
setConfirmText('') setConfirmText('')
setRestartBusy(false) setRestartBusy(false)
setRestartError(null) setRestartError(null)
setUsers([])
setUsersLoading(false)
setUsersError(null)
setUsersMessage(null)
setNewUsername('')
setNewPassword('')
setNewRole('viewer')
setCreatingUser(false)
setDeletingUsername('')
} }
}, [isOpen, settings]) }, [isOpen, settings])
if (!isOpen) return null
function set(key, value) { function set(key, value) {
setForm((f) => ({ ...f, [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 ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" 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> </div>
{/* Scrollable body */} {/* 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 */} {/* Section: Paths */}
<div> <div>
@@ -363,6 +469,118 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave, onRes
</div> </div>
</div> </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> </div>
{/* Footer */} {/* Footer */}
@@ -373,12 +591,14 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave, onRes
> >
Cancel Cancel
</button> </button>
<button {activeTab === 'general' && (
onClick={handleSave} <button
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" 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> Save
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -100,9 +100,14 @@ function formatConfigEntries(config) {
function getConfigMapImages(configKey) { function getConfigMapImages(configKey) {
if (!configKey) return [] if (!configKey) return []
const fileName = `${configKey.toUpperCase()}.png` const key = configKey.toUpperCase()
return Object.entries(CONFIG_MAP_IMAGE_MODULES) 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) .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 [showConfigMap, setShowConfigMap] = useState(false)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [exportError, setExportError] = useState(null) const [exportError, setExportError] = useState(null)
@@ -264,24 +269,26 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
</h2> </h2>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button {!isViewer ? (
type="button" <button
onClick={handleExport} type="button"
disabled={isExporting || !windowDetails?.tests?.length} onClick={handleExport}
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" disabled={isExporting || !windowDetails?.tests?.length}
aria-label="Export .ini files for this window" 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"> {isExporting ? (
<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 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">
</svg> <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 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">
</svg> <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> {isExporting ? 'Exporting…' : 'Export Tests'}
</button>
) : null}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}