implemented login
This commit is contained in:
@@ -1 +1,11 @@
|
||||
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
@@ -1,8 +1,8 @@
|
||||
/backend/.venv
|
||||
/backend/__pycache__
|
||||
**/__pycache__
|
||||
/backend/output.txt
|
||||
/backend/.env
|
||||
/backend/tests
|
||||
/backend/.pytest_cache
|
||||
*.db
|
||||
.env
|
||||
**/.pytest_cache
|
||||
@@ -4,3 +4,4 @@ pydantic==2.11.7
|
||||
watchdog==6.0.0
|
||||
httpx==0.28.1
|
||||
smbprotocol==1.15.0
|
||||
PyJWT==2.10.1
|
||||
|
||||
+128
-13
@@ -6,10 +6,11 @@ import io
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi import Depends, FastAPI, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import auth
|
||||
import db as db
|
||||
from parser import CsvValidationError
|
||||
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()
|
||||
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):
|
||||
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")
|
||||
@@ -40,6 +61,17 @@ class SaveSettingsRequest(BaseModel):
|
||||
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]:
|
||||
return {
|
||||
"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
|
||||
|
||||
|
||||
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
|
||||
async def lifespan(application: FastAPI):
|
||||
auth.validate_auth_environment()
|
||||
db.init_db(DB_PATH)
|
||||
_bootstrap_default_users()
|
||||
settings = db.read_settings(DB_PATH)
|
||||
configure_result_watcher(settings)
|
||||
try:
|
||||
@@ -308,20 +351,92 @@ def health() -> dict[str, str]:
|
||||
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")
|
||||
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)
|
||||
configure_result_watcher(db.read_settings(DB_PATH))
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
configure_result_watcher({})
|
||||
return {
|
||||
@@ -331,7 +446,7 @@ def restart_and_clear_data() -> dict[str, Any]:
|
||||
|
||||
|
||||
@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] = []
|
||||
if request.csv_path and 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")
|
||||
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.
|
||||
# Keep endpoint for compatibility with frontend calls.
|
||||
return {"status": "ok", "removed": len(request.test_ids)}
|
||||
|
||||
|
||||
@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:
|
||||
try:
|
||||
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")
|
||||
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)
|
||||
tests = db.get_rerun_tests(DB_PATH)
|
||||
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")
|
||||
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()]
|
||||
db.upsert_holidays(dates, DB_PATH)
|
||||
return {"status": "saved", "count": len(dates)}
|
||||
|
||||
|
||||
@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))}
|
||||
|
||||
|
||||
@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)}
|
||||
|
||||
|
||||
@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()
|
||||
try:
|
||||
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")
|
||||
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)
|
||||
if version is not None and resolved_version is None:
|
||||
raise HTTPException(status_code=404, detail=f"Schedule version {version} was not found")
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,9 @@ import json
|
||||
import sqlite3
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -18,6 +21,7 @@ REF = os.getenv("REF", "CGW452").strip()
|
||||
|
||||
# Device names for the test database (use hardware device names)
|
||||
MAX_SCHEDULE_VERSIONS = 50
|
||||
PBKDF2_ITERATIONS = 390000
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestRecord:
|
||||
@@ -53,6 +57,21 @@ class ScheduleRow:
|
||||
status: str
|
||||
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
|
||||
def get_connection(db_path: str | Path = DB_PATH) -> Iterator[sqlite3.Connection]:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
@@ -122,8 +141,18 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
|
||||
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_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:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
@@ -9,6 +9,13 @@ services:
|
||||
HOST_MOUNT_ROOT: /host
|
||||
DB_PATH: /app/data/scheduler.db
|
||||
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:
|
||||
- ${HOST_BROWSE_ROOT}:/host
|
||||
- scheduler-db:/app/runtime
|
||||
|
||||
+185
-20
@@ -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(),
|
||||
])
|
||||
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,15 +753,18 @@ export default function App() {
|
||||
onRemakeSchedule={handleRemakeSchedule}
|
||||
loading={loading}
|
||||
tonightConfigRows={tonightConfigRows}
|
||||
isViewer={isViewer}
|
||||
/>
|
||||
|
||||
<TestWindowDetailsPanel
|
||||
isOpen={Boolean(selectedWindow)}
|
||||
windowDetails={selectedWindow}
|
||||
onClose={handleCloseWindowDetails}
|
||||
isViewer={isViewer}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{isAdmin ? (
|
||||
<SettingsModal
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
@@ -608,6 +772,7 @@ export default function App() {
|
||||
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,6 +156,7 @@ export default function Calendar({
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
{!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
|
||||
@@ -174,6 +176,7 @@ export default function Calendar({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 7-day grid */}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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>
|
||||
<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"
|
||||
@@ -30,6 +35,16 @@ export default function Header({ onOpenSettings }) {
|
||||
</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"
|
||||
>
|
||||
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>
|
||||
{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,6 +269,7 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isViewer ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
@@ -282,6 +288,7 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
|
||||
)}
|
||||
{isExporting ? 'Exporting…' : 'Export Tests'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
||||
Reference in New Issue
Block a user