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
+14 -89
View File
@@ -1,112 +1,37 @@
import Link from "next/link";
import { readMeta, listGroups } from "@/lib/store";
import type { FileRecord } from "@/lib/store";
import BrowseClient from "./BrowseClient";
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 TypeBadge({ type }: { type: FileRecord["type"] }) {
return type === "backerup" ? (
<span className="rounded-full bg-cyan-500/15 px-2.5 py-0.5 text-[11px] font-semibold text-cyan-400">
.backerup
</span>
) : (
<span className="rounded-full bg-violet-500/15 px-2.5 py-0.5 text-[11px] font-semibold text-violet-400">
.json config
</span>
);
}
export default async function BrowsePage({
searchParams,
}: {
searchParams: Promise<{ group?: string }>;
}) {
const { group: selectedGroup } = await searchParams;
export default async function BrowsePage() {
const [allRecords, groups] = await Promise.all([readMeta(), listGroups()]);
const records = selectedGroup
? allRecords.filter((r) => r.group === selectedGroup)
: allRecords;
return (
<div className="mx-auto max-w-5xl px-6 py-16">
{/* Header */}
<div className="mb-10 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white">Browse shared files</h1>
<p className="text-xs font-semibold uppercase tracking-widest text-cyan-400 mb-2">
Community
</p>
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Browse shared files
</h1>
<p className="mt-2 text-slate-400">
{records.length} file{records.length !== 1 ? "s" : ""}
{selectedGroup ? ` in "${selectedGroup}"` : " shared by the community"}.
Docker configs and backups shared by the community. Find, inspect, and restore in
seconds.
</p>
</div>
<Link
href="/share"
className="rounded-xl bg-cyan-500 px-6 py-2.5 text-sm font-bold text-slate-900 hover:bg-cyan-400 transition-colors"
className="rounded-xl px-6 py-2.5 text-sm font-bold text-slate-900 transition-all hover:brightness-110 hover:scale-105 active:scale-95 shrink-0"
style={{ background: "linear-gradient(135deg, #22d3ee, #06b6d4)" }}
>
+ Share a file
</Link>
</div>
{/* Group filter bar */}
{groups.length > 0 && (
<BrowseClient groups={groups} selectedGroup={selectedGroup ?? null} />
)}
{records.length === 0 ? (
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-16 text-center">
<p className="text-4xl">📭</p>
<p className="mt-4 text-slate-400">
{selectedGroup ? `No files in group "${selectedGroup}".` : "No files shared yet."}
</p>
<Link href="/share" className="mt-4 inline-block text-sm text-cyan-400 hover:underline">
Be the first to share one
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{records.map((r) => (
<Link
key={r.token}
href={`/files/${r.token}`}
className="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 transition hover:border-slate-600 hover:bg-slate-900"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<TypeBadge type={r.type} />
{r.group && (
<span className="rounded-full bg-slate-700/60 px-2.5 py-0.5 text-[11px] font-medium text-slate-400">
{r.group}
</span>
)}
<span className="truncate font-mono text-xs text-slate-500">{r.originalName}</span>
</div>
{(r.displayName ?? r.containerName) && (
<p className="mt-2 font-semibold text-white truncate">
{r.displayName ?? r.containerName}
</p>
)}
{r.image && (
<p className="mt-0.5 text-xs text-slate-400 truncate">{r.image}</p>
)}
{r.description && (
<p className="mt-2 text-sm text-slate-400 line-clamp-2">{r.description}</p>
)}
</div>
<span className="shrink-0 text-slate-600 group-hover:text-slate-400 transition-colors text-lg"></span>
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-slate-600">
<span>{formatBytes(r.size)}</span>
<span>{new Date(r.uploadedAt).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
)}
{/* Client section: search + filter + cards */}
<BrowseClient records={allRecords} groups={groups} />
</div>
);
}