Implement RFID tag dump PWA with REST API, auth, and CI (#1)

Add Next.js app with SQLite/Drizzle storage for sites and LF/HF tag
dumps, multi-user Auth.js (credentials + optional OIDC), personal API
tokens in Settings, versioned /api/v1 for UI and future CLI use,
MCT/Proxmark/JSON import-export, PWA offline shell, Vitest tests, and
GitHub Actions CI.

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
Nick Trochalakis
2026-08-23 15:24:12 -07:00
committed by GitHub
co-authored by Cursor Agent
parent 5029945cc2
commit 8500d2f673
65 changed files with 13689 additions and 1 deletions
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CopyButton } from "@/components/CopyButton";
type TokenRow = {
id: string;
name: string;
prefix: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
};
type Me = {
id: string;
email: string;
name: string;
oidcAvailable: boolean;
};
export default function SettingsPage() {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const [tokens, setTokens] = useState<TokenRow[]>([]);
const [name, setName] = useState("");
const [expiry, setExpiry] = useState("");
const [createdToken, setCreatedToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [backupStatus, setBackupStatus] = useState<string | null>(null);
async function load() {
const [meRes, tokRes] = await Promise.all([
fetch("/api/v1/me"),
fetch("/api/v1/tokens"),
]);
if (meRes.status === 401) {
router.push("/login");
return;
}
setMe(await meRes.json());
const tokData = await tokRes.json();
setTokens(tokData.tokens || []);
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function createToken(e: FormEvent) {
e.preventDefault();
setError(null);
setCreatedToken(null);
const body: { name: string; expiresAt?: string | null } = { name };
if (expiry) {
body.expiresAt = new Date(expiry).toISOString();
}
const res = await fetch("/api/v1/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error?.message || "Could not create token");
return;
}
setCreatedToken(data.token);
setName("");
setExpiry("");
load();
}
async function revoke(id: string) {
if (!confirm("Revoke this token?")) return;
await fetch(`/api/v1/tokens/${id}`, { method: "DELETE" });
load();
}
async function onImportBackup(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setBackupStatus(null);
const form = e.currentTarget;
const fileInput = form.elements.namedItem("backup") as HTMLInputElement;
const file = fileInput.files?.[0];
if (!file) return;
const text = await file.text();
let json: unknown;
try {
json = JSON.parse(text);
} catch {
setError("Backup file is not valid JSON");
return;
}
const res = await fetch("/api/v1/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...(json as object), mode: "merge" }),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error?.message || "Import failed");
return;
}
setBackupStatus(
`Imported: ${data.sitesCreated} sites, ${data.tagsCreated} tags created`
);
}
return (
<div>
<h1 className="page-title">Settings</h1>
<p className="page-sub">Account, API tokens, and backups</p>
{error && <div className="error-banner">{error}</div>}
{backupStatus && <div className="success-banner">{backupStatus}</div>}
{me && (
<section className="panel stack" style={{ marginBottom: "1.25rem" }}>
<h2 style={{ margin: 0, fontSize: "1.1rem" }}>Account</h2>
<div>
<strong>{me.name}</strong>
<div className="muted">{me.email}</div>
</div>
{me.oidcAvailable && (
<p className="muted" style={{ margin: 0, fontSize: "0.9rem" }}>
OIDC SSO is configured on this server.
</p>
)}
</section>
)}
<section className="panel stack" style={{ marginBottom: "1.25rem" }}>
<div>
<h2 style={{ margin: 0, fontSize: "1.1rem" }}>API tokens</h2>
<p className="muted" style={{ margin: "0.35rem 0 0", fontSize: "0.9rem" }}>
For CLI / Proxmark scripts. The plaintext token is shown only once.
</p>
</div>
<form className="stack" onSubmit={createToken}>
<div className="row">
<div className="field" style={{ flex: 1, marginBottom: 0 }}>
<label className="label">Name</label>
<input
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="proxmark-laptop"
/>
</div>
<div className="field" style={{ flex: "0 0 12rem", marginBottom: 0 }}>
<label className="label">Expiry (optional)</label>
<input
className="input"
type="datetime-local"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
/>
</div>
</div>
<button className="btn" type="submit">
Create token
</button>
</form>
{createdToken && (
<div className="success-banner" style={{ marginBottom: 0 }}>
<div style={{ marginBottom: "0.5rem" }}>
Copy this token now it will not be shown again.
</div>
<div className="row">
<code className="mono" style={{ wordBreak: "break-all", flex: 1 }}>
{createdToken}
</code>
<CopyButton value={createdToken} />
</div>
</div>
)}
<div style={{ borderTop: "1px solid var(--border)", paddingTop: "0.75rem" }}>
{tokens.length === 0 ? (
<p className="muted" style={{ margin: 0 }}>
No tokens yet.
</p>
) : (
tokens.map((t) => (
<div key={t.id} className="list-row" style={{ paddingLeft: 0, paddingRight: 0 }}>
<div>
<strong>{t.name}</strong>
<div className="muted mono" style={{ fontSize: "0.8rem" }}>
{t.prefix} · created {new Date(t.createdAt).toLocaleString()}
{t.lastUsedAt
? ` · used ${new Date(t.lastUsedAt).toLocaleString()}`
: " · never used"}
{t.expiresAt
? ` · expires ${new Date(t.expiresAt).toLocaleString()}`
: ""}
</div>
</div>
<button type="button" className="btn btn-danger" onClick={() => revoke(t.id)}>
Revoke
</button>
</div>
))
)}
</div>
</section>
<section className="panel stack">
<h2 style={{ margin: 0, fontSize: "1.1rem" }}>Backup</h2>
<div className="row">
<a className="btn btn-secondary" href="/api/v1/backup?download=1">
Download JSON backup
</a>
</div>
<form className="stack" onSubmit={onImportBackup}>
<div className="field">
<label className="label">Import backup (merge)</label>
<input className="input" type="file" name="backup" accept="application/json,.json" required />
</div>
<button className="btn btn-secondary" type="submit">
Import
</button>
</form>
</section>
</div>
);
}