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,3 @@
|
||||
import { handlers } from "@/lib/auth/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,203 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { z } from "zod";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
import { FREQUENCIES, PROTOCOLS } from "@/lib/rfid/protocols";
|
||||
import { dumpDataSchema, keysSchema } from "@/lib/validation/rfid";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const allSites = db.select().from(sites).all();
|
||||
const allTags = db.select().from(tagRecords).all();
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sites: allSites.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
notes: s.notes,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
})),
|
||||
tags: allTags.map((t) => ({
|
||||
id: t.id,
|
||||
siteId: t.siteId,
|
||||
label: t.label,
|
||||
frequency: t.frequency,
|
||||
protocol: t.protocol,
|
||||
uid: t.uid,
|
||||
dumpData: t.dumpData,
|
||||
keys: t.keys,
|
||||
notes: t.notes,
|
||||
lastWrittenAt: t.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
updatedAt: t.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
const download = request.nextUrl.searchParams.get("download") === "1";
|
||||
if (download) {
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": 'attachment; filename="rfid-backup.json"',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return jsonOk(payload);
|
||||
}
|
||||
|
||||
const backupSchema = z.object({
|
||||
version: z.number().optional(),
|
||||
mode: z.enum(["merge", "replace"]).default("merge"),
|
||||
sites: z.array(
|
||||
z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1),
|
||||
code: z.string().min(1),
|
||||
notes: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
tags: z.array(
|
||||
z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
siteId: z.string().optional(),
|
||||
siteCode: z.string().optional(),
|
||||
label: z.string().min(1),
|
||||
frequency: z.enum(FREQUENCIES),
|
||||
protocol: z.enum(PROTOCOLS),
|
||||
uid: z.string().min(1),
|
||||
dumpData: dumpDataSchema,
|
||||
keys: keysSchema,
|
||||
notes: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = backupSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
|
||||
if (parsed.data.mode === "replace") {
|
||||
db.delete(tagRecords).run();
|
||||
db.delete(sites).run();
|
||||
}
|
||||
|
||||
const codeToId = new Map<string, string>();
|
||||
let sitesCreated = 0;
|
||||
let tagsCreated = 0;
|
||||
|
||||
for (const s of parsed.data.sites) {
|
||||
const code = s.code.toUpperCase();
|
||||
const existing = db.select().from(sites).where(eq(sites.code, code)).get();
|
||||
if (existing) {
|
||||
codeToId.set(code, existing.id);
|
||||
if (s.id) codeToId.set(s.id, existing.id);
|
||||
continue;
|
||||
}
|
||||
const id = s.id ?? crypto.randomUUID();
|
||||
db.insert(sites)
|
||||
.values({
|
||||
id,
|
||||
name: s.name,
|
||||
code,
|
||||
notes: s.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
createdAt: now,
|
||||
})
|
||||
.run();
|
||||
codeToId.set(code, id);
|
||||
codeToId.set(id, id);
|
||||
sitesCreated += 1;
|
||||
}
|
||||
|
||||
// refresh map from DB
|
||||
for (const s of db.select().from(sites).all()) {
|
||||
codeToId.set(s.code, s.id);
|
||||
codeToId.set(s.id, s.id);
|
||||
}
|
||||
|
||||
for (const t of parsed.data.tags) {
|
||||
let siteId = t.siteId ? codeToId.get(t.siteId) : undefined;
|
||||
if (!siteId && t.siteCode) {
|
||||
siteId = codeToId.get(t.siteCode.toUpperCase());
|
||||
}
|
||||
if (!siteId) continue;
|
||||
|
||||
let uid: string;
|
||||
try {
|
||||
uid = normalizeUid(t.uid);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = t.id ?? crypto.randomUUID();
|
||||
const existingById = t.id
|
||||
? db.select().from(tagRecords).where(eq(tagRecords.id, t.id)).get()
|
||||
: undefined;
|
||||
|
||||
if (existingById) {
|
||||
db.update(tagRecords)
|
||||
.set({
|
||||
siteId,
|
||||
label: t.label,
|
||||
frequency: t.frequency,
|
||||
protocol: t.protocol,
|
||||
uid,
|
||||
dumpData: t.dumpData,
|
||||
keys: t.keys ?? null,
|
||||
notes: t.notes ?? null,
|
||||
updatedById: userOrRes.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(tagRecords.id, existingById.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId,
|
||||
label: t.label,
|
||||
frequency: t.frequency,
|
||||
protocol: t.protocol,
|
||||
uid,
|
||||
dumpData: t.dumpData,
|
||||
keys: t.keys ?? null,
|
||||
notes: t.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
tagsCreated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return jsonOk({ ok: true, sitesCreated, tagsCreated, mode: parsed.data.mode });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { isOidcConfigured } from "@/lib/auth/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const row = db.select().from(users).where(eq(users.id, userOrRes.id)).get();
|
||||
if (!row) return jsonError(404, "NOT_FOUND", "User not found");
|
||||
|
||||
return jsonOk({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
oidcAvailable: isOidcConfigured(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { desc, like, or, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const q = request.nextUrl.searchParams.get("q")?.trim();
|
||||
if (!q) {
|
||||
return jsonError(400, "VALIDATION_ERROR", "Query parameter q is required");
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(request.nextUrl.searchParams.get("limit") ?? 50), 200);
|
||||
const pattern = `%${q}%`;
|
||||
const db = getDb();
|
||||
|
||||
const matchingSites = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(or(like(sites.code, pattern), like(sites.name, pattern), like(sites.notes, pattern)))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
const matchingTags = db
|
||||
.select({
|
||||
tag: tagRecords,
|
||||
siteCode: sites.code,
|
||||
siteName: sites.name,
|
||||
})
|
||||
.from(tagRecords)
|
||||
.innerJoin(sites, eq(sites.id, tagRecords.siteId))
|
||||
.where(
|
||||
or(
|
||||
like(tagRecords.uid, pattern),
|
||||
like(tagRecords.label, pattern),
|
||||
like(tagRecords.notes, pattern),
|
||||
like(tagRecords.protocol, pattern),
|
||||
like(sites.code, pattern)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(tagRecords.updatedAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
q,
|
||||
sites: matchingSites.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
notes: s.notes,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
})),
|
||||
tags: matchingTags.map((r) => ({
|
||||
id: r.tag.id,
|
||||
siteId: r.tag.siteId,
|
||||
siteCode: r.siteCode,
|
||||
siteName: r.siteName,
|
||||
label: r.tag.label,
|
||||
frequency: r.tag.frequency,
|
||||
protocol: r.tag.protocol,
|
||||
uid: r.tag.uid,
|
||||
notes: r.tag.notes,
|
||||
updatedAt: r.tag.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { siteUpdateSchema } from "@/lib/validation/rfid";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const site = db.select().from(sites).where(eq(sites.id, id)).get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const tagCount =
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.siteId, id))
|
||||
.get()?.c ?? 0;
|
||||
|
||||
return jsonOk({
|
||||
...site,
|
||||
createdAt: site.createdAt.toISOString(),
|
||||
tagCount: Number(tagCount),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = siteUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = db.select().from(sites).where(eq(sites.id, id)).get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const updates: Partial<typeof sites.$inferInsert> = {};
|
||||
if (parsed.data.name !== undefined) updates.name = parsed.data.name;
|
||||
if (parsed.data.code !== undefined) updates.code = parsed.data.code.toUpperCase();
|
||||
if (parsed.data.notes !== undefined) updates.notes = parsed.data.notes;
|
||||
|
||||
if (updates.code && updates.code !== existing.code) {
|
||||
const clash = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.code, updates.code))
|
||||
.get();
|
||||
if (clash) {
|
||||
return jsonError(409, "CONFLICT", `Site code ${updates.code} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
db.update(sites).set(updates).where(eq(sites.id, id)).run();
|
||||
const updated = db.select().from(sites).where(eq(sites.id, id)).get()!;
|
||||
return jsonOk({
|
||||
...updated,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const existing = db.select().from(sites).where(eq(sites.id, id)).get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
db.delete(sites).where(eq(sites.id, id)).run();
|
||||
return jsonOk({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { asc, count, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { siteCreateSchema } from "@/lib/validation/rfid";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.select({
|
||||
id: sites.id,
|
||||
name: sites.name,
|
||||
code: sites.code,
|
||||
notes: sites.notes,
|
||||
createdById: sites.createdById,
|
||||
createdAt: sites.createdAt,
|
||||
tagCount: count(tagRecords.id),
|
||||
})
|
||||
.from(sites)
|
||||
.leftJoin(tagRecords, eq(tagRecords.siteId, sites.id))
|
||||
.groupBy(sites.id)
|
||||
.orderBy(asc(sites.code))
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
sites: rows.map((s) => ({
|
||||
...s,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
tagCount: Number(s.tagCount),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = siteCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const code = parsed.data.code.toUpperCase();
|
||||
const existing = db.select().from(sites).where(eq(sites.code, code)).get();
|
||||
if (existing) {
|
||||
return jsonError(409, "CONFLICT", `Site code ${code} already exists`);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
db.insert(sites)
|
||||
.values({
|
||||
id,
|
||||
name: parsed.data.name,
|
||||
code,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
|
||||
return jsonOk(
|
||||
{
|
||||
id,
|
||||
name: parsed.data.name,
|
||||
code,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
createdAt: createdAt.toISOString(),
|
||||
tagCount: 0,
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { tagRecords } from "@/db/schema";
|
||||
import { exportFormatSchema } from "@/lib/validation/rfid";
|
||||
import { exportTag, type ExportFormat } from "@/lib/rfid/exporters";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const formatRaw = request.nextUrl.searchParams.get("format") ?? "json";
|
||||
const download = request.nextUrl.searchParams.get("download") === "1";
|
||||
|
||||
const parsed = exportFormatSchema.safeParse(formatRaw);
|
||||
if (!parsed.success) {
|
||||
return jsonError(
|
||||
400,
|
||||
"VALIDATION_ERROR",
|
||||
"format must be json|proxmark|mct|hex"
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get();
|
||||
if (!row) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
|
||||
const exported = exportTag(
|
||||
{
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
},
|
||||
parsed.data as ExportFormat
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": exported.contentType,
|
||||
};
|
||||
if (download) {
|
||||
headers["Content-Disposition"] =
|
||||
`attachment; filename="${exported.filename}"`;
|
||||
}
|
||||
|
||||
return new Response(exported.body, { status: 200, headers });
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { tagUpdateSchema } from "@/lib/validation/rfid";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
function serializeTag(row: typeof tagRecords.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdById: row.createdById,
|
||||
updatedById: row.updatedById,
|
||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.select({
|
||||
tag: tagRecords,
|
||||
siteCode: sites.code,
|
||||
siteName: sites.name,
|
||||
})
|
||||
.from(tagRecords)
|
||||
.innerJoin(sites, eq(sites.id, tagRecords.siteId))
|
||||
.where(eq(tagRecords.id, id))
|
||||
.get();
|
||||
|
||||
if (!row) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
return jsonOk({
|
||||
...serializeTag(row.tag),
|
||||
siteCode: row.siteCode,
|
||||
siteName: row.siteName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = tagUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.id, id))
|
||||
.get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
|
||||
const updates: Partial<typeof tagRecords.$inferInsert> = {
|
||||
updatedById: userOrRes.id,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (parsed.data.siteId !== undefined) {
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
updates.siteId = parsed.data.siteId;
|
||||
}
|
||||
if (parsed.data.label !== undefined) updates.label = parsed.data.label;
|
||||
if (parsed.data.frequency !== undefined)
|
||||
updates.frequency = parsed.data.frequency;
|
||||
if (parsed.data.protocol !== undefined)
|
||||
updates.protocol = parsed.data.protocol;
|
||||
if (parsed.data.uid !== undefined) {
|
||||
try {
|
||||
updates.uid = normalizeUid(parsed.data.uid);
|
||||
} catch (e) {
|
||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||
}
|
||||
}
|
||||
if (parsed.data.dumpData !== undefined)
|
||||
updates.dumpData = parsed.data.dumpData;
|
||||
if (parsed.data.keys !== undefined) updates.keys = parsed.data.keys ?? null;
|
||||
if (parsed.data.notes !== undefined) updates.notes = parsed.data.notes;
|
||||
if (parsed.data.lastWrittenAt !== undefined) {
|
||||
updates.lastWrittenAt = parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: null;
|
||||
}
|
||||
|
||||
db.update(tagRecords).set(updates).where(eq(tagRecords.id, id)).run();
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk(serializeTag(row));
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.id, id))
|
||||
.get();
|
||||
if (!existing) return jsonError(404, "NOT_FOUND", "Tag not found");
|
||||
|
||||
db.delete(tagRecords).where(eq(tagRecords.id, id)).run();
|
||||
return jsonOk({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { tagUpsertSchema } from "@/lib/validation/rfid";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
type Ctx = { params: Promise<{ uid: string }> };
|
||||
|
||||
function serializeTag(row: typeof tagRecords.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdById: row.createdById,
|
||||
updatedById: row.updatedById,
|
||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { uid: uidParam } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const siteIdFromQuery = request.nextUrl.searchParams.get("siteId");
|
||||
const merged =
|
||||
typeof body === "object" && body !== null
|
||||
? { siteId: siteIdFromQuery, ...(body as Record<string, unknown>) }
|
||||
: body;
|
||||
|
||||
const parsed = tagUpsertSchema.safeParse(merged);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
let uid: string;
|
||||
try {
|
||||
uid = normalizeUid(uidParam);
|
||||
// body uid should match path if provided
|
||||
if (normalizeUid(parsed.data.uid) !== uid) {
|
||||
// prefer path uid
|
||||
uid = normalizeUid(uidParam);
|
||||
}
|
||||
} catch (e) {
|
||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(and(eq(tagRecords.siteId, parsed.data.siteId), eq(tagRecords.uid, uid)))
|
||||
.get();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (existing) {
|
||||
db.update(tagRecords)
|
||||
.set({
|
||||
label: parsed.data.label,
|
||||
frequency: parsed.data.frequency,
|
||||
protocol: parsed.data.protocol,
|
||||
dumpData: parsed.data.dumpData,
|
||||
keys: parsed.data.keys ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
updatedById: userOrRes.id,
|
||||
updatedAt: now,
|
||||
lastWrittenAt: parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: existing.lastWrittenAt,
|
||||
})
|
||||
.where(eq(tagRecords.id, existing.id))
|
||||
.run();
|
||||
|
||||
const row = db
|
||||
.select()
|
||||
.from(tagRecords)
|
||||
.where(eq(tagRecords.id, existing.id))
|
||||
.get()!;
|
||||
return jsonOk({ ...serializeTag(row), created: false });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId: parsed.data.siteId,
|
||||
label: parsed.data.label,
|
||||
frequency: parsed.data.frequency,
|
||||
protocol: parsed.data.protocol,
|
||||
uid,
|
||||
dumpData: parsed.data.dumpData,
|
||||
keys: parsed.data.keys ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk({ ...serializeTag(row), created: true }, 201);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { parseImport } from "@/lib/rfid/parsers";
|
||||
import { z } from "zod";
|
||||
|
||||
const importMetaSchema = z.object({
|
||||
siteId: z.string().uuid(),
|
||||
label: z.string().min(1).max(200).optional(),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
content: z.string().min(1),
|
||||
filename: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = importMetaSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
let imported;
|
||||
try {
|
||||
imported = parseImport(parsed.data.content, parsed.data.filename);
|
||||
} catch (e) {
|
||||
return jsonError(400, "PARSE_ERROR", (e as Error).message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const label =
|
||||
parsed.data.label || imported.label || `Imported ${imported.uid}`;
|
||||
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId: parsed.data.siteId,
|
||||
label,
|
||||
frequency: imported.frequency,
|
||||
protocol: imported.protocol,
|
||||
uid: imported.uid,
|
||||
dumpData: imported.dumpData,
|
||||
keys: imported.keys ?? null,
|
||||
notes: parsed.data.notes ?? imported.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk(
|
||||
{
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { and, desc, eq, like, or } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { sites, tagRecords } from "@/db/schema";
|
||||
import { tagCreateSchema } from "@/lib/validation/rfid";
|
||||
import { normalizeUid } from "@/lib/rfid/uid";
|
||||
|
||||
function serializeTag(row: typeof tagRecords.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
label: row.label,
|
||||
frequency: row.frequency,
|
||||
protocol: row.protocol,
|
||||
uid: row.uid,
|
||||
dumpData: row.dumpData,
|
||||
keys: row.keys,
|
||||
notes: row.notes,
|
||||
createdById: row.createdById,
|
||||
updatedById: row.updatedById,
|
||||
lastWrittenAt: row.lastWrittenAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const siteId = searchParams.get("siteId");
|
||||
const frequency = searchParams.get("frequency");
|
||||
const protocol = searchParams.get("protocol");
|
||||
const q = searchParams.get("q");
|
||||
const limit = Math.min(Number(searchParams.get("limit") ?? 100), 500);
|
||||
|
||||
const db = getDb();
|
||||
const conditions = [];
|
||||
if (siteId) conditions.push(eq(tagRecords.siteId, siteId));
|
||||
if (frequency) conditions.push(eq(tagRecords.frequency, frequency));
|
||||
if (protocol) conditions.push(eq(tagRecords.protocol, protocol));
|
||||
if (q) {
|
||||
const pattern = `%${q}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
like(tagRecords.uid, pattern),
|
||||
like(tagRecords.label, pattern),
|
||||
like(tagRecords.notes, pattern),
|
||||
like(sites.code, pattern)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
tag: tagRecords,
|
||||
siteCode: sites.code,
|
||||
siteName: sites.name,
|
||||
})
|
||||
.from(tagRecords)
|
||||
.innerJoin(sites, eq(sites.id, tagRecords.siteId))
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(tagRecords.updatedAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
tags: rows.map((r) => ({
|
||||
...serializeTag(r.tag),
|
||||
siteCode: r.siteCode,
|
||||
siteName: r.siteName,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = tagCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
let uid: string;
|
||||
try {
|
||||
uid = normalizeUid(parsed.data.uid);
|
||||
} catch (e) {
|
||||
return jsonError(400, "VALIDATION_ERROR", (e as Error).message);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const site = db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.id, parsed.data.siteId))
|
||||
.get();
|
||||
if (!site) return jsonError(404, "NOT_FOUND", "Site not found");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
db.insert(tagRecords)
|
||||
.values({
|
||||
id,
|
||||
siteId: parsed.data.siteId,
|
||||
label: parsed.data.label,
|
||||
frequency: parsed.data.frequency,
|
||||
protocol: parsed.data.protocol,
|
||||
uid,
|
||||
dumpData: parsed.data.dumpData,
|
||||
keys: parsed.data.keys ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
createdById: userOrRes.id,
|
||||
updatedById: userOrRes.id,
|
||||
lastWrittenAt: parsed.data.lastWrittenAt
|
||||
? new Date(parsed.data.lastWrittenAt)
|
||||
: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(tagRecords).where(eq(tagRecords.id, id)).get()!;
|
||||
return jsonOk(serializeTag(row), 201);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Ctx) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const { id } = await context.params;
|
||||
const db = getDb();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(apiTokens)
|
||||
.where(and(eq(apiTokens.id, id), eq(apiTokens.userId, userOrRes.id)))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
return jsonError(404, "NOT_FOUND", "Token not found");
|
||||
}
|
||||
|
||||
db.delete(apiTokens).where(eq(apiTokens.id, id)).run();
|
||||
return jsonOk({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { requireApiUser, jsonError, jsonOk } from "@/lib/api/auth-guard";
|
||||
import { getDb } from "@/db/client";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { tokenCreateSchema } from "@/lib/validation/rfid";
|
||||
import { generateApiToken } from "@/lib/auth/tokens";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.select({
|
||||
id: apiTokens.id,
|
||||
name: apiTokens.name,
|
||||
prefix: apiTokens.prefix,
|
||||
expiresAt: apiTokens.expiresAt,
|
||||
lastUsedAt: apiTokens.lastUsedAt,
|
||||
createdAt: apiTokens.createdAt,
|
||||
})
|
||||
.from(apiTokens)
|
||||
.where(eq(apiTokens.userId, userOrRes.id))
|
||||
.orderBy(desc(apiTokens.createdAt))
|
||||
.all();
|
||||
|
||||
return jsonOk({
|
||||
tokens: rows.map((t) => ({
|
||||
...t,
|
||||
expiresAt: t.expiresAt?.toISOString() ?? null,
|
||||
lastUsedAt: t.lastUsedAt?.toISOString() ?? null,
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userOrRes = await requireApiUser(request);
|
||||
if (userOrRes instanceof Response) return userOrRes;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonError(400, "INVALID_JSON", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const parsed = tokenCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(400, "VALIDATION_ERROR", parsed.error.message);
|
||||
}
|
||||
|
||||
const { token, tokenHash, prefix } = generateApiToken();
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
const expiresAt = parsed.data.expiresAt
|
||||
? new Date(parsed.data.expiresAt)
|
||||
: null;
|
||||
|
||||
const db = getDb();
|
||||
db.insert(apiTokens)
|
||||
.values({
|
||||
id,
|
||||
userId: userOrRes.id,
|
||||
name: parsed.data.name,
|
||||
tokenHash,
|
||||
prefix,
|
||||
expiresAt,
|
||||
lastUsedAt: null,
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
|
||||
return jsonOk(
|
||||
{
|
||||
id,
|
||||
name: parsed.data.name,
|
||||
prefix,
|
||||
token,
|
||||
expiresAt: expiresAt?.toISOString() ?? null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
warning: "Store this token now. It will not be shown again.",
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--bg-elevated: #171e26;
|
||||
--bg-soft: #1e2732;
|
||||
--border: #2a3544;
|
||||
--text: #e8eef4;
|
||||
--text-muted: #8b9aab;
|
||||
--accent: #1a7f6e;
|
||||
--accent-hover: #229985;
|
||||
--accent-soft: rgba(26, 127, 110, 0.15);
|
||||
--danger: #c45c5c;
|
||||
--danger-soft: rgba(196, 92, 92, 0.15);
|
||||
--warning: #c9a227;
|
||||
--font-sans: var(--font-dm-sans), "Segoe UI", sans-serif;
|
||||
--font-mono: var(--font-ibm-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(26, 127, 110, 0.18), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(45, 90, 120, 0.12), transparent),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(960px, 100% - 2rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-soft);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
color: #f0c0c0;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(196, 92, 92, 0.3);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.list-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: #7fd4c4;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid var(--danger);
|
||||
color: #f0c0c0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
color: #a8e6d9;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dump-grid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dump-block {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dump-block code {
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DM_Sans, IBM_Plex_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-dm-sans",
|
||||
});
|
||||
|
||||
const ibmMono = IBM_Plex_Mono({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
variable: "--font-ibm-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RFID Database",
|
||||
description: "Store and look up LF/HF RFID tag dumps by site",
|
||||
applicationName: "RFID DB",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: "RFID DB",
|
||||
statusBarStyle: "black-translucent",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#1a7f6e",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${dmSans.variable} ${ibmMono.variable} antialiased`}>
|
||||
<Providers>
|
||||
<AppHeader />
|
||||
<main className="container" style={{ padding: "1.25rem 0 3rem" }}>
|
||||
{children}
|
||||
</main>
|
||||
<ServiceWorkerRegister />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const callbackUrl = params.get("callbackUrl") || "/";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const oidcName = process.env.NEXT_PUBLIC_AUTH_OIDC_NAME || "SSO";
|
||||
const oidcEnabled = process.env.NEXT_PUBLIC_AUTH_OIDC_ENABLED === "1";
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res?.error) {
|
||||
setError("Invalid email or password");
|
||||
return;
|
||||
}
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: "3rem auto" }}>
|
||||
<h1 className="page-title">RFID Database</h1>
|
||||
<p className="page-sub">Sign in to manage site tag dumps</p>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<form className="panel stack" onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
<label className="label" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
className="input"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" type="submit" disabled={loading}>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
{oidcEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => signIn("oidc", { callbackUrl })}
|
||||
>
|
||||
Sign in with {oidcName}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "RFID Database",
|
||||
short_name: "RFID DB",
|
||||
description: "Store and look up LF/HF RFID tag dumps by site",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0f1419",
|
||||
theme_color: "#1a7f6e",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Site = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
notes: string | null;
|
||||
tagCount: number;
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
async function load() {
|
||||
const res = await fetch("/api/v1/sites");
|
||||
if (res.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError("Failed to load sites");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setSites(data.sites);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function onSearch(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!q.trim()) {
|
||||
load();
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/v1/search?q=${encodeURIComponent(q.trim())}`);
|
||||
if (!res.ok) {
|
||||
setError("Search failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setSites(
|
||||
data.sites.map((s: Site & { tagCount?: number }) => ({
|
||||
...s,
|
||||
tagCount: s.tagCount ?? 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function createSite(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/v1/sites", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, name, notes: notes || null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error?.message || "Could not create site");
|
||||
return;
|
||||
}
|
||||
setShowNew(false);
|
||||
setCode("");
|
||||
setName("");
|
||||
setNotes("");
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<h1 className="page-title">Sites</h1>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>
|
||||
Browse tag dumps by location
|
||||
</p>
|
||||
</div>
|
||||
<div className="row">
|
||||
<Link className="btn btn-secondary" href="/tags/new">
|
||||
New tag
|
||||
</Link>
|
||||
<button type="button" className="btn" onClick={() => setShowNew((v) => !v)}>
|
||||
New site
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<form className="row" onSubmit={onSearch} style={{ marginBottom: "1rem" }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
placeholder="Search sites, UIDs, labels…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-secondary" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{showNew && (
|
||||
<form className="panel stack" onSubmit={createSite} style={{ marginBottom: "1rem" }}>
|
||||
<div className="row">
|
||||
<div className="field" style={{ flex: "0 0 6rem", marginBottom: 0 }}>
|
||||
<label className="label">Code</label>
|
||||
<input className="input" value={code} onChange={(e) => setCode(e.target.value)} required placeholder="A" />
|
||||
</div>
|
||||
<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="Building 1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Notes</label>
|
||||
<input className="input" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn" type="submit">
|
||||
Create site
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="panel" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{sites.length === 0 ? (
|
||||
<p className="muted" style={{ padding: "1.25rem" }}>
|
||||
No sites yet. Create one to start storing dumps.
|
||||
</p>
|
||||
) : (
|
||||
sites.map((site) => (
|
||||
<Link key={site.id} href={`/sites/${site.id}`} className="list-row">
|
||||
<div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<span className="badge badge-accent">{site.code}</span>
|
||||
<strong>{site.name}</strong>
|
||||
</div>
|
||||
{site.notes && (
|
||||
<div className="muted" style={{ fontSize: "0.85rem", marginTop: "0.25rem" }}>
|
||||
{site.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="muted">{site.tagCount} tags</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { formatUid } from "@/lib/rfid/uid";
|
||||
|
||||
type Site = { id: string; name: string; code: string; notes: string | null };
|
||||
type Tag = {
|
||||
id: string;
|
||||
label: string;
|
||||
frequency: string;
|
||||
protocol: string;
|
||||
uid: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export default function SitePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [site, setSite] = useState<Site | null>(null);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [siteRes, tagsRes] = await Promise.all([
|
||||
fetch(`/api/v1/sites/${id}`),
|
||||
fetch(`/api/v1/tags?siteId=${id}`),
|
||||
]);
|
||||
if (siteRes.status === 401 || tagsRes.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (!siteRes.ok) {
|
||||
setError("Site not found");
|
||||
return;
|
||||
}
|
||||
setSite(await siteRes.json());
|
||||
const tagData = await tagsRes.json();
|
||||
setTags(tagData.tags);
|
||||
}
|
||||
load();
|
||||
}, [id, router]);
|
||||
|
||||
async function deleteSite() {
|
||||
if (!confirm("Delete this site and all its tags?")) return;
|
||||
const res = await fetch(`/api/v1/sites/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
setError("Failed to delete site");
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
if (error) return <div className="error-banner">{error}</div>;
|
||||
if (!site) return <p className="muted">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<div className="row" style={{ gap: "0.5rem", marginBottom: "0.35rem" }}>
|
||||
<span className="badge badge-accent">{site.code}</span>
|
||||
<h1 className="page-title" style={{ margin: 0 }}>
|
||||
{site.name}
|
||||
</h1>
|
||||
</div>
|
||||
{site.notes && <p className="page-sub">{site.notes}</p>}
|
||||
</div>
|
||||
<div className="row">
|
||||
<Link className="btn" href={`/tags/new?siteId=${site.id}`}>
|
||||
Add tag
|
||||
</Link>
|
||||
<button type="button" className="btn btn-danger" onClick={deleteSite}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{tags.length === 0 ? (
|
||||
<p className="muted" style={{ padding: "1.25rem" }}>
|
||||
No tags at this site yet.
|
||||
</p>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<Link key={tag.id} href={`/tags/${tag.id}`} className="list-row">
|
||||
<div>
|
||||
<strong>{tag.label}</strong>
|
||||
<div className="muted mono" style={{ fontSize: "0.85rem", marginTop: "0.2rem" }}>
|
||||
{formatUid(tag.uid)} · {tag.protocol.replaceAll("_", " ")}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge">{tag.frequency}</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { CopyButton } from "@/components/CopyButton";
|
||||
import { formatUid } from "@/lib/rfid/uid";
|
||||
import {
|
||||
isLfDump,
|
||||
isMifareClassicDump,
|
||||
isNtagDump,
|
||||
} from "@/lib/rfid/dump-types";
|
||||
|
||||
type Tag = {
|
||||
id: string;
|
||||
siteId: string;
|
||||
siteCode: string;
|
||||
siteName: string;
|
||||
label: string;
|
||||
frequency: string;
|
||||
protocol: string;
|
||||
uid: string;
|
||||
dumpData: unknown;
|
||||
keys: { A?: string[]; B?: string[] } | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export default function TagDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [tag, setTag] = useState<Tag | null>(null);
|
||||
const [tab, setTab] = useState<"dump" | "keys" | "export">("dump");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/tags/${id}`)
|
||||
.then(async (res) => {
|
||||
if (res.status === 401) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError("Tag not found");
|
||||
return;
|
||||
}
|
||||
setTag(await res.json());
|
||||
})
|
||||
.catch(() => setError("Failed to load tag"));
|
||||
}, [id, router]);
|
||||
|
||||
async function duplicate() {
|
||||
if (!tag) return;
|
||||
const res = await fetch("/api/v1/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siteId: tag.siteId,
|
||||
label: `${tag.label} (copy)`,
|
||||
frequency: tag.frequency,
|
||||
protocol: tag.protocol,
|
||||
uid: tag.uid,
|
||||
dumpData: tag.dumpData,
|
||||
keys: tag.keys,
|
||||
notes: tag.notes,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Duplicate failed");
|
||||
return;
|
||||
}
|
||||
router.push(`/tags/${data.id}`);
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm("Delete this tag record?")) return;
|
||||
const res = await fetch(`/api/v1/tags/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
setError("Delete failed");
|
||||
return;
|
||||
}
|
||||
router.push(`/sites/${tag?.siteId}`);
|
||||
}
|
||||
|
||||
function download(format: string) {
|
||||
window.location.href = `/api/v1/tags/${id}/export?format=${format}&download=1`;
|
||||
}
|
||||
|
||||
if (error) return <div className="error-banner">{error}</div>;
|
||||
if (!tag) return <p className="muted">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="muted" style={{ marginBottom: "0.5rem", fontSize: "0.9rem" }}>
|
||||
<Link href={`/sites/${tag.siteId}`}>
|
||||
Site {tag.siteCode} · {tag.siteName}
|
||||
</Link>
|
||||
{" · "}
|
||||
{tag.protocol.replaceAll("_", " ")}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<h1 className="page-title">{tag.label}</h1>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<span className="mono" style={{ fontSize: "1.05rem" }}>
|
||||
{formatUid(tag.uid)}
|
||||
</span>
|
||||
<CopyButton value={tag.uid} />
|
||||
<span className="badge">{tag.frequency}</span>
|
||||
</div>
|
||||
{tag.notes && <p className="page-sub">{tag.notes}</p>}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button type="button" className="btn btn-secondary" onClick={duplicate}>
|
||||
Duplicate
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={remove}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginBottom: "0.75rem" }}>
|
||||
{(["dump", "keys", "export"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`btn ${tab === t ? "" : "btn-secondary"}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t[0].toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
{tab === "dump" && <DumpView dumpData={tag.dumpData} />}
|
||||
{tab === "keys" && <KeysView keys={tag.keys} />}
|
||||
{tab === "export" && (
|
||||
<div className="stack">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Download a writer-ready dump, or fetch the same payload via the API.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button type="button" className="btn" onClick={() => download("json")}>
|
||||
JSON
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => download("proxmark")}>
|
||||
Proxmark
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => download("mct")}>
|
||||
MCT
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => download("hex")}>
|
||||
Hex
|
||||
</button>
|
||||
</div>
|
||||
<code className="mono muted" style={{ fontSize: "0.8rem" }}>
|
||||
GET /api/v1/tags/{tag.id}/export?format=proxmark
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DumpView({ dumpData }: { dumpData: unknown }) {
|
||||
if (isMifareClassicDump(dumpData)) {
|
||||
return (
|
||||
<div className="dump-grid stack">
|
||||
<div className="muted">MIFARE Classic {dumpData.size}</div>
|
||||
{[...dumpData.sectors]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((sector) => (
|
||||
<div key={sector.index}>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<strong>Sector {sector.index}</strong>
|
||||
<CopyButton value={sector.blocks.join("\n")} label="Copy sector" />
|
||||
</div>
|
||||
{sector.blocks.map((block, i) => (
|
||||
<div className="dump-block" key={i}>
|
||||
<span className="muted">B{i}</span>
|
||||
<code>{block.toUpperCase()}</code>
|
||||
<CopyButton value={block.toUpperCase()} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNtagDump(dumpData)) {
|
||||
return (
|
||||
<div className="dump-grid">
|
||||
{[...dumpData.pages]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((page) => (
|
||||
<div className="dump-block" key={page.index}>
|
||||
<span className="muted">P{page.index}</span>
|
||||
<code>{page.data.toUpperCase()}</code>
|
||||
<CopyButton value={page.data.toUpperCase()} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLfDump(dumpData)) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<div>
|
||||
Format: <strong>{dumpData.format}</strong>
|
||||
</div>
|
||||
<div className="row">
|
||||
<code className="mono">{dumpData.uidBytes.toUpperCase()}</code>
|
||||
<CopyButton value={dumpData.uidBytes.toUpperCase()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="mono" style={{ margin: 0, whiteSpace: "pre-wrap", fontSize: "0.8rem" }}>
|
||||
{JSON.stringify(dumpData, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function KeysView({ keys }: { keys: Tag["keys"] }) {
|
||||
if (!keys || (!keys.A?.length && !keys.B?.length)) {
|
||||
return <p className="muted">No keys stored for this tag.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{keys.A && keys.A.length > 0 && (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<strong>Key A</strong>
|
||||
<CopyButton value={keys.A.join("\n")} label="Copy all A" />
|
||||
</div>
|
||||
{keys.A.map((k, i) => (
|
||||
<div className="dump-block" key={`A-${i}`}>
|
||||
<span className="muted">S{i}</span>
|
||||
<code className="mono">{k.toUpperCase()}</code>
|
||||
<CopyButton value={k.toUpperCase()} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{keys.B && keys.B.length > 0 && (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<strong>Key B</strong>
|
||||
<CopyButton value={keys.B.join("\n")} label="Copy all B" />
|
||||
</div>
|
||||
{keys.B.map((k, i) => (
|
||||
<div className="dump-block" key={`B-${i}`}>
|
||||
<span className="muted">S{i}</span>
|
||||
<code className="mono">{k.toUpperCase()}</code>
|
||||
<CopyButton value={k.toUpperCase()} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { PROTOCOLS, LF_PROTOCOLS, type Protocol } from "@/lib/rfid/protocols";
|
||||
|
||||
type Site = { id: string; name: string; code: string };
|
||||
|
||||
function NewTagForm() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [siteId, setSiteId] = useState(search.get("siteId") || "");
|
||||
const [label, setLabel] = useState("");
|
||||
const [protocol, setProtocol] = useState<Protocol>("MIFARE_CLASSIC_1K");
|
||||
const [uid, setUid] = useState("");
|
||||
const [dumpText, setDumpText] = useState("");
|
||||
const [keysText, setKeysText] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [importMode, setImportMode] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const frequency = useMemo(
|
||||
() => (LF_PROTOCOLS.includes(protocol) ? "LF" : "HF"),
|
||||
[protocol]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/sites")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setSites(d.sites || []);
|
||||
if (!siteId && d.sites?.[0]) setSiteId(d.sites[0].id);
|
||||
});
|
||||
}, [siteId]);
|
||||
|
||||
function defaultDump(): unknown {
|
||||
if (LF_PROTOCOLS.includes(protocol)) {
|
||||
return { uidBytes: uid.replace(/[^0-9a-fA-F]/g, "").toUpperCase(), format: protocol };
|
||||
}
|
||||
if (protocol.startsWith("NTAG") || protocol === "MIFARE_UL") {
|
||||
return { pages: [{ index: 0, data: "00000000" }] };
|
||||
}
|
||||
if (protocol.includes("CLASSIC")) {
|
||||
return {
|
||||
size: protocol.includes("4K") ? "4K" : "1K",
|
||||
sectors: [{ index: 0, blocks: ["00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000", "FFFFFFFFFFFFFF078069FFFFFFFFFFFF"] }],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (importMode) {
|
||||
const res = await fetch("/api/v1/tags/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siteId,
|
||||
label: label || undefined,
|
||||
notes: notes || null,
|
||||
content: dumpText,
|
||||
filename: "import.json",
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Import failed");
|
||||
return;
|
||||
}
|
||||
router.push(`/tags/${data.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let dumpData: unknown = defaultDump();
|
||||
if (dumpText.trim()) {
|
||||
try {
|
||||
dumpData = JSON.parse(dumpText);
|
||||
} catch {
|
||||
setError("dumpData must be valid JSON");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let keys: unknown = null;
|
||||
if (keysText.trim()) {
|
||||
try {
|
||||
keys = JSON.parse(keysText);
|
||||
} catch {
|
||||
setError("keys must be valid JSON");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch("/api/v1/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siteId,
|
||||
label,
|
||||
frequency,
|
||||
protocol,
|
||||
uid,
|
||||
dumpData,
|
||||
keys,
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Could not create tag");
|
||||
return;
|
||||
}
|
||||
router.push(`/tags/${data.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">New tag</h1>
|
||||
<p className="page-sub">Store a dump for later write-out</p>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="row" style={{ marginBottom: "1rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${!importMode ? "" : "btn-secondary"}`}
|
||||
onClick={() => setImportMode(false)}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${importMode ? "" : "btn-secondary"}`}
|
||||
onClick={() => setImportMode(true)}
|
||||
>
|
||||
Import dump
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="panel stack" onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
<label className="label">Site</label>
|
||||
<select className="select" value={siteId} onChange={(e) => setSiteId(e.target.value)} required>
|
||||
<option value="" disabled>
|
||||
Select site
|
||||
</option>
|
||||
{sites.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.code} — {s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Label</label>
|
||||
<input className="input" value={label} onChange={(e) => setLabel(e.target.value)} required={!importMode} placeholder="Front door fob" />
|
||||
</div>
|
||||
|
||||
{!importMode && (
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
<label className="label">Protocol</label>
|
||||
<select
|
||||
className="select"
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value as Protocol)}
|
||||
>
|
||||
{PROTOCOLS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p.replaceAll("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ flex: "0 0 5rem" }}>
|
||||
<label className="label">Band</label>
|
||||
<input className="input" value={frequency} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">UID</label>
|
||||
<input className="input mono" value={uid} onChange={(e) => setUid(e.target.value)} required placeholder="04:A1:B2:C3" />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">dumpData (JSON, optional — defaults applied)</label>
|
||||
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} placeholder='{"size":"1K","sectors":[...]}' />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">keys (JSON, optional)</label>
|
||||
<textarea className="textarea" value={keysText} onChange={(e) => setKeysText(e.target.value)} placeholder='{"A":["FFFFFFFFFFFF"],"B":[]}' />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importMode && (
|
||||
<div className="field">
|
||||
<label className="label">Paste JSON / MCT / Proxmark dump</label>
|
||||
<textarea className="textarea" value={dumpText} onChange={(e) => setDumpText(e.target.value)} required style={{ minHeight: "14rem" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Notes</label>
|
||||
<input className="input" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<button className="btn" type="submit">
|
||||
{importMode ? "Import" : "Save tag"}
|
||||
</button>
|
||||
<Link className="btn btn-secondary" href="/">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewTagPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<NewTagForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user