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 ( +
+

Checking session...

+
+ ) + } + + if (!isAuthenticated) { + return ( +
+
+
+

Sign in

+
+ + {loginError && ( +
+ {loginError} +
+ )} + + + + + + +
+
+ ) + } + 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