mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
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:
co-authored by
Cursor Agent
parent
5029945cc2
commit
8500d2f673
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user