feat: implement user authentication and admin management

- Add user management API endpoints for listing and creating users.
- Implement login and logout functionality with rate limiting.
- Create UI components for login form, logout button, and animated elements.
- Add session management with JWT and secure cookie handling.
- Seed initial admin user if no users exist.
- Introduce utility functions for password hashing and user authentication.
- Set up proxy middleware for route protection and security headers.
- Create a JSON file for user data storage.
- Add links configuration for external resources.
This commit is contained in:
Anders Böttcher
2026-05-12 14:58:11 +02:00
parent 50566f2a22
commit 0d58fafe4e
34 changed files with 4263 additions and 5012 deletions
+303 -33
View File
@@ -1,47 +1,317 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
import Link from "next/link";
import type { FileRecord } from "@/lib/store";
type SortOrder = "newest" | "oldest" | "name" | "size-desc" | "size-asc";
type TypeFilter = "all" | "backerup" | "config";
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
function formatDate(s: string) {
return new Date(s).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);
}
function ArrowIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
);
}
function XIcon() {
return (
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function SortIcon() {
return (
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h18M6 12h12M10 17h4" />
</svg>
);
}
export default function BrowseClient({
groups,
selectedGroup,
records: allRecords,
groups,
}: {
groups: string[];
selectedGroup: string | null;
records: FileRecord[];
groups: string[];
}) {
const router = useRouter();
const [search, setSearch] = useState("");
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortOrder>("newest");
function select(g: string | null) {
if (g === null) {
router.push("/browse");
} else {
router.push(`/browse?group=${encodeURIComponent(g)}`);
}
const filtered = useMemo(() => {
let r = allRecords;
if (search.trim()) {
const q = search.toLowerCase();
r = r.filter(
(rec) =>
rec.displayName?.toLowerCase().includes(q) ||
rec.originalName.toLowerCase().includes(q) ||
rec.description?.toLowerCase().includes(q) ||
rec.containerName?.toLowerCase().includes(q) ||
rec.image?.toLowerCase().includes(q) ||
rec.group?.toLowerCase().includes(q)
);
}
return (
<div className="mb-8 flex flex-wrap gap-2">
if (selectedGroup) r = r.filter((rec) => rec.group === selectedGroup);
if (typeFilter !== "all") r = r.filter((rec) => rec.type === typeFilter);
const sorted = [...r];
switch (sort) {
case "newest":
sorted.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime());
break;
case "oldest":
sorted.sort((a, b) => new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime());
break;
case "name":
sorted.sort((a, b) =>
(a.displayName ?? a.originalName).localeCompare(b.displayName ?? b.originalName)
);
break;
case "size-desc":
sorted.sort((a, b) => b.size - a.size);
break;
case "size-asc":
sorted.sort((a, b) => a.size - b.size);
break;
}
return sorted;
}, [allRecords, search, selectedGroup, typeFilter, sort]);
const activeFilters =
(search.trim() ? 1 : 0) + (selectedGroup ? 1 : 0) + (typeFilter !== "all" ? 1 : 0);
function clearAll() {
setSearch("");
setSelectedGroup(null);
setTypeFilter("all");
setSort("newest");
}
return (
<div>
{/* ── Search + Sort row ── */}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<SearchIcon />
</span>
<input
type="search"
placeholder="Search by name, image, description, group…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-white/8 bg-white/4 pl-10 pr-4 py-2.5 text-sm text-slate-200 placeholder-slate-600 outline-none focus:border-cyan-500/40 focus:bg-white/6 transition-all"
/>
{search && (
<button
onClick={() => select(null)}
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${selectedGroup === null
? "bg-cyan-500 text-slate-900"
: "border border-slate-700 bg-slate-800/50 text-slate-400 hover:border-slate-500 hover:text-slate-200"
}`}
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
All
<XIcon />
</button>
{groups.map((g) => (
<button
key={g}
onClick={() => select(g)}
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${selectedGroup === g
? "bg-cyan-500 text-slate-900"
: "border border-slate-700 bg-slate-800/50 text-slate-400 hover:border-slate-500 hover:text-slate-200"
}`}
>
{g}
</button>
))}
)}
</div>
);
<div className="flex items-center gap-2">
<span className="text-slate-600 shrink-0">
<SortIcon />
</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
className="rounded-xl border border-white/8 bg-slate-900 px-3 py-2.5 text-sm text-slate-300 outline-none focus:border-cyan-500/40 transition-all cursor-pointer"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name AZ</option>
<option value="size-desc">Largest first</option>
<option value="size-asc">Smallest first</option>
</select>
</div>
</div>
{/* ── Filter pills ── */}
<div className="mb-8 flex flex-wrap items-center gap-2">
{/* Type pills */}
{(["all", "backerup", "config"] as TypeFilter[]).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
typeFilter === t
? t === "all"
? "bg-cyan-500 text-slate-900"
: t === "backerup"
? "bg-cyan-500/20 border border-cyan-500/50 text-cyan-300"
: "bg-violet-500/20 border border-violet-500/50 text-violet-300"
: "border border-white/8 bg-white/3 text-slate-500 hover:border-white/16 hover:text-slate-300"
}`}
>
{t === "all" ? "All types" : t === "backerup" ? ".backerup" : ".json config"}
</button>
))}
{groups.length > 0 && (
<>
<div className="h-5 w-px bg-white/8 mx-1" />
{groups.map((g) => (
<button
key={g}
onClick={() => setSelectedGroup(selectedGroup === g ? null : g)}
className={`rounded-full px-3.5 py-1.5 text-xs font-semibold transition-all ${
selectedGroup === g
? "bg-violet-500/20 border border-violet-500/50 text-violet-300"
: "border border-white/8 bg-white/3 text-slate-500 hover:border-white/16 hover:text-slate-300"
}`}
>
{selectedGroup === g && (
<span className="mr-1 inline-block w-1.5 h-1.5 rounded-full bg-violet-400" />
)}
{g}
</button>
))}
</>
)}
{activeFilters > 0 && (
<button
onClick={clearAll}
className="ml-auto flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
<XIcon />
Clear filters ({activeFilters})
</button>
)}
</div>
{/* ── Results count ── */}
<p className="mb-5 text-xs text-slate-600">
{filtered.length === allRecords.length
? `${allRecords.length} file${allRecords.length !== 1 ? "s" : ""} shared by the community`
: `${filtered.length} of ${allRecords.length} files`}
</p>
{/* ── Cards ── */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-white/5 glass p-20 text-center">
<div className="text-5xl mb-5">🔍</div>
<p className="font-semibold text-white">No results found</p>
<p className="mt-2 text-sm text-slate-500">Try adjusting your search or clearing some filters.</p>
{activeFilters > 0 && (
<button
onClick={clearAll}
className="mt-5 text-sm text-cyan-400 hover:text-cyan-300 transition-colors"
>
Clear all filters
</button>
)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((r) => (
<Link
key={r.token}
href={`/files/${r.token}`}
className="group rounded-2xl border border-white/5 glass p-5 card-hover block"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* Badges */}
<div className="flex flex-wrap items-center gap-2">
{r.type === "backerup" ? (
<span className="rounded-full bg-cyan-500/12 border border-cyan-500/20 px-2.5 py-0.5 text-[11px] font-semibold text-cyan-400">
.backerup
</span>
) : (
<span className="rounded-full bg-violet-500/12 border border-violet-500/20 px-2.5 py-0.5 text-[11px] font-semibold text-violet-400">
.json config
</span>
)}
{r.group && (
<span className="rounded-full bg-white/4 border border-white/8 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{r.group}
</span>
)}
</div>
{/* Title */}
{(r.displayName ?? r.containerName) ? (
<p className="mt-2.5 font-semibold text-white truncate group-hover:text-cyan-300 transition-colors">
{r.displayName ?? r.containerName}
</p>
) : (
<p className="mt-2.5 font-mono text-sm text-slate-400 truncate">
{r.originalName}
</p>
)}
{/* Image */}
{r.image && (
<p className="mt-0.5 font-mono text-xs text-slate-500 truncate">{r.image}</p>
)}
{/* Description */}
{r.description && (
<p className="mt-2 text-sm text-slate-400 line-clamp-2 leading-relaxed">
{r.description}
</p>
)}
</div>
{/* Arrow */}
<span className="shrink-0 text-slate-700 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all mt-1">
<ArrowIcon />
</span>
</div>
{/* Meta row */}
<div className="mt-4 flex items-center gap-3 text-xs text-slate-600">
<span>{formatBytes(r.size)}</span>
<span className="text-slate-800">·</span>
<span>{formatDate(r.uploadedAt)}</span>
{r.containerName && r.displayName && (
<>
<span className="text-slate-800">·</span>
<span className="font-mono truncate">{r.containerName}</span>
</>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}