login system
This commit is contained in:
@@ -42,6 +42,7 @@ server/__pycache__/
|
||||
server/dashboard.db*
|
||||
server/*.pyc
|
||||
*.pyc
|
||||
server/.env
|
||||
|
||||
# Test output
|
||||
test/
|
||||
Generated
+58
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+108
-7
@@ -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,16 +61,101 @@ 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 (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-200 flex items-center justify-center">
|
||||
<p className="text-sm text-slate-400">Checking 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={(e) => setUsername(e.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={(e) => setPassword(e.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="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
||||
{/* Top bar */}
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400 hidden md:inline">
|
||||
{user?.username} ({user?.role})
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setShowConfig(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
@@ -69,6 +163,13 @@ export default function App() {
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -155,7 +256,7 @@ export default function App() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
|
||||
{isAdmin && showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
dashboard.db*
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+210
-3
@@ -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"))
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user