diff --git a/.gitignore b/.gitignore
index fc8a770..e1cb9c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ server/__pycache__/
server/dashboard.db*
server/*.pyc
*.pyc
+server/.env
# Test output
test/
\ No newline at end of file
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 4b33272..939c6b0 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -13,6 +13,7 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-router-dom": "^7.16.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
@@ -1340,6 +1341,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2437,6 +2451,44 @@
"react": "^19.2.6"
}
},
+ "node_modules/react-router": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
+ "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
+ "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.16.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2486,6 +2538,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index bd24a44..2ebb0d0 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -15,6 +15,7 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-router-dom": "^7.16.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx
index fa49f9f..beb0e9f 100644
--- a/dashboard/src/App.jsx
+++ b/dashboard/src/App.jsx
@@ -1,8 +1,10 @@
-import { useState, useMemo } from 'react'
+import { useMemo, useState } from 'react'
import { Settings } from 'lucide-react'
+import { useQueryClient } from '@tanstack/react-query'
import { useStats } from './hooks/useStats'
import { useTests } from './hooks/useTests'
import { useConfig } from './hooks/useConfig'
+import { useAuth } from './hooks/useAuth'
import cgw453Image from './assets/CGW453.PNG'
import StatCard from './components/StatCard'
import CompletionBar from './components/CompletionBar'
@@ -24,12 +26,19 @@ function hasCoePairs(value) {
}
export default function App() {
+ const queryClient = useQueryClient()
+ const { user, isReady, isAuthenticated, isAdmin, login, logout } = useAuth()
+
const [showConfig, setShowConfig] = useState(false)
const [filters, setFilters] = useState({})
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [loginError, setLoginError] = useState('')
+ const [isLoggingIn, setIsLoggingIn] = useState(false)
- const { data: stats, isLoading: statsLoading } = useStats()
- const { data: allTests = [], isLoading: testsLoading } = useTests()
- const { data: config, isLoading: configLoading } = useConfig()
+ const { data: stats, isLoading: statsLoading } = useStats(isAuthenticated)
+ const { data: allTests = [], isLoading: testsLoading } = useTests({}, isAuthenticated)
+ const { data: config, isLoading: configLoading } = useConfig(isAuthenticated && isAdmin)
// Apply filters client-side
const filteredTests = useMemo(() => {
@@ -52,22 +61,114 @@ export default function App() {
})
}, [allTests, filters])
- const noConfig = !configLoading && !config?.target_dir && !config?.results_dir
- const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir &&
+ const noConfig = isAdmin && !configLoading && !config?.target_dir && !config?.results_dir
+ const configuredButEmpty = isAdmin && !configLoading && config?.target_dir && config?.results_dir &&
!statsLoading && stats?.overall.total === 0
+ async function handleLogin(e) {
+ e.preventDefault()
+ setLoginError('')
+ setIsLoggingIn(true)
+ try {
+ await login(username.trim(), password)
+ setPassword('')
+ } catch (err) {
+ setLoginError(err?.message ?? 'Login failed')
+ } finally {
+ setIsLoggingIn(false)
+ }
+ }
+
+ function handleLogout() {
+ logout()
+ queryClient.clear()
+ setShowConfig(false)
+ setFilters({})
+ }
+
+ if (!isReady) {
+ return (
+
+ )
+ }
+
+ if (!isAuthenticated) {
+ return (
+
+ )
+ }
+
return (
{/* Top bar */}
CGW453 Test Dashboard
+
+ {user?.username} ({user?.role})
+
+ {isAdmin && (
+
+ )}
@@ -155,7 +256,7 @@ export default function App() {
- {showConfig && setShowConfig(false)} />}
+ {isAdmin && showConfig && setShowConfig(false)} />}
)
}
diff --git a/dashboard/src/hooks/useAuth.js b/dashboard/src/hooks/useAuth.js
new file mode 100644
index 0000000..44d02c7
--- /dev/null
+++ b/dashboard/src/hooks/useAuth.js
@@ -0,0 +1,74 @@
+import { createContext, createElement, useContext, useEffect, useMemo, useState } from 'react'
+import { getMe, getStoredToken, login as loginApi, setStoredToken } from '../lib/api'
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }) {
+ const [user, setUser] = useState(null)
+ const [isReady, setIsReady] = useState(false)
+
+ useEffect(() => {
+ async function initializeAuth() {
+ const token = getStoredToken()
+ if (!token) {
+ setIsReady(true)
+ return
+ }
+
+ try {
+ const data = await getMe()
+ setUser(data?.user ?? null)
+ } catch {
+ setStoredToken(null)
+ setUser(null)
+ } finally {
+ setIsReady(true)
+ }
+ }
+
+ initializeAuth()
+ }, [])
+
+ useEffect(() => {
+ function handleUnauthorized() {
+ setUser(null)
+ }
+
+ window.addEventListener('auth:unauthorized', handleUnauthorized)
+ return () => window.removeEventListener('auth:unauthorized', handleUnauthorized)
+ }, [])
+
+ async function login(username, password) {
+ const data = await loginApi({ username, password })
+ setStoredToken(data?.token)
+ setUser(data?.user ?? null)
+ return data?.user ?? null
+ }
+
+ function logout() {
+ setStoredToken(null)
+ setUser(null)
+ }
+
+ const value = useMemo(
+ () => ({
+ user,
+ isReady,
+ isAuthenticated: Boolean(user),
+ isAdmin: user?.role === 'admin',
+ login,
+ logout,
+ }),
+ [user, isReady]
+ )
+
+ return createElement(AuthContext.Provider, { value }, children)
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext)
+ if (!context) {
+ throw new Error('useAuth must be used inside AuthProvider')
+ }
+ return context
+}
diff --git a/dashboard/src/hooks/useConfig.js b/dashboard/src/hooks/useConfig.js
index edf6549..6a945a1 100644
--- a/dashboard/src/hooks/useConfig.js
+++ b/dashboard/src/hooks/useConfig.js
@@ -1,8 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getConfig, saveConfig } from '../lib/api'
-export function useConfig() {
- return useQuery({ queryKey: ['config'], queryFn: getConfig })
+export function useConfig(enabled = true) {
+ return useQuery({ queryKey: ['config'], queryFn: getConfig, enabled })
}
export function useSaveConfig() {
diff --git a/dashboard/src/hooks/useStats.js b/dashboard/src/hooks/useStats.js
index 5837378..cfb2b7a 100644
--- a/dashboard/src/hooks/useStats.js
+++ b/dashboard/src/hooks/useStats.js
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getStats } from '../lib/api'
-export function useStats() {
+export function useStats(enabled = true) {
const statsQuery = useQuery({
queryKey: ['stats'],
queryFn: getStats,
+ enabled,
})
return {
diff --git a/dashboard/src/hooks/useTests.js b/dashboard/src/hooks/useTests.js
index 9adfb4c..528b248 100644
--- a/dashboard/src/hooks/useTests.js
+++ b/dashboard/src/hooks/useTests.js
@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getTests } from '../lib/api'
-export function useTests(filters = {}) {
+export function useTests(filters = {}, enabled = true) {
return useQuery({
queryKey: ['tests', filters],
queryFn: () => getTests(filters),
keepPreviousData: true,
+ enabled,
})
}
diff --git a/dashboard/src/lib/api.js b/dashboard/src/lib/api.js
index e69b048..6f53148 100644
--- a/dashboard/src/lib/api.js
+++ b/dashboard/src/lib/api.js
@@ -1,10 +1,33 @@
const BASE = '/api'
+const TOKEN_KEY = 'dashboard_jwt'
+
+export function getStoredToken() {
+ return localStorage.getItem(TOKEN_KEY)
+}
+
+export function setStoredToken(token) {
+ if (!token) {
+ localStorage.removeItem(TOKEN_KEY)
+ return
+ }
+ localStorage.setItem(TOKEN_KEY, token)
+}
+
export async function apiFetch(path, options = {}) {
+ const token = getStoredToken()
+ const authHeader = token ? { Authorization: `Bearer ${token}` } : {}
+ const { headers: customHeaders = {}, ...restOptions } = options
const res = await fetch(`${BASE}${path}`, {
- headers: { 'Content-Type': 'application/json', ...options.headers },
- ...options,
+ ...restOptions,
+ headers: { 'Content-Type': 'application/json', ...authHeader, ...customHeaders },
})
+
+ if (res.status === 401) {
+ setStoredToken(null)
+ window.dispatchEvent(new Event('auth:unauthorized'))
+ }
+
if (!res.ok) {
const text = await res.text().catch(() => res.statusText)
throw new Error(text || res.statusText)
@@ -12,6 +35,9 @@ export async function apiFetch(path, options = {}) {
return res.json()
}
+export const login = (body) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(body) })
+export const getMe = () => apiFetch('/auth/me')
+
export const getStats = () => apiFetch('/stats')
export const getTests = (params = {}) => {
const qs = new URLSearchParams(
diff --git a/dashboard/src/main.jsx b/dashboard/src/main.jsx
index 1e93110..1e2e46c 100644
--- a/dashboard/src/main.jsx
+++ b/dashboard/src/main.jsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.jsx'
+import { AuthProvider } from './hooks/useAuth'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000, retry: 1 } },
@@ -11,7 +12,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')).render(
-
+
+
+
,
)
diff --git a/server/.dockerignore b/server/.dockerignore
index 418543d..60e602f 100644
--- a/server/.dockerignore
+++ b/server/.dockerignore
@@ -1,4 +1,5 @@
.venv
__pycache__
*.pyc
-dashboard.db*
\ No newline at end of file
+dashboard.db*
+.env
\ No newline at end of file
diff --git a/server/.env b/server/.env
deleted file mode 100644
index dd00d95..0000000
--- a/server/.env
+++ /dev/null
@@ -1,5 +0,0 @@
-# Flask backend port (default: 3001)
-PORT=3001
-
-# Note: Target and results directories are configured via the dashboard Settings UI
-# and stored in SQLite (dashboard.db), not in environment variables.
diff --git a/server/.env.template b/server/.env.template
new file mode 100644
index 0000000..8dff798
--- /dev/null
+++ b/server/.env.template
@@ -0,0 +1,11 @@
+# Flask backend port (default: 3001)
+PORT=3001
+
+JWT_SECRET=
+JWT_EXPIRES_HOURS=8
+DEFAULT_ADMIN_USERNAME=wnc
+DEFAULT_ADMIN_PASSWORD=@wnc111111
+JWT_ALGORITHM=HS256
+
+DEFAULT_VIEWER_USERNAME=viewer
+DEFAULT_VIEWER_PASSWORD=viewer3040
\ No newline at end of file
diff --git a/server/app.py b/server/app.py
index e257163..8a4bff6 100644
--- a/server/app.py
+++ b/server/app.py
@@ -1,13 +1,41 @@
import os
+from datetime import datetime, timedelta, timezone
+from functools import wraps
from pathlib import Path
-from flask import Flask, jsonify, request, send_from_directory
+import jwt
+from dotenv import load_dotenv
+from flask import Flask, g, jsonify, request, send_from_directory
from flask_cors import CORS
+from werkzeug.security import check_password_hash, generate_password_hash
-from db_py import count_tests, del_config, get_all_tests, get_config, set_config
+from db_py import (
+ count_tests,
+ count_users,
+ create_user,
+ del_config,
+ get_all_tests,
+ get_all_users,
+ get_config,
+ get_user_by_id,
+ get_user_by_username,
+ set_config,
+ clear_users,
+)
from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only
+BASE_DIR = Path(__file__).resolve().parent
+load_dotenv(BASE_DIR / ".env")
+
PORT = int(os.getenv("PORT", "3001"))
+JWT_SECRET = os.getenv("JWT_SECRET")
+JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
+JWT_EXPIRES_HOURS = int(os.getenv("JWT_EXPIRES_HOURS", "8"))
+DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME")
+DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD")
+DEFAULT_VIEWER_USERNAME = os.getenv("DEFAULT_VIEWER_USERNAME")
+DEFAULT_VIEWER_PASSWORD = os.getenv("DEFAULT_VIEWER_PASSWORD")
+
ALLOWED_KEYS = {
"target_dir",
"results_dir",
@@ -21,13 +49,110 @@ ALLOWED_KEYS = {
"smb_domain",
}
-BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR.parent / "dashboard" / "dist"
app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
CORS(app)
+def _make_token(user):
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": str(user["id"]),
+ "username": user["username"],
+ "role": user["role"],
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(hours=JWT_EXPIRES_HOURS)).timestamp()),
+ }
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+
+def _decode_token(token):
+ try:
+ return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ except jwt.InvalidTokenError:
+ return None
+
+
+def _extract_bearer_token():
+ auth_header = request.headers.get("Authorization", "")
+ if not auth_header.lower().startswith("bearer "):
+ return None
+ return auth_header[7:].strip() or None
+
+
+def require_auth(fn):
+ @wraps(fn)
+ def wrapper(*args, **kwargs):
+ token = _extract_bearer_token()
+ if not token:
+ return jsonify({"error": "Authentication required"}), 401
+
+ payload = _decode_token(token)
+ if payload is None:
+ return jsonify({"error": "Invalid or expired token"}), 401
+
+ try:
+ user_id = int(payload.get("sub"))
+ except (TypeError, ValueError):
+ return jsonify({"error": "Invalid token subject"}), 401
+
+ user = get_user_by_id(user_id)
+ if not user or not user.get("is_active"):
+ return jsonify({"error": "User is not authorized"}), 401
+
+ g.current_user = {
+ "id": user["id"],
+ "username": user["username"],
+ "role": user["role"],
+ }
+ return fn(*args, **kwargs)
+
+ return wrapper
+
+
+def require_role(required_role):
+ def decorator(fn):
+ @wraps(fn)
+ def wrapper(*args, **kwargs):
+ current_user = getattr(g, "current_user", None)
+ if not current_user:
+ return jsonify({"error": "Authentication required"}), 401
+ if current_user.get("role") != required_role:
+ return jsonify({"error": "Forbidden"}), 403
+ return fn(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+def _create_default_user_if_missing(username, password, role):
+ if not username or not password:
+ print(f"[server] Skipping default {role} seed: username/password not configured")
+ return
+
+ existing = get_user_by_username(username)
+ if existing:
+ return
+
+ create_user(
+ username,
+ generate_password_hash(password),
+ role=role,
+ is_active=1,
+ )
+ print(f"[server] Created default {role} user: {username}")
+
+
+def _ensure_default_users():
+ if count_users() == 0:
+ print("[server] No users found. Seeding default accounts...")
+
+ _create_default_user_if_missing(DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, "admin")
+ _create_default_user_if_missing(DEFAULT_VIEWER_USERNAME, DEFAULT_VIEWER_PASSWORD, "viewer")
+
+
def _apply_smb_env_from_config():
mapping = {
"SMB_USERNAME": get_config("smb_username"),
@@ -43,6 +168,7 @@ def _apply_smb_env_from_config():
@app.get("/api/tests")
+@require_auth
def get_tests_route():
completed = request.args.get("completed")
interference = request.args.get("interference")
@@ -90,6 +216,7 @@ def get_tests_route():
@app.get("/api/stats")
+@require_auth
def get_stats_route():
tests = get_all_tests()
types = ["COE", "P2P", "P3P"]
@@ -197,11 +324,84 @@ def get_stats_route():
@app.get("/api/scan-status")
+@require_auth
def get_scan_status_route():
return jsonify({"scanning": is_scan_in_progress()})
+@app.post("/api/auth/login")
+def auth_login_route():
+ body = request.get_json(silent=True)
+ if not isinstance(body, dict):
+ return jsonify({"error": "Request body must be a JSON object"}), 400
+
+ username = (body.get("username") or "").strip()
+ password = body.get("password") or ""
+
+ if not username or not password:
+ return jsonify({"error": "Username and password are required"}), 400
+
+ user = get_user_by_username(username)
+ if not user or not user.get("is_active"):
+ return jsonify({"error": "Invalid username or password"}), 401
+
+ if not check_password_hash(user["password_hash"], password):
+ return jsonify({"error": "Invalid username or password"}), 401
+
+ token = _make_token(user)
+ return jsonify(
+ {
+ "token": token,
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "role": user["role"],
+ },
+ }
+ )
+
+
+@app.get("/api/auth/me")
+@require_auth
+def auth_me_route():
+ return jsonify({"user": g.current_user})
+
+
+@app.get("/api/users")
+@require_auth
+@require_role("admin")
+def list_users_route():
+ return jsonify(get_all_users())
+
+
+@app.post("/api/users")
+@require_auth
+@require_role("admin")
+def create_user_route():
+ body = request.get_json(silent=True)
+ if not isinstance(body, dict):
+ return jsonify({"error": "Request body must be a JSON object"}), 400
+
+ username = (body.get("username") or "").strip()
+ password = body.get("password") or ""
+ role = (body.get("role") or "viewer").strip().lower()
+
+ if not username or not password:
+ return jsonify({"error": "Username and password are required"}), 400
+
+ if role not in {"admin", "viewer"}:
+ return jsonify({"error": "Role must be admin or viewer"}), 400
+
+ if get_user_by_username(username):
+ return jsonify({"error": "User already exists"}), 409
+
+ user_id = create_user(username, generate_password_hash(password), role=role, is_active=1)
+ return jsonify({"id": user_id, "username": username, "role": role, "is_active": 1}), 201
+
+
@app.get("/api/config")
+@require_auth
+@require_role("admin")
def get_config_route():
config = {}
for key in ALLOWED_KEYS:
@@ -210,6 +410,8 @@ def get_config_route():
@app.post("/api/config")
+@require_auth
+@require_role("admin")
def set_config_route():
updates = request.get_json(silent=True)
if not isinstance(updates, dict):
@@ -249,6 +451,8 @@ def set_config_route():
@app.post("/api/config/rescan")
+@require_auth
+@require_role("admin")
def rescan_route():
_apply_smb_env_from_config()
target_dir = resolve_runtime_path(get_config("target_dir"))
@@ -267,6 +471,8 @@ def rescan_route():
@app.post("/api/config/rescan-results")
+@require_auth
+@require_role("admin")
def rescan_results_route():
_apply_smb_env_from_config()
results_dir = resolve_runtime_path(get_config("results_dir"))
@@ -295,6 +501,7 @@ def static_or_spa(path=""):
def bootstrap():
+ _ensure_default_users()
_apply_smb_env_from_config()
target_dir = resolve_runtime_path(get_config("target_dir"))
results_dir = resolve_runtime_path(get_config("results_dir"))
diff --git a/server/dashboard.db b/server/dashboard.db
deleted file mode 100644
index bff3e1c..0000000
Binary files a/server/dashboard.db and /dev/null differ
diff --git a/server/db_py.py b/server/db_py.py
index d956262..24ca79e 100644
--- a/server/db_py.py
+++ b/server/db_py.py
@@ -54,8 +54,18 @@ def _init_db():
duration_seconds REAL
);
+ 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,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
CREATE INDEX IF NOT EXISTS idx_tests_test_id ON tests (test_id);
CREATE INDEX IF NOT EXISTS idx_tests_device ON tests (device);
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
"""
)
@@ -278,4 +288,64 @@ def count_tests():
return row["n"]
+def get_user_by_username(username):
+ with _lock:
+ row = _conn.execute(
+ """
+ SELECT id, username, password_hash, role, is_active, created_at
+ FROM users
+ WHERE username = ?
+ """,
+ (username,),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def create_user(username, password_hash, role="viewer", is_active=1):
+ with _tx():
+ cursor = _conn.execute(
+ """
+ INSERT INTO users (username, password_hash, role, is_active)
+ VALUES (?, ?, ?, ?)
+ """,
+ (username, password_hash, role, is_active),
+ )
+ return cursor.lastrowid
+
+
+def count_users():
+ with _lock:
+ row = _conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()
+ return row["n"]
+
+
+def get_user_by_id(user_id):
+ with _lock:
+ row = _conn.execute(
+ """
+ SELECT id, username, password_hash, role, is_active, created_at
+ FROM users
+ WHERE id = ?
+ """,
+ (user_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def get_all_users():
+ with _lock:
+ rows = _conn.execute(
+ """
+ SELECT id, username, role, is_active, created_at
+ FROM users
+ ORDER BY username ASC
+ """
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+def clear_users():
+ with _tx():
+ _conn.execute("DELETE FROM users")
+
+
_init_db()
diff --git a/server/requirements.txt b/server/requirements.txt
index bad148b..80ff2f4 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -1,3 +1,5 @@
Flask>=3.0.0,<4.0.0
Flask-Cors>=4.0.1,<5.0.0
smbprotocol>=1.13.0,<2.0.0
+PyJWT>=2.9.0,<3.0.0
+python-dotenv>=1.0.1,<2.0.0