Harden API, data layer, and autosave; add validation, pagination, migrations
Build and Push Docker Image / build (push) Successful in 2m26s
Build and Push Docker Image / build (push) Successful in 2m26s
- Enforce task ownership on PATCH/DELETE /api/tasks (was: any signed-in user could edit or delete anyone's tasks) - Validate all API request bodies with zod; escape user content and restrict links to http(s) in the Drive export; block reverting a SUBMITTED report - Add @@unique([userId, date]) on Report with upsert to eliminate the duplicate-daily-report race; switch startup from `prisma db push --accept-data-loss` to `prisma migrate deploy` with automatic baselining of existing databases (dedup migration merges any pre-existing duplicates) - Autosave: re-queue and retry failed task saves with a visible saving/error indicator instead of silently dropping edits - Paginate and filter GET /api/reports (?date, ?mine, ?q, ?take, ?cursor); report form fetches only today's report, admin dashboard uses server-side search + Load more - Type the frontend and lib layer (DTOs in src/types/api.ts); zero eslint errors - Update README and Unraid guide for migrations, upgrade path, and API Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,21 +2,31 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Users, Calendar, Settings, ListChecks, Save, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import type { AdminUserDTO, ReportDTO, ReportsPageDTO } from "@/types/api";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [reports, setReports] = useState<any[]>([]);
|
||||
const [reports, setReports] = useState<ReportDTO[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS");
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<AdminUserDTO[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
// Initial load + server-side search (debounced so we don't query per keystroke)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => fetchReports(search), search ? 400 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
@@ -35,11 +45,12 @@ export default function AdminDashboard() {
|
||||
const saveSetting = async (key: string, value: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch("/api/admin/settings", {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
alert("Setting saved successfully!");
|
||||
} catch (error) {
|
||||
alert("Failed to save setting");
|
||||
@@ -52,7 +63,8 @@ export default function AdminDashboard() {
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/users");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: AdminUserDTO[] = await res.json();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users");
|
||||
@@ -83,23 +95,25 @@ export default function AdminDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReports = async () => {
|
||||
const fetchReports = async (q: string, cursor?: string) => {
|
||||
if (cursor) setLoadingMore(true);
|
||||
try {
|
||||
const res = await fetch("/api/reports");
|
||||
const data = await res.json();
|
||||
setReports(data);
|
||||
const params = new URLSearchParams({ take: String(PAGE_SIZE) });
|
||||
if (q) params.set("q", q);
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const res = await fetch(`/api/reports?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: ReportsPageDTO = await res.json();
|
||||
setReports(prev => (cursor ? [...prev, ...data.reports] : data.reports));
|
||||
setNextCursor(data.nextCursor);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch reports");
|
||||
console.error("Failed to fetch reports", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredReports = reports.filter(r =>
|
||||
r.user?.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.managerName?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <div className="text-center py-20 animate-pulse">Loading reports...</div>;
|
||||
|
||||
return (
|
||||
@@ -148,7 +162,7 @@ export default function AdminDashboard() {
|
||||
|
||||
{tab === "REPORTS" ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredReports.map((report) => (
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="glass-card overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-white/5"
|
||||
@@ -180,7 +194,7 @@ export default function AdminDashboard() {
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase text-accent-primary tracking-widest">Planned Tasks</h4>
|
||||
{report.tasks.filter((t: any) => t.type === 'PLANNED').map((task: any) => (
|
||||
{report.tasks.filter((t) => t.type === 'PLANNED').map((task) => (
|
||||
<div key={task.id} className="text-sm border-l-2 border-accent-primary/30 pl-3 py-1">
|
||||
<p className="font-medium">{task.description}</p>
|
||||
<p className="text-xs text-text-dim">Est: {task.timeEstimate} • {task.notes}</p>
|
||||
@@ -189,7 +203,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase text-accent-secondary tracking-widest">Completed Tasks</h4>
|
||||
{report.tasks.filter((t: any) => t.type === 'COMPLETED').map((task: any) => (
|
||||
{report.tasks.filter((t) => t.type === 'COMPLETED').map((task) => (
|
||||
<div key={task.id} className="text-sm border-l-2 border-accent-secondary/30 pl-3 py-1">
|
||||
<p className="font-medium">{task.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -208,7 +222,16 @@ export default function AdminDashboard() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredReports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
|
||||
{reports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
|
||||
{nextCursor && (
|
||||
<button
|
||||
onClick={() => fetchReports(search, nextCursor)}
|
||||
disabled={loadingMore}
|
||||
className="btn-secondary py-2 px-4 text-sm mx-auto disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more reports"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : tab === "USERS" ? (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
@@ -229,7 +252,7 @@ export default function AdminDashboard() {
|
||||
<div key={user.id} className="glass-card p-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{user.image ? (
|
||||
<img src={user.image} alt={user.name} className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
<img src={user.image} alt={user.name ?? "User avatar"} className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-accent-primary/20 flex items-center justify-center text-accent-primary flex-shrink-0">
|
||||
<User size={20} />
|
||||
|
||||
Reference in New Issue
Block a user