read log files from end, added user management
This commit is contained in:
@@ -256,7 +256,12 @@ export default function App() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isAdmin && showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
|
||||
{isAdmin && showConfig && (
|
||||
<ConfigModal
|
||||
onClose={() => setShowConfig(false)}
|
||||
currentUserId={user?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
||||
import { apiFetch } from '../lib/api'
|
||||
import { apiFetch, createUser, deleteUser, getUsers, setUserPassword } from '../lib/api'
|
||||
|
||||
function useScanPoller(onDone) {
|
||||
const timerRef = useRef(null)
|
||||
@@ -42,17 +42,37 @@ function fmtSeconds(s) {
|
||||
return String(m)
|
||||
}
|
||||
|
||||
export default function ConfigModal({ onClose }) {
|
||||
function getErrorMessage(err, fallback) {
|
||||
if (!err?.message) return fallback
|
||||
try {
|
||||
const parsed = JSON.parse(err.message)
|
||||
if (parsed?.error) return parsed.error
|
||||
} catch {
|
||||
// Keep original error text when not JSON.
|
||||
}
|
||||
return err.message || fallback
|
||||
}
|
||||
|
||||
export default function ConfigModal({ onClose, currentUserId = null }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: config, isLoading } = useConfig()
|
||||
const { mutate: save, isPending } = useSaveConfig()
|
||||
|
||||
const [activeTab, setActiveTab] = useState('general')
|
||||
const [form, setForm] = useState({})
|
||||
const [scanResult, setScanResult] = useState(null)
|
||||
const [saveError, setSaveError] = useState(null)
|
||||
const [isRescanning, setIsRescanning] = useState(false)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [isSavingTimes, setIsSavingTimes] = useState(false)
|
||||
const [users, setUsers] = useState([])
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [usersError, setUsersError] = useState('')
|
||||
const [usersNotice, setUsersNotice] = useState('')
|
||||
const [isCreatingUser, setIsCreatingUser] = useState(false)
|
||||
const [isSettingPassword, setIsSettingPassword] = useState(false)
|
||||
const [deletingUserId, setDeletingUserId] = useState(null)
|
||||
const [createForm, setCreateForm] = useState({ username: '', password: '', role: 'viewer' })
|
||||
const [passwordForm, setPasswordForm] = useState({ userId: '', password: '' })
|
||||
|
||||
const scanPoller = useScanPoller(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] })
|
||||
@@ -80,7 +100,6 @@ export default function ConfigModal({ onClose }) {
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
const payload = {
|
||||
target_dir: form.target_dir || null,
|
||||
results_dir: form.results_dir || null,
|
||||
@@ -111,7 +130,6 @@ export default function ConfigModal({ onClose }) {
|
||||
|
||||
async function handleSaveTimes() {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
setIsSavingTimes(true)
|
||||
try {
|
||||
await apiFetch('/config', {
|
||||
@@ -134,7 +152,6 @@ export default function ConfigModal({ onClose }) {
|
||||
|
||||
async function handleRescanResults() {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
setIsRescanning(true)
|
||||
try {
|
||||
const data = await apiFetch('/config/rescan-results', { method: 'POST' })
|
||||
@@ -157,16 +174,133 @@ export default function ConfigModal({ onClose }) {
|
||||
{ key: 'avg_time_p3p', label: 'P3P avg time (min)' },
|
||||
]
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setUsersLoading(true)
|
||||
setUsersError('')
|
||||
try {
|
||||
const data = await getUsers()
|
||||
setUsers(Array.isArray(data) ? data : [])
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
userId: prev.userId || (data?.[0]?.id ? String(data[0].id) : ''),
|
||||
}))
|
||||
} catch (err) {
|
||||
setUsersError(getErrorMessage(err, 'Failed to load users'))
|
||||
} finally {
|
||||
setUsersLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'users') {
|
||||
loadUsers()
|
||||
}
|
||||
}, [activeTab, loadUsers])
|
||||
|
||||
async function handleCreateUser() {
|
||||
setUsersError('')
|
||||
setUsersNotice('')
|
||||
|
||||
const username = createForm.username.trim()
|
||||
const password = createForm.password
|
||||
const role = createForm.role
|
||||
|
||||
if (!username || !password) {
|
||||
setUsersError('Username and password are required')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingUser(true)
|
||||
try {
|
||||
await createUser({ username, password, role })
|
||||
setUsersNotice(`User ${username} created`)
|
||||
setCreateForm({ username: '', password: '', role: 'viewer' })
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
setUsersError(getErrorMessage(err, 'Failed to create user'))
|
||||
} finally {
|
||||
setIsCreatingUser(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetPassword() {
|
||||
setUsersError('')
|
||||
setUsersNotice('')
|
||||
|
||||
const userId = parseInt(passwordForm.userId, 10)
|
||||
const password = passwordForm.password
|
||||
|
||||
if (!userId || !password) {
|
||||
setUsersError('Select a user and provide a new password')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSettingPassword(true)
|
||||
try {
|
||||
await setUserPassword(userId, { password })
|
||||
const target = users.find((u) => u.id === userId)
|
||||
setUsersNotice(`Password updated for ${target?.username || `user #${userId}`}`)
|
||||
setPasswordForm((prev) => ({ ...prev, password: '' }))
|
||||
} catch (err) {
|
||||
setUsersError(getErrorMessage(err, 'Failed to set password'))
|
||||
} finally {
|
||||
setIsSettingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(userId, username) {
|
||||
setUsersError('')
|
||||
setUsersNotice('')
|
||||
|
||||
const confirmed = window.confirm(`Delete user ${username}? This cannot be undone.`)
|
||||
if (!confirmed) return
|
||||
|
||||
setDeletingUserId(userId)
|
||||
try {
|
||||
await deleteUser(userId)
|
||||
setUsersNotice(`User ${username} deleted`)
|
||||
setPasswordForm((prev) => ({
|
||||
...prev,
|
||||
userId: prev.userId === String(userId) ? '' : prev.userId,
|
||||
}))
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
setUsersError(getErrorMessage(err, 'Failed to delete user'))
|
||||
} finally {
|
||||
setDeletingUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-3xl mx-4 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
|
||||
<h2 className="text-slate-100 font-semibold">Settings</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-slate-100 font-semibold">Settings</h2>
|
||||
<span className="text-slate-500 text-xs">Admin</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4">
|
||||
<div className="inline-flex 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="px-5 py-4 space-y-6">
|
||||
{/* Error banner */}
|
||||
{saveError && (
|
||||
@@ -175,6 +309,18 @@ export default function ConfigModal({ onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersError && activeTab === 'users' && (
|
||||
<div className="bg-red-950/50 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm">
|
||||
{usersError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersNotice && activeTab === 'users' && (
|
||||
<div className="bg-emerald-950/50 border border-emerald-700 rounded-lg px-4 py-3 text-emerald-300 text-sm">
|
||||
{usersNotice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scanning indicator (save triggered a scan) */}
|
||||
{isScanning && (
|
||||
<div className="bg-blue-950/50 border border-blue-700 rounded-lg px-4 py-3 text-blue-300 text-sm flex items-center gap-2">
|
||||
@@ -191,9 +337,9 @@ export default function ConfigModal({ onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
{activeTab === 'general' && isLoading ? (
|
||||
<p className="text-slate-500 text-sm">Loading…</p>
|
||||
) : (
|
||||
) : activeTab === 'general' ? (
|
||||
<>
|
||||
{/* Directories */}
|
||||
<section>
|
||||
@@ -285,40 +431,173 @@ export default function ConfigModal({ onClose }) {
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section>
|
||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Current Users</h3>
|
||||
{usersLoading ? (
|
||||
<p className="text-slate-500 text-sm">Loading users…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto border border-slate-800 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800/50 text-slate-300">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium">Username</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Role</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Status</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Created</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-slate-800 text-slate-200">
|
||||
<td className="px-3 py-2">{u.username}</td>
|
||||
<td className="px-3 py-2 uppercase text-xs tracking-wide">{u.role}</td>
|
||||
<td className="px-3 py-2">{u.is_active ? 'Active' : 'Inactive'}</td>
|
||||
<td className="px-3 py-2 text-slate-400">{u.created_at ?? '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{u.id === currentUserId ? (
|
||||
<span className="inline-block px-2.5 py-1 text-xs rounded border border-slate-700 text-slate-400">
|
||||
Current User
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u.id, u.username)}
|
||||
disabled={Boolean(deletingUserId) || isCreatingUser || isSettingPassword}
|
||||
className="px-2.5 py-1 text-xs rounded border border-red-700 text-red-300 hover:bg-red-950/40 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deletingUserId === u.id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!users.length && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-4 text-slate-500">No users found.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Create User</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="Username"
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="Password"
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, role: e.target.value }))}
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={handleCreateUser}
|
||||
disabled={isCreatingUser || isSettingPassword || Boolean(deletingUserId)}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isCreatingUser ? 'Creating…' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Set User Password</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-center">
|
||||
<select
|
||||
value={passwordForm.userId}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, userId: e.target.value }))}
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select user</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={String(u.id)}>{u.username} ({u.role})</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="New password"
|
||||
className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSetPassword}
|
||||
disabled={isSettingPassword || isCreatingUser || Boolean(deletingUserId)}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSettingPassword ? 'Saving…' : 'Set Password'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between gap-2 px-5 py-4 border-t border-slate-800">
|
||||
<button
|
||||
onClick={handleRescanResults}
|
||||
disabled={isRescanning || isPending || isSavingTimes}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isRescanning ? 'Rescanning…' : 'Save Results'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveTimes}
|
||||
disabled={isSavingTimes || isPending || isRescanning}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSavingTimes ? 'Saving…' : 'Save Times'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || isRescanning || isSavingTimes}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Saving…' : 'Save All'}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'general' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRescanResults}
|
||||
disabled={isRescanning || isPending || isSavingTimes}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isRescanning ? 'Rescanning…' : 'Save Results'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveTimes}
|
||||
disabled={isSavingTimes || isPending || isRescanning}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSavingTimes ? 'Saving…' : 'Save Times'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || isRescanning || isSavingTimes}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Saving…' : 'Save All'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,10 @@ export async function apiFetch(path, options = {}) {
|
||||
|
||||
export const login = (body) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(body) })
|
||||
export const getMe = () => apiFetch('/auth/me')
|
||||
export const getUsers = () => apiFetch('/users')
|
||||
export const createUser = (body) => apiFetch('/users', { method: 'POST', body: JSON.stringify(body) })
|
||||
export const setUserPassword = (userId, body) => apiFetch(`/users/${userId}/password`, { method: 'POST', body: JSON.stringify(body) })
|
||||
export const deleteUser = (userId) => apiFetch(`/users/${userId}`, { method: 'DELETE' })
|
||||
|
||||
export const getStats = () => apiFetch('/stats')
|
||||
export const getTests = (params = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user