mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-09 16:01: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,11 @@
|
|||||||
|
AUTH_SECRET=change-me-to-a-long-random-string
|
||||||
|
# Optional: path to SQLite file (default ./data/rfid.db)
|
||||||
|
# RFID_DB_PATH=./data/rfid.db
|
||||||
|
|
||||||
|
# Optional OIDC (Authentik, Keycloak, Authelia, etc.)
|
||||||
|
# AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/
|
||||||
|
# AUTH_OIDC_CLIENT_ID=
|
||||||
|
# AUTH_OIDC_CLIENT_SECRET=
|
||||||
|
# AUTH_OIDC_NAME=SSO
|
||||||
|
# NEXT_PUBLIC_AUTH_OIDC_ENABLED=1
|
||||||
|
# NEXT_PUBLIC_AUTH_OIDC_NAME=SSO
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# local sqlite
|
||||||
|
/data/
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV AUTH_SECRET=build-time-placeholder-secret-32chars
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV RFID_DB_PATH=/data/rfid.db
|
||||||
|
ENV PORT=3000
|
||||||
|
RUN apt-get update && apt-get install -y libstdc++6 && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
COPY --from=builder /app/package.json /app/package-lock.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/data"]
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -1 +1,101 @@
|
|||||||
# rfid-database
|
# RFID Database
|
||||||
|
|
||||||
|
PWA + REST API for storing LF/HF RFID tag dumps organized by site. Use the web UI in the field, or call `/api/v1` from scripts (and a future Proxmark CLI).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Sites (A, B, C, …) with tag records (UID, protocol, keys, full dump)
|
||||||
|
- Multi-user auth (shared workspace, no RBAC yet) — local password + optional OIDC
|
||||||
|
- Personal API tokens (Settings UI) for Bearer auth
|
||||||
|
- Export: JSON, Proxmark, MCT, hex
|
||||||
|
- Import dumps + full JSON backup
|
||||||
|
- Installable PWA with offline read of cached pages
|
||||||
|
- Vitest unit/integration tests + GitHub Actions CI
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# set AUTH_SECRET to a long random string
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run create-user -- --email [email protected] --password 'changeme' --name You
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000 and sign in.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `AUTH_SECRET` | yes | NextAuth secret |
|
||||||
|
| `RFID_DB_PATH` | no | SQLite path (default `./data/rfid.db`) |
|
||||||
|
| `AUTH_OIDC_ISSUER` | no | OIDC issuer URL |
|
||||||
|
| `AUTH_OIDC_CLIENT_ID` | no | OIDC client id |
|
||||||
|
| `AUTH_OIDC_CLIENT_SECRET` | no | OIDC client secret |
|
||||||
|
| `AUTH_OIDC_NAME` | no | Button label (default `SSO`) |
|
||||||
|
| `NEXT_PUBLIC_AUTH_OIDC_ENABLED` | no | Set `1` to show SSO button |
|
||||||
|
| `NEXT_PUBLIC_AUTH_OIDC_NAME` | no | Public SSO button label |
|
||||||
|
|
||||||
|
## REST API (`/api/v1`)
|
||||||
|
|
||||||
|
Authenticate with a session cookie **or** `Authorization: Bearer rfid_…` (create tokens under **Settings → API tokens**).
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET/POST | `/api/v1/sites` | List / create sites |
|
||||||
|
| GET/PATCH/DELETE | `/api/v1/sites/{id}` | Site detail |
|
||||||
|
| GET/POST | `/api/v1/tags` | List / create tags |
|
||||||
|
| GET/PATCH/DELETE | `/api/v1/tags/{id}` | Tag detail |
|
||||||
|
| PUT | `/api/v1/tags/by-uid/{uid}?siteId=` | Upsert by UID |
|
||||||
|
| GET | `/api/v1/tags/{id}/export?format=` | `json` \| `proxmark` \| `mct` \| `hex` |
|
||||||
|
| POST | `/api/v1/tags/import` | Parse dump → create tag |
|
||||||
|
| GET | `/api/v1/search?q=` | Cross-site search |
|
||||||
|
| GET/POST | `/api/v1/backup` | Full backup export / import |
|
||||||
|
| GET/POST | `/api/v1/tokens` | List / create PATs |
|
||||||
|
| DELETE | `/api/v1/tokens/{id}` | Revoke PAT |
|
||||||
|
| GET | `/api/v1/me` | Current user |
|
||||||
|
|
||||||
|
### curl examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a token in the UI first, then:
|
||||||
|
export TOKEN=rfid_…
|
||||||
|
export HOST=http://localhost:3000
|
||||||
|
|
||||||
|
curl -sH "Authorization: Bearer $TOKEN" "$HOST/api/v1/sites"
|
||||||
|
|
||||||
|
curl -sH "Authorization: Bearer $TOKEN" \
|
||||||
|
"$HOST/api/v1/tags/$ID/export?format=proxmark" -o dump.json
|
||||||
|
|
||||||
|
curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-X PUT "$HOST/api/v1/tags/by-uid/04A1B2C3?siteId=$SITE" \
|
||||||
|
-d '{"label":"Dock fob","frequency":"HF","protocol":"MIFARE_CLASSIC_1K","uid":"04A1B2C3","dumpData":{"size":"1K","sectors":[]},"keys":{"A":["FFFFFFFFFFFF"]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests & CI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub Actions runs lint, typecheck, and tests on every pull request and push to `main`.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t rfid-database .
|
||||||
|
docker run --rm -p 3000:3000 \
|
||||||
|
-e AUTH_SECRET=your-long-secret \
|
||||||
|
-v rfid-data:/data \
|
||||||
|
rfid-database
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the first user against the mounted DB (exec into the container or run `create-user` with `RFID_DB_PATH` pointed at the volume).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Tag dumps often include sector keys. Keep the app behind HTTPS, do not expose it publicly without auth, and treat `data/rfid.db` as sensitive.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.RFID_DB_PATH ?? "./data/rfid.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"node_modules/**",
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: RFID Database API
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Versioned REST API for RFID tag dump storage. Authenticate with session cookie or Bearer personal access token (rfid_…).
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
paths:
|
||||||
|
/sites:
|
||||||
|
get:
|
||||||
|
summary: List sites
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
post:
|
||||||
|
summary: Create site
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
/sites/{id}:
|
||||||
|
get:
|
||||||
|
summary: Get site
|
||||||
|
patch:
|
||||||
|
summary: Update site
|
||||||
|
delete:
|
||||||
|
summary: Delete site
|
||||||
|
/tags:
|
||||||
|
get:
|
||||||
|
summary: List tags
|
||||||
|
post:
|
||||||
|
summary: Create tag
|
||||||
|
/tags/{id}:
|
||||||
|
get:
|
||||||
|
summary: Get tag
|
||||||
|
patch:
|
||||||
|
summary: Update tag
|
||||||
|
delete:
|
||||||
|
summary: Delete tag
|
||||||
|
/tags/by-uid/{uid}:
|
||||||
|
put:
|
||||||
|
summary: Upsert tag by UID within a site
|
||||||
|
/tags/{id}/export:
|
||||||
|
get:
|
||||||
|
summary: Export tag dump (json|proxmark|mct|hex)
|
||||||
|
/tags/import:
|
||||||
|
post:
|
||||||
|
summary: Import dump content into a new tag
|
||||||
|
/search:
|
||||||
|
get:
|
||||||
|
summary: Search sites and tags
|
||||||
|
/backup:
|
||||||
|
get:
|
||||||
|
summary: Export full backup JSON
|
||||||
|
post:
|
||||||
|
summary: Import backup (merge or replace)
|
||||||
|
/tokens:
|
||||||
|
get:
|
||||||
|
summary: List current user API tokens
|
||||||
|
post:
|
||||||
|
summary: Create API token (plaintext returned once)
|
||||||
|
/tokens/{id}:
|
||||||
|
delete:
|
||||||
|
summary: Revoke API token
|
||||||
|
/me:
|
||||||
|
get:
|
||||||
|
summary: Current user profile
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
Generated
+9163
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "rfid-database",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"create-user": "tsx scripts/create-user.ts",
|
||||||
|
"db:ensure": "tsx -e \"import { getDb, resetDbConnection } from './src/db/client'; getDb(); resetDbConnection(); console.log('ok');\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"next": "15.5.23",
|
||||||
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"uuid": "^14.0.2",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.5.23",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 413 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
/* Simple offline cache for recently viewed pages and static assets */
|
||||||
|
const CACHE = "rfid-db-v1";
|
||||||
|
const PRECACHE = ["/", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
// Network-first for API
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req).catch(() => caches.match(req).then((r) => r || Response.error()))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for same-origin navigations and static
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then((cached) => {
|
||||||
|
const fetched = fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok && url.origin === self.location.origin) {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE).then((cache) => cache.put(req, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => cached);
|
||||||
|
return cached || fetched;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Create a local user account.
|
||||||
|
* Usage: npm run create-user -- --email [email protected] --password 'secret' [--name "You"]
|
||||||
|
*/
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getDb, resetDbConnection } from "../src/db/client";
|
||||||
|
import { users } from "../src/db/schema";
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx === -1) return undefined;
|
||||||
|
return process.argv[idx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = arg("email")?.trim().toLowerCase();
|
||||||
|
const password = arg("password");
|
||||||
|
const name = arg("name") || email?.split("@")[0] || "User";
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error(
|
||||||
|
"Usage: npm run create-user -- --email [email protected] --password 'secret' [--name Name]"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
|
if (existing) {
|
||||||
|
console.error(`User already exists: ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.insert(users)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
passwordHash,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
console.log(`Created user ${email} (${id})`);
|
||||||
|
resetDbConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getDb, resetDbConnection } from "../src/db/client";
|
||||||
|
import { apiTokens, users } from "../src/db/schema";
|
||||||
|
import { generateApiToken } from "../src/lib/auth/tokens";
|
||||||
|
|
||||||
|
const email = process.argv[2] || "[email protected]";
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
|
if (!user) {
|
||||||
|
console.error("User not found:", email);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const g = generateApiToken();
|
||||||
|
db.insert(apiTokens)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
name: "smoke",
|
||||||
|
tokenHash: g.tokenHash,
|
||||||
|
prefix: g.prefix,
|
||||||
|
expiresAt: null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
console.log(g.token);
|
||||||
|
resetDbConnection();
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname === "/login") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
background: "rgba(15, 20, 25, 0.85)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="container"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0.75rem 0",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link href="/" style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}>
|
||||||
|
<span style={{ fontWeight: 800, letterSpacing: "-0.03em", fontSize: "1.1rem" }}>
|
||||||
|
RFID DB
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ fontSize: "0.75rem" }}>
|
||||||
|
tag dumps
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="row" style={{ gap: "0.25rem" }}>
|
||||||
|
<Link className="btn btn-ghost" href="/">
|
||||||
|
Sites
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-ghost" href="/tags/new">
|
||||||
|
New tag
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-ghost" href="/settings">
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
{session?.user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function CopyButton({ value, label = "Copy" }: { value: string; label?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onCopy} style={{ padding: "0.3rem 0.6rem", fontSize: "0.8rem" }}>
|
||||||
|
{copied ? "Copied" : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||||
|
// ignore registration failures in dev
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
export type AppDatabase = BetterSQLite3Database<typeof schema>;
|
||||||
|
|
||||||
|
let _db: AppDatabase | null = null;
|
||||||
|
let _sqlite: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function getDbPath(): string {
|
||||||
|
if (process.env.RFID_DB_PATH) {
|
||||||
|
return process.env.RFID_DB_PATH;
|
||||||
|
}
|
||||||
|
return path.join(process.cwd(), "data", "rfid.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSchema(sqlite: Database.Database) {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_account_id TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
access_token TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
token_type TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
id_token TEXT,
|
||||||
|
session_state TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS accounts_user_id_idx ON accounts(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
session_token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS verification_tokens (
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
expires INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
expires_at INTEGER,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_user_id_idx ON api_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
notes TEXT,
|
||||||
|
created_by_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS sites_code_idx ON sites(code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_records (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
frequency TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
dump_data TEXT NOT NULL,
|
||||||
|
keys TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
last_written_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_records_site_id_idx ON tag_records(site_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_records_uid_idx ON tag_records(uid);
|
||||||
|
CREATE INDEX IF NOT EXISTS tag_records_site_uid_idx ON tag_records(site_id, uid);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(): AppDatabase {
|
||||||
|
if (_db) return _db;
|
||||||
|
|
||||||
|
const dbPath = getDbPath();
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
ensureSchema(sqlite);
|
||||||
|
|
||||||
|
_sqlite = sqlite;
|
||||||
|
_db = drizzle(sqlite, { schema });
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset singleton — used by tests with temp DB paths. */
|
||||||
|
export function resetDbConnection() {
|
||||||
|
if (_sqlite) {
|
||||||
|
_sqlite.close();
|
||||||
|
}
|
||||||
|
_sqlite = null;
|
||||||
|
_db = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
passwordHash: text("password_hash"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accounts = sqliteTable(
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
providerAccountId: text("provider_account_id").notNull(),
|
||||||
|
refresh_token: text("refresh_token"),
|
||||||
|
access_token: text("access_token"),
|
||||||
|
expires_at: integer("expires_at"),
|
||||||
|
token_type: text("token_type"),
|
||||||
|
scope: text("scope"),
|
||||||
|
id_token: text("id_token"),
|
||||||
|
session_state: text("session_state"),
|
||||||
|
},
|
||||||
|
(t) => [index("accounts_user_id_idx").on(t.userId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const sessions = sqliteTable("sessions", {
|
||||||
|
sessionToken: text("session_token").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verificationTokens = sqliteTable("verification_tokens", {
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
token: text("token").notNull(),
|
||||||
|
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiTokens = sqliteTable(
|
||||||
|
"api_tokens",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
tokenHash: text("token_hash").notNull().unique(),
|
||||||
|
prefix: text("prefix").notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
||||||
|
lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("api_tokens_user_id_idx").on(t.userId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const sites = sqliteTable(
|
||||||
|
"sites",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
code: text("code").notNull().unique(),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdById: text("created_by_id").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("sites_code_idx").on(t.code)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tagRecords = sqliteTable(
|
||||||
|
"tag_records",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
siteId: text("site_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sites.id, { onDelete: "cascade" }),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
frequency: text("frequency").notNull(), // LF | HF
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
uid: text("uid").notNull(),
|
||||||
|
dumpData: text("dump_data", { mode: "json" }).notNull(),
|
||||||
|
keys: text("keys", { mode: "json" }),
|
||||||
|
notes: text("notes"),
|
||||||
|
createdById: text("created_by_id").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
updatedById: text("updated_by_id").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
lastWrittenAt: integer("last_written_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("tag_records_site_id_idx").on(t.siteId),
|
||||||
|
index("tag_records_uid_idx").on(t.uid),
|
||||||
|
index("tag_records_site_uid_idx").on(t.siteId, t.uid),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type Site = typeof sites.$inferSelect;
|
||||||
|
export type TagRecord = typeof tagRecords.$inferSelect;
|
||||||
|
export type ApiToken = typeof apiTokens.$inferSelect;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getDb } from "@/db/client";
|
||||||
|
import { apiTokens, users, type User } from "@/db/schema";
|
||||||
|
import { auth } from "@/lib/auth/auth";
|
||||||
|
import { hashToken, isApiTokenFormat } from "@/lib/auth/tokens";
|
||||||
|
import { jsonError, jsonOk } from "@/lib/api/errors";
|
||||||
|
|
||||||
|
export type AuthUser = Pick<User, "id" | "email" | "name">;
|
||||||
|
|
||||||
|
export { jsonError, jsonOk };
|
||||||
|
|
||||||
|
export async function requireApiUser(
|
||||||
|
request: NextRequest
|
||||||
|
): Promise<AuthUser | Response> {
|
||||||
|
const header = request.headers.get("authorization");
|
||||||
|
if (header?.toLowerCase().startsWith("bearer ")) {
|
||||||
|
const token = header.slice(7).trim();
|
||||||
|
if (!isApiTokenFormat(token)) {
|
||||||
|
return jsonError(401, "UNAUTHORIZED", "Invalid API token");
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
const row = db
|
||||||
|
.select({
|
||||||
|
tokenId: apiTokens.id,
|
||||||
|
expiresAt: apiTokens.expiresAt,
|
||||||
|
userId: users.id,
|
||||||
|
email: users.email,
|
||||||
|
name: users.name,
|
||||||
|
})
|
||||||
|
.from(apiTokens)
|
||||||
|
.innerJoin(users, eq(apiTokens.userId, users.id))
|
||||||
|
.where(eq(apiTokens.tokenHash, tokenHash))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return jsonError(401, "UNAUTHORIZED", "Invalid API token");
|
||||||
|
}
|
||||||
|
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
|
||||||
|
return jsonError(401, "UNAUTHORIZED", "API token expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(apiTokens)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(apiTokens.id, row.tokenId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { id: row.userId, email: row.email, name: row.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return jsonError(401, "UNAUTHORIZED", "Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.user.id,
|
||||||
|
email: session.user.email ?? "",
|
||||||
|
name: session.user.name ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function jsonError(
|
||||||
|
status: number,
|
||||||
|
code: string,
|
||||||
|
message: string
|
||||||
|
): Response {
|
||||||
|
return Response.json({ error: { code, message } }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonOk<T>(data: T, status = 200): Response {
|
||||||
|
return Response.json(data, { status });
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge-safe auth config (no Node DB imports).
|
||||||
|
* Used by middleware; full providers live in auth.ts.
|
||||||
|
*/
|
||||||
|
export const authConfig = {
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
providers: [],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user?.id) {
|
||||||
|
token.sub = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token.sub) {
|
||||||
|
session.user.id = token.sub;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
authorized({ auth, request }) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/login") ||
|
||||||
|
pathname.startsWith("/api/auth") ||
|
||||||
|
pathname === "/manifest.webmanifest" ||
|
||||||
|
pathname === "/sw.js" ||
|
||||||
|
pathname.startsWith("/icons")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// API v1 uses its own Bearer/session check in handlers
|
||||||
|
if (pathname.startsWith("/api/v1")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!auth?.user;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustHost: true,
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import type { Provider } from "next-auth/providers";
|
||||||
|
import { compare } from "bcryptjs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getDb } from "@/db/client";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { authConfig } from "@/lib/auth/auth.config";
|
||||||
|
|
||||||
|
function buildProviders(): Provider[] {
|
||||||
|
const providers: Provider[] = [
|
||||||
|
Credentials({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const email = credentials?.email?.toString().trim().toLowerCase();
|
||||||
|
const password = credentials?.password?.toString() ?? "";
|
||||||
|
if (!email || !password) return null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
|
if (!user?.passwordHash) return null;
|
||||||
|
|
||||||
|
const ok = await compare(password, user.passwordHash);
|
||||||
|
if (!ok) return null;
|
||||||
|
|
||||||
|
return { id: user.id, email: user.email, name: user.name };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const issuer = process.env.AUTH_OIDC_ISSUER;
|
||||||
|
const clientId = process.env.AUTH_OIDC_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
|
||||||
|
if (issuer && clientId && clientSecret) {
|
||||||
|
providers.push({
|
||||||
|
id: "oidc",
|
||||||
|
name: process.env.AUTH_OIDC_NAME || "SSO",
|
||||||
|
type: "oidc",
|
||||||
|
issuer,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
allowDangerousEmailAccountLinking: true,
|
||||||
|
} as Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: buildProviders(),
|
||||||
|
callbacks: {
|
||||||
|
...authConfig.callbacks,
|
||||||
|
async signIn({ user, account }) {
|
||||||
|
if (account?.provider === "credentials") return true;
|
||||||
|
if (!user.email) return false;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
let existing = db.select().from(users).where(eq(users.email, email)).get();
|
||||||
|
if (!existing) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.insert(users)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name: user.name || email.split("@")[0],
|
||||||
|
passwordHash: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
existing = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
user.id = existing.id;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isOidcConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.AUTH_OIDC_ISSUER &&
|
||||||
|
process.env.AUTH_OIDC_CLIENT_ID &&
|
||||||
|
process.env.AUTH_OIDC_CLIENT_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const TOKEN_PREFIX = "rfid_";
|
||||||
|
|
||||||
|
export function generateApiToken(): {
|
||||||
|
token: string;
|
||||||
|
tokenHash: string;
|
||||||
|
prefix: string;
|
||||||
|
} {
|
||||||
|
const secret = randomBytes(32).toString("base64url");
|
||||||
|
const token = `${TOKEN_PREFIX}${secret}`;
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
tokenHash: hashToken(token),
|
||||||
|
prefix: token.slice(0, 12),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiTokenFormat(token: string): boolean {
|
||||||
|
return token.startsWith(TOKEN_PREFIX) && token.length > TOKEN_PREFIX.length + 16;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { MifareClassicDump, NtagDump, LfDump } from "@/lib/validation/rfid";
|
||||||
|
|
||||||
|
export function isMifareClassicDump(data: unknown): data is MifareClassicDump {
|
||||||
|
return (
|
||||||
|
typeof data === "object" &&
|
||||||
|
data !== null &&
|
||||||
|
"sectors" in data &&
|
||||||
|
Array.isArray((data as MifareClassicDump).sectors)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNtagDump(data: unknown): data is NtagDump {
|
||||||
|
return (
|
||||||
|
typeof data === "object" &&
|
||||||
|
data !== null &&
|
||||||
|
"pages" in data &&
|
||||||
|
Array.isArray((data as NtagDump).pages)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLfDump(data: unknown): data is LfDump {
|
||||||
|
return (
|
||||||
|
typeof data === "object" &&
|
||||||
|
data !== null &&
|
||||||
|
"uidBytes" in data &&
|
||||||
|
typeof (data as LfDump).uidBytes === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
isLfDump,
|
||||||
|
isMifareClassicDump,
|
||||||
|
isNtagDump,
|
||||||
|
} from "@/lib/rfid/dump-types";
|
||||||
|
import type { ExportFormat } from "@/lib/rfid/types";
|
||||||
|
|
||||||
|
export type { CanonicalTag, ExportFormat } from "@/lib/rfid/types";
|
||||||
|
|
||||||
|
export function exportTag(
|
||||||
|
tag: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
frequency: string;
|
||||||
|
protocol: string;
|
||||||
|
uid: string;
|
||||||
|
dumpData: unknown;
|
||||||
|
keys: unknown;
|
||||||
|
notes: string | null;
|
||||||
|
},
|
||||||
|
format: ExportFormat
|
||||||
|
): { body: string; contentType: string; filename: string } {
|
||||||
|
switch (format) {
|
||||||
|
case "json":
|
||||||
|
return {
|
||||||
|
body: JSON.stringify(
|
||||||
|
{
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
frequency: tag.frequency,
|
||||||
|
protocol: tag.protocol,
|
||||||
|
uid: tag.uid,
|
||||||
|
dumpData: tag.dumpData,
|
||||||
|
keys: tag.keys,
|
||||||
|
notes: tag.notes,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
contentType: "application/json",
|
||||||
|
filename: `${safeName(tag.label)}.json`,
|
||||||
|
};
|
||||||
|
case "proxmark":
|
||||||
|
return {
|
||||||
|
body: exportProxmark(tag),
|
||||||
|
contentType: "application/json",
|
||||||
|
filename: `${safeName(tag.label)}.pm3.json`,
|
||||||
|
};
|
||||||
|
case "mct":
|
||||||
|
return {
|
||||||
|
body: exportMct(tag),
|
||||||
|
contentType: "text/plain",
|
||||||
|
filename: `${safeName(tag.label)}.mct`,
|
||||||
|
};
|
||||||
|
case "hex":
|
||||||
|
return {
|
||||||
|
body: exportHex(tag),
|
||||||
|
contentType: "text/plain",
|
||||||
|
filename: `${safeName(tag.label)}.hex`,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported format: ${format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeName(label: string): string {
|
||||||
|
return label.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 64) || "tag";
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportProxmark(tag: {
|
||||||
|
uid: string;
|
||||||
|
protocol: string;
|
||||||
|
dumpData: unknown;
|
||||||
|
keys: unknown;
|
||||||
|
}): string {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
if (isMifareClassicDump(tag.dumpData)) {
|
||||||
|
for (const sector of [...tag.dumpData.sectors].sort(
|
||||||
|
(a, b) => a.index - b.index
|
||||||
|
)) {
|
||||||
|
for (const block of sector.blocks) {
|
||||||
|
blocks.push(block.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
Created: new Date().toISOString(),
|
||||||
|
FileType: "mfcard",
|
||||||
|
Card: {
|
||||||
|
UID: tag.uid.toUpperCase(),
|
||||||
|
ATQA: "",
|
||||||
|
SAK: "",
|
||||||
|
},
|
||||||
|
blocks,
|
||||||
|
SectorKeys: tag.keys ?? {},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportMct(tag: {
|
||||||
|
uid: string;
|
||||||
|
dumpData: unknown;
|
||||||
|
}): string {
|
||||||
|
const lines: string[] = [`+UID: ${tag.uid.toUpperCase()}`];
|
||||||
|
if (isMifareClassicDump(tag.dumpData)) {
|
||||||
|
for (const sector of [...tag.dumpData.sectors].sort(
|
||||||
|
(a, b) => a.index - b.index
|
||||||
|
)) {
|
||||||
|
lines.push(`+Sector: ${sector.index}`);
|
||||||
|
for (const block of sector.blocks) {
|
||||||
|
lines.push(block.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isNtagDump(tag.dumpData)) {
|
||||||
|
for (const page of [...tag.dumpData.pages].sort(
|
||||||
|
(a, b) => a.index - b.index
|
||||||
|
)) {
|
||||||
|
lines.push(`+Page: ${page.index}`);
|
||||||
|
lines.push(page.data.toUpperCase());
|
||||||
|
}
|
||||||
|
} else if (isLfDump(tag.dumpData)) {
|
||||||
|
lines.push(`+LF: ${tag.dumpData.format}`);
|
||||||
|
lines.push(tag.dumpData.uidBytes.toUpperCase());
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportHex(tag: { dumpData: unknown; uid: string }): string {
|
||||||
|
const lines: string[] = [`# UID ${tag.uid.toUpperCase()}`];
|
||||||
|
if (isMifareClassicDump(tag.dumpData)) {
|
||||||
|
for (const sector of [...tag.dumpData.sectors].sort(
|
||||||
|
(a, b) => a.index - b.index
|
||||||
|
)) {
|
||||||
|
sector.blocks.forEach((block, i) => {
|
||||||
|
lines.push(`S${sector.index}B${i} ${block.toUpperCase()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (isNtagDump(tag.dumpData)) {
|
||||||
|
for (const page of [...tag.dumpData.pages].sort(
|
||||||
|
(a, b) => a.index - b.index
|
||||||
|
)) {
|
||||||
|
lines.push(`P${page.index} ${page.data.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
} else if (isLfDump(tag.dumpData)) {
|
||||||
|
lines.push(tag.dumpData.uidBytes.toUpperCase());
|
||||||
|
} else {
|
||||||
|
lines.push(JSON.stringify(tag.dumpData));
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { CanonicalTag } from "@/lib/rfid/types";
|
||||||
|
import { normalizeUid } from "@/lib/rfid/uid";
|
||||||
|
import type { Protocol } from "@/lib/rfid/protocols";
|
||||||
|
import type { MifareClassicDump } from "@/lib/validation/rfid";
|
||||||
|
|
||||||
|
export function parseImport(
|
||||||
|
content: string,
|
||||||
|
filenameHint?: string
|
||||||
|
): CanonicalTag {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
const lower = (filenameHint ?? "").toLowerCase();
|
||||||
|
|
||||||
|
if (trimmed.startsWith("{") || lower.endsWith(".json")) {
|
||||||
|
return parseJsonDump(trimmed);
|
||||||
|
}
|
||||||
|
if (lower.endsWith(".mct") || trimmed.includes("+Sector:")) {
|
||||||
|
return parseMct(trimmed);
|
||||||
|
}
|
||||||
|
// Heuristic: MCT-like or hex lines
|
||||||
|
if (trimmed.includes("+Sector:") || trimmed.includes("+UID:")) {
|
||||||
|
return parseMct(trimmed);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Unrecognized dump format. Provide JSON, MCT (.mct), or Proxmark JSON."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonDump(content: string): CanonicalTag {
|
||||||
|
const data = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Proxmark-style
|
||||||
|
if (data.FileType === "mfcard" || Array.isArray(data.blocks)) {
|
||||||
|
return parseProxmarkJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical
|
||||||
|
if (data.dumpData && data.uid && data.protocol) {
|
||||||
|
return {
|
||||||
|
label: typeof data.label === "string" ? data.label : undefined,
|
||||||
|
frequency: (data.frequency as "LF" | "HF") ?? "HF",
|
||||||
|
protocol: data.protocol as Protocol,
|
||||||
|
uid: normalizeUid(String(data.uid)),
|
||||||
|
dumpData: data.dumpData,
|
||||||
|
keys: (data.keys as CanonicalTag["keys"]) ?? undefined,
|
||||||
|
notes: typeof data.notes === "string" ? data.notes : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("JSON dump missing required fields (uid, protocol, dumpData)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProxmarkJson(data: Record<string, unknown>): CanonicalTag {
|
||||||
|
const card = (data.Card as { UID?: string } | undefined) ?? {};
|
||||||
|
const uid = normalizeUid(String(card.UID ?? data.UID ?? ""));
|
||||||
|
const blocks = (data.blocks as string[]) ?? [];
|
||||||
|
const dumpData = blocksToClassicDump(blocks);
|
||||||
|
return {
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||||
|
uid,
|
||||||
|
dumpData,
|
||||||
|
keys: (data.SectorKeys as CanonicalTag["keys"]) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocksToClassicDump(blocks: string[]): MifareClassicDump {
|
||||||
|
const normalized = blocks.map((b) => b.replace(/\s/g, "").toUpperCase());
|
||||||
|
const size = normalized.length > 64 ? "4K" : "1K";
|
||||||
|
const sectors: MifareClassicDump["sectors"] = [];
|
||||||
|
let offset = 0;
|
||||||
|
let sectorIndex = 0;
|
||||||
|
while (offset < normalized.length) {
|
||||||
|
const blocksPerSector = sectorIndex < 32 ? 4 : 16;
|
||||||
|
const slice = normalized.slice(offset, offset + blocksPerSector);
|
||||||
|
if (slice.length === 0) break;
|
||||||
|
sectors.push({ index: sectorIndex, blocks: slice });
|
||||||
|
offset += blocksPerSector;
|
||||||
|
sectorIndex += 1;
|
||||||
|
}
|
||||||
|
return { size, sectors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMct(content: string): CanonicalTag {
|
||||||
|
const lines = content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
let uid = "";
|
||||||
|
const sectors: MifareClassicDump["sectors"] = [];
|
||||||
|
let current: { index: number; blocks: string[] } | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("+UID:")) {
|
||||||
|
uid = normalizeUid(line.slice(5).trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("+Sector:")) {
|
||||||
|
if (current) sectors.push(current);
|
||||||
|
current = {
|
||||||
|
index: Number.parseInt(line.slice(8).trim(), 10),
|
||||||
|
blocks: [],
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^[0-9A-Fa-f]+$/.test(line) && current) {
|
||||||
|
current.blocks.push(line.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) sectors.push(current);
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
// try first block of sector 0
|
||||||
|
const b0 = sectors.find((s) => s.index === 0)?.blocks[0];
|
||||||
|
if (b0 && b0.length >= 8) {
|
||||||
|
uid = b0.slice(0, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!uid) throw new Error("MCT dump missing UID");
|
||||||
|
|
||||||
|
const totalBlocks = sectors.reduce((n, s) => n + s.blocks.length, 0);
|
||||||
|
const size = totalBlocks > 64 ? "4K" : "1K";
|
||||||
|
|
||||||
|
return {
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
|
||||||
|
uid,
|
||||||
|
dumpData: { size, sectors },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const FREQUENCIES = ["LF", "HF"] as const;
|
||||||
|
export type Frequency = (typeof FREQUENCIES)[number];
|
||||||
|
|
||||||
|
export const PROTOCOLS = [
|
||||||
|
"EM4100",
|
||||||
|
"HID_PROX",
|
||||||
|
"MIFARE_CLASSIC_1K",
|
||||||
|
"MIFARE_CLASSIC_4K",
|
||||||
|
"NTAG213",
|
||||||
|
"NTAG215",
|
||||||
|
"NTAG216",
|
||||||
|
"MIFARE_UL",
|
||||||
|
"ISO15693",
|
||||||
|
"UNKNOWN",
|
||||||
|
] as const;
|
||||||
|
export type Protocol = (typeof PROTOCOLS)[number];
|
||||||
|
|
||||||
|
export const LF_PROTOCOLS: Protocol[] = ["EM4100", "HID_PROX"];
|
||||||
|
export const HF_PROTOCOLS: Protocol[] = [
|
||||||
|
"MIFARE_CLASSIC_1K",
|
||||||
|
"MIFARE_CLASSIC_4K",
|
||||||
|
"NTAG213",
|
||||||
|
"NTAG215",
|
||||||
|
"NTAG216",
|
||||||
|
"MIFARE_UL",
|
||||||
|
"ISO15693",
|
||||||
|
"UNKNOWN",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function defaultFrequencyForProtocol(protocol: Protocol): Frequency {
|
||||||
|
return LF_PROTOCOLS.includes(protocol) ? "LF" : "HF";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protocolLabel(protocol: Protocol): string {
|
||||||
|
return protocol.replaceAll("_", " ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { TagKeys } from "@/lib/validation/rfid";
|
||||||
|
import type { Protocol } from "@/lib/rfid/protocols";
|
||||||
|
|
||||||
|
export type CanonicalTag = {
|
||||||
|
label?: string;
|
||||||
|
frequency: "LF" | "HF";
|
||||||
|
protocol: Protocol;
|
||||||
|
uid: string;
|
||||||
|
dumpData: unknown;
|
||||||
|
keys?: TagKeys;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportFormat = "json" | "proxmark" | "mct" | "hex";
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Normalize RFID UIDs to uppercase hex without separators.
|
||||||
|
* Accepts common forms: "04:A1:B2:C3", "04 A1 B2 C3", "04a1b2c3".
|
||||||
|
*/
|
||||||
|
export function normalizeUid(input: string): string {
|
||||||
|
const cleaned = input.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
|
||||||
|
if (cleaned.length < 4 || cleaned.length % 2 !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid UID "${input}": expected even-length hex of at least 2 bytes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display form with colon separators: 04:A1:B2:C3 */
|
||||||
|
export function formatUid(uid: string): string {
|
||||||
|
const normalized = normalizeUid(uid);
|
||||||
|
return normalized.match(/.{1,2}/g)?.join(":") ?? normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryNormalizeUid(input: string): string | null {
|
||||||
|
try {
|
||||||
|
return normalizeUid(input);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { FREQUENCIES, PROTOCOLS } from "@/lib/rfid/protocols";
|
||||||
|
|
||||||
|
export const mifareClassicDumpSchema = z.object({
|
||||||
|
size: z.enum(["1K", "4K"]),
|
||||||
|
sectors: z.array(
|
||||||
|
z.object({
|
||||||
|
index: z.number().int().min(0),
|
||||||
|
blocks: z.array(z.string().regex(/^[0-9A-Fa-f]+$/)).min(1),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ntagDumpSchema = z.object({
|
||||||
|
pages: z.array(
|
||||||
|
z.object({
|
||||||
|
index: z.number().int().min(0),
|
||||||
|
data: z.string().regex(/^[0-9A-Fa-f]+$/),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const lfDumpSchema = z.object({
|
||||||
|
uidBytes: z.string().regex(/^[0-9A-Fa-f]+$/),
|
||||||
|
format: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dumpDataSchema = z.union([
|
||||||
|
mifareClassicDumpSchema,
|
||||||
|
ntagDumpSchema,
|
||||||
|
lfDumpSchema,
|
||||||
|
z.record(z.string(), z.unknown()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const keysSchema = z
|
||||||
|
.object({
|
||||||
|
A: z.array(z.string()).optional(),
|
||||||
|
B: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.nullable()
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const siteCreateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(32)
|
||||||
|
.regex(/^[A-Za-z0-9_-]+$/, "Code must be alphanumeric"),
|
||||||
|
notes: z.string().max(5000).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteUpdateSchema = siteCreateSchema.partial();
|
||||||
|
|
||||||
|
export const tagCreateSchema = z.object({
|
||||||
|
siteId: z.string().uuid(),
|
||||||
|
label: z.string().min(1).max(200),
|
||||||
|
frequency: z.enum(FREQUENCIES),
|
||||||
|
protocol: z.enum(PROTOCOLS),
|
||||||
|
uid: z.string().min(1),
|
||||||
|
dumpData: dumpDataSchema,
|
||||||
|
keys: keysSchema,
|
||||||
|
notes: z.string().max(5000).nullable().optional(),
|
||||||
|
lastWrittenAt: z.string().datetime().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagUpdateSchema = tagCreateSchema.partial().omit({ siteId: true }).extend({
|
||||||
|
siteId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagUpsertSchema = tagCreateSchema;
|
||||||
|
|
||||||
|
export const tokenCreateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
expiresAt: z.string().datetime().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const exportFormatSchema = z.enum(["json", "proxmark", "mct", "hex"]);
|
||||||
|
|
||||||
|
export type MifareClassicDump = z.infer<typeof mifareClassicDumpSchema>;
|
||||||
|
export type NtagDump = z.infer<typeof ntagDumpSchema>;
|
||||||
|
export type LfDump = z.infer<typeof lfDumpSchema>;
|
||||||
|
export type DumpData = z.infer<typeof dumpDataSchema>;
|
||||||
|
export type TagKeys = z.infer<typeof keysSchema>;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authConfig } from "@/lib/auth/auth.config";
|
||||||
|
|
||||||
|
const { auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const isLoggedIn = !!req.auth?.user;
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn && pathname !== "/login") {
|
||||||
|
const url = new URL("/login", req.nextUrl.origin);
|
||||||
|
url.searchParams.set("callbackUrl", pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn && pathname === "/login") {
|
||||||
|
return NextResponse.redirect(new URL("/", req.nextUrl.origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|icons|.*\\.png$).*)"],
|
||||||
|
};
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silence unused
|
||||||
|
void NextAuth;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/auth", () => ({
|
||||||
|
auth: async () => null,
|
||||||
|
isOidcConfigured: () => false,
|
||||||
|
handlers: {},
|
||||||
|
signIn: async () => undefined,
|
||||||
|
signOut: async () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getDb, resetDbConnection } from "@/db/client";
|
||||||
|
import { apiTokens, sites, users } from "@/db/schema";
|
||||||
|
import { generateApiToken } from "@/lib/auth/tokens";
|
||||||
|
import { GET as getSites, POST as postSite } from "@/app/api/v1/sites/route";
|
||||||
|
import { GET as getTags, POST as postTag } from "@/app/api/v1/tags/route";
|
||||||
|
import { PUT as putByUid } from "@/app/api/v1/tags/by-uid/[uid]/route";
|
||||||
|
import { GET as getExport } from "@/app/api/v1/tags/[id]/export/route";
|
||||||
|
import { GET as listTokens, POST as createToken } from "@/app/api/v1/tokens/route";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
function req(url: string, init?: ConstructorParameters<typeof NextRequest>[1]) {
|
||||||
|
return new NextRequest(new URL(url, "http://localhost"), init);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("API v1 integration", () => {
|
||||||
|
let dbPath: string;
|
||||||
|
let bearer: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dbPath = path.join(os.tmpdir(), `rfid-test-${crypto.randomUUID()}.db`);
|
||||||
|
process.env.RFID_DB_PATH = dbPath;
|
||||||
|
process.env.AUTH_SECRET = "test-secret-at-least-32-characters-long";
|
||||||
|
resetDbConnection();
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
const passwordHash = await hash("password", 4);
|
||||||
|
db.insert(users)
|
||||||
|
.values({
|
||||||
|
id: userId,
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "Tester",
|
||||||
|
passwordHash,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const generated = generateApiToken();
|
||||||
|
bearer = generated.token;
|
||||||
|
db.insert(apiTokens)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
name: "test",
|
||||||
|
tokenHash: generated.tokenHash,
|
||||||
|
prefix: generated.prefix,
|
||||||
|
expiresAt: null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetDbConnection();
|
||||||
|
if (dbPath && fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated requests", async () => {
|
||||||
|
const res = await getSites(req("http://localhost/api/v1/sites"));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates site, tag, upserts by uid, exports", async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${bearer}` };
|
||||||
|
|
||||||
|
const siteRes = await postSite(
|
||||||
|
req("http://localhost/api/v1/sites", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "A", name: "Building 1" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(siteRes.status).toBe(201);
|
||||||
|
const site = await siteRes.json();
|
||||||
|
|
||||||
|
const listRes = await getSites(
|
||||||
|
req("http://localhost/api/v1/sites", { headers: auth })
|
||||||
|
);
|
||||||
|
expect(listRes.status).toBe(200);
|
||||||
|
const list = await listRes.json();
|
||||||
|
expect(list.sites).toHaveLength(1);
|
||||||
|
|
||||||
|
const tagRes = await postTag(
|
||||||
|
req("http://localhost/api/v1/tags", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siteId: site.id,
|
||||||
|
label: "Dock fob",
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: "MIFARE_CLASSIC_1K",
|
||||||
|
uid: "04:A1:B2:C3",
|
||||||
|
dumpData: {
|
||||||
|
size: "1K",
|
||||||
|
sectors: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
blocks: [
|
||||||
|
"04A1B2C304A1B2C304A1B2C304A1B2C3",
|
||||||
|
"00000000000000000000000000000000",
|
||||||
|
"00000000000000000000000000000000",
|
||||||
|
"FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
keys: { A: ["FFFFFFFFFFFF"], B: [] },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(tagRes.status).toBe(201);
|
||||||
|
const tag = await tagRes.json();
|
||||||
|
expect(tag.uid).toBe("04A1B2C3");
|
||||||
|
|
||||||
|
const upsert = await putByUid(
|
||||||
|
req(`http://localhost/api/v1/tags/by-uid/04A1B2C3?siteId=${site.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siteId: site.id,
|
||||||
|
label: "Dock fob updated",
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: "MIFARE_CLASSIC_1K",
|
||||||
|
uid: "04A1B2C3",
|
||||||
|
dumpData: tag.dumpData,
|
||||||
|
keys: tag.keys,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ uid: "04A1B2C3" }) }
|
||||||
|
);
|
||||||
|
expect(upsert.status).toBe(200);
|
||||||
|
const upserted = await upsert.json();
|
||||||
|
expect(upserted.created).toBe(false);
|
||||||
|
expect(upserted.label).toBe("Dock fob updated");
|
||||||
|
|
||||||
|
const tagsRes = await getTags(
|
||||||
|
req(`http://localhost/api/v1/tags?siteId=${site.id}`, { headers: auth })
|
||||||
|
);
|
||||||
|
const tags = await tagsRes.json();
|
||||||
|
expect(tags.tags).toHaveLength(1);
|
||||||
|
|
||||||
|
const exportRes = await getExport(
|
||||||
|
req(`http://localhost/api/v1/tags/${tag.id}/export?format=mct`, {
|
||||||
|
headers: auth,
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id: tag.id }) }
|
||||||
|
);
|
||||||
|
expect(exportRes.status).toBe(200);
|
||||||
|
const body = await exportRes.text();
|
||||||
|
expect(body).toContain("+UID:");
|
||||||
|
expect(body).toContain("+Sector: 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and lists API tokens", async () => {
|
||||||
|
const auth = { Authorization: `Bearer ${bearer}` };
|
||||||
|
const created = await createToken(
|
||||||
|
req("http://localhost/api/v1/tokens", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...auth, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: "cli" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const data = await created.json();
|
||||||
|
expect(data.token).toMatch(/^rfid_/);
|
||||||
|
|
||||||
|
const listed = await listTokens(
|
||||||
|
req("http://localhost/api/v1/tokens", { headers: auth })
|
||||||
|
);
|
||||||
|
const list = await listed.json();
|
||||||
|
expect(list.tokens.some((t: { name: string }) => t.name === "cli")).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
||||||
|
expect(user?.email).toBe("[email protected]");
|
||||||
|
expect(db.select().from(sites).all().length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseMct, parseImport } from "@/lib/rfid/parsers";
|
||||||
|
import { exportTag } from "@/lib/rfid/exporters";
|
||||||
|
import { mifareClassicDumpSchema } from "@/lib/validation/rfid";
|
||||||
|
|
||||||
|
const sampleMct = `+UID: 04A1B2C3
|
||||||
|
+Sector: 0
|
||||||
|
04A1B2C304A1B2C304A1B2C304A1B2C3
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
||||||
|
+Sector: 1
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000
|
||||||
|
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("MCT parser/exporter", () => {
|
||||||
|
it("parses MCT dumps", () => {
|
||||||
|
const tag = parseMct(sampleMct);
|
||||||
|
expect(tag.uid).toBe("04A1B2C3");
|
||||||
|
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
|
||||||
|
expect(mifareClassicDumpSchema.safeParse(tag.dumpData).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips via export mct", () => {
|
||||||
|
const tag = parseMct(sampleMct);
|
||||||
|
const exported = exportTag(
|
||||||
|
{
|
||||||
|
id: "00000000-0000-4000-8000-000000000001",
|
||||||
|
label: "test",
|
||||||
|
frequency: tag.frequency,
|
||||||
|
protocol: tag.protocol,
|
||||||
|
uid: tag.uid,
|
||||||
|
dumpData: tag.dumpData,
|
||||||
|
keys: null,
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
"mct"
|
||||||
|
);
|
||||||
|
const again = parseImport(exported.body, "test.mct");
|
||||||
|
expect(again.uid).toBe(tag.uid);
|
||||||
|
expect(again.protocol).toBe(tag.protocol);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses canonical JSON", () => {
|
||||||
|
const json = JSON.stringify({
|
||||||
|
label: "Dock",
|
||||||
|
frequency: "HF",
|
||||||
|
protocol: "MIFARE_CLASSIC_1K",
|
||||||
|
uid: "04:A1:B2:C3",
|
||||||
|
dumpData: {
|
||||||
|
size: "1K",
|
||||||
|
sectors: [{ index: 0, blocks: ["AABBCCDDEEFF00112233445566778899"] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tag = parseImport(json, "x.json");
|
||||||
|
expect(tag.uid).toBe("04A1B2C3");
|
||||||
|
expect(tag.label).toBe("Dock");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { generateApiToken, hashToken, isApiTokenFormat } from "@/lib/auth/tokens";
|
||||||
|
|
||||||
|
describe("api tokens", () => {
|
||||||
|
it("generates rfid_ prefixed tokens", () => {
|
||||||
|
const { token, tokenHash, prefix } = generateApiToken();
|
||||||
|
expect(isApiTokenFormat(token)).toBe(true);
|
||||||
|
expect(tokenHash).toBe(hashToken(token));
|
||||||
|
expect(prefix).toBe(token.slice(0, 12));
|
||||||
|
expect(hashToken(token)).not.toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-tokens", () => {
|
||||||
|
expect(isApiTokenFormat("Bearer abc")).toBe(false);
|
||||||
|
expect(isApiTokenFormat("rfid_short")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatUid, normalizeUid, tryNormalizeUid } from "@/lib/rfid/uid";
|
||||||
|
|
||||||
|
describe("normalizeUid", () => {
|
||||||
|
it("strips separators and uppercases", () => {
|
||||||
|
expect(normalizeUid("04:a1:b2:c3")).toBe("04A1B2C3");
|
||||||
|
expect(normalizeUid("04 a1 b2 c3")).toBe("04A1B2C3");
|
||||||
|
expect(normalizeUid("04a1b2c3")).toBe("04A1B2C3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects odd length", () => {
|
||||||
|
expect(() => normalizeUid("04A")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats with colons", () => {
|
||||||
|
expect(formatUid("04a1b2c3")).toBe("04:A1:B2:C3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tryNormalizeUid returns null on bad input", () => {
|
||||||
|
expect(tryNormalizeUid("xyz")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
fileParallelism: false,
|
||||||
|
sequence: { concurrent: false },
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
inline: ["next-auth"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(root, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user