iobroker.zeiterfassung 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -29
- package/build/lib/db/repositories/rfid.js +75 -0
- package/build/lib/db/repositories/rfid.js.map +2 -2
- package/build/lib/web/api.js +104 -4
- package/build/lib/web/api.js.map +2 -2
- package/io-package.json +27 -27
- package/package.json +2 -1
- package/src-pwa/src/i18n/de.json +11 -0
- package/src-pwa/src/i18n/en.json +11 -0
- package/src-pwa/src/i18n/es.json +14 -3
- package/src-pwa/src/i18n/fr.json +13 -2
- package/src-pwa/src/i18n/it.json +14 -3
- package/src-pwa/src/i18n/nl.json +13 -2
- package/src-pwa/src/i18n/pl.json +15 -4
- package/src-pwa/src/i18n/pt.json +14 -3
- package/src-pwa/src/i18n/ru.json +13 -2
- package/src-pwa/src/i18n/uk.json +14 -3
- package/src-pwa/src/i18n/zh-cn.json +13 -2
- package/www/assets/{index-B_4iEAhU.js → index-Axy_52TB.js} +2 -2
- package/www/assets/index-Axy_52TB.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-B_4iEAhU.js.map +0 -1
package/README.md
CHANGED
|
@@ -305,6 +305,30 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
305
305
|
|
|
306
306
|
### **WORK IN PROGRESS**
|
|
307
307
|
|
|
308
|
+
### 0.1.4 (2026-09-18)
|
|
309
|
+
|
|
310
|
+
- (Alex) **badges (RFID/NFC)**: a badge can be edited (employee, label, validity) and given a **new link** — for one
|
|
311
|
+
that was lost, expired or revoked and should work again. A new employee or a new validity re-signs the link, so the
|
|
312
|
+
answer carries it and the link handed out before stops working immediately (a label on its own leaves it alone)
|
|
313
|
+
- (Alex) translations: a second technical pass, this time with a helper (`tools/check-i18n-review.mjs`) that looks for
|
|
314
|
+
what `check:i18n` cannot see — wrong script, copies between languages, texts much longer or shorter than English.
|
|
315
|
+
It found real gaps: `brandingHint` had lost its second sentence in nine languages, `brandColorHint` its “empty for
|
|
316
|
+
the default” in seven, and `admin.user.pinTitle` was a Russian sentence in the Ukrainian file. `docs/i18n.md` lists
|
|
317
|
+
what stays with a native speaker
|
|
318
|
+
|
|
319
|
+
### 0.1.3 (2026-09-18)
|
|
320
|
+
|
|
321
|
+
- (Alex) fix: the badge (RFID/NFC) tab builds the link from the address the administration is **currently** open
|
|
322
|
+
with — scheme, host and port come from the browser, so the link works on a plain HTTP instance and behind a reverse
|
|
323
|
+
proxy alike, and no server side guess can point at a scheme the instance does not serve
|
|
324
|
+
- (Alex) fix: the badge (RFID/NFC) tab shows the state of every badge — active, expired or revoked — and when it was
|
|
325
|
+
last used. A revoked badge now offers “Remove permanently” instead of failing with “not found”: the old entry
|
|
326
|
+
disappears from the list, while the audit trail keeps the trace of it
|
|
327
|
+
- (Alex) translations: a technical review pass over the 11 languages — the punch, break, absence, badge and overtime
|
|
328
|
+
text families were compared with each other. Five wrong `reports.overtime` labels were corrected (`pt`, `fr`, `it`,
|
|
329
|
+
`es`, `zh-cn` said “over time” instead of overtime), and the Polish punch labels now use the same root as the badge
|
|
330
|
+
texts. `docs/i18n.md` records the method and what a native speaker still has to settle
|
|
331
|
+
|
|
308
332
|
### 0.1.2 (2026-09-18)
|
|
309
333
|
|
|
310
334
|
- (Alex) fix: a badge (RFID/NFC) link points to the address the administration itself is reached with — the scheme
|
|
@@ -336,35 +360,6 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
336
360
|
states instead of waiting for the five minute timer. That is what made `users.<id>.present` and
|
|
337
361
|
`company.presentCount` lag behind
|
|
338
362
|
|
|
339
|
-
### 0.0.19 (2026-09-18)
|
|
340
|
-
|
|
341
|
-
- internal: the browser tests wait longer for the slower runner of the pipeline and put the branding back when the
|
|
342
|
-
file is done, so it can run again and in any order; the end-to-end workflow uses the current majors of
|
|
343
|
-
`actions/checkout`, `actions/setup-node` and `actions/upload-artifact` (the old ones still target Node 20, which
|
|
344
|
-
the runner deprecates)
|
|
345
|
-
|
|
346
|
-
- (Alex) ioBroker comfort: **actions** (trigger rules) — a state of another adapter like a fingerprint reader, a
|
|
347
|
-
button or a door contact punches or sets the presence. The table lives in the administration
|
|
348
|
-
(`GET`/`PUT /api/trigger-rules`), a rule fires only when the value changes and honours a cooldown, and every
|
|
349
|
-
punch carries the note `trigger.<id>` in the audit trail
|
|
350
|
-
- (Alex) **`sendTo` messages** — `punch`, `present`, `status`, `report` (PDF or Excel as base64) and `backup`, so a
|
|
351
|
-
script or a Blockly block drives the instance without HTTP
|
|
352
|
-
- (Alex) more states for dashboards and notifications: `company.presentCount`/`present`/`openConflicts`/`lastPunch`,
|
|
353
|
-
per employee `monthWorkedMinutes`/`monthBalanceMinutes`/`yearBalanceMinutes` and `events.lastAt`/`lastType`/
|
|
354
|
-
`lastUser`/`lastDirection`/`lastSource`
|
|
355
|
-
|
|
356
|
-
### 0.0.18 (2026-09-18)
|
|
357
|
-
|
|
358
|
-
- (Alex) fix: an uploaded background picture can be taken away again — every picture field has a “Remove picture”
|
|
359
|
-
button (the logo as well), and “Default” resets the colour and the background picture in one click
|
|
360
|
-
- internal: the settings dialog shows the stored logo and background picture again (a read leaves the large pictures
|
|
361
|
-
out on purpose, so the preview and the remove button come from the branding route)
|
|
362
|
-
- (Alex) internal: the release flow asks the owner before the version commit and the tag — the question “may I push,
|
|
363
|
-
or do you have remarks?” comes first and only a “go” leads to commit, tag and push; `CONTRIBUTING.md` documents
|
|
364
|
-
the step and `npm run push:approve` reminds of it
|
|
365
|
-
- internal: the lint ignores the generated report of the browser tests (it carried a bundled viewer and made a local
|
|
366
|
-
`npm run lint` crash)
|
|
367
|
-
|
|
368
363
|
Older entries are kept in [`CHANGELOG_OLD.md`](CHANGELOG_OLD.md).
|
|
369
364
|
|
|
370
365
|
## Provenance
|
|
@@ -82,6 +82,11 @@ function createRfidRepository(db) {
|
|
|
82
82
|
);
|
|
83
83
|
const updateLastUsed = db.prepare("UPDATE rfid_tags SET last_used_at = ? WHERE id = ?");
|
|
84
84
|
const deactivate = db.prepare("UPDATE rfid_tags SET is_active = 0 WHERE id = ?");
|
|
85
|
+
const deleteTag = db.prepare("DELETE FROM rfid_tags WHERE id = ?");
|
|
86
|
+
const changeFields = db.prepare(
|
|
87
|
+
"UPDATE rfid_tags SET user_id = ?, label = ?, expires_at = ?, is_active = ? WHERE id = ?"
|
|
88
|
+
);
|
|
89
|
+
const changeSignature = db.prepare("UPDATE rfid_tags SET token_hash = ? WHERE id = ?");
|
|
85
90
|
const read = (id) => {
|
|
86
91
|
const row = selectById.get(id);
|
|
87
92
|
return row ? mapTagRow(row) : null;
|
|
@@ -174,6 +179,76 @@ function createRfidRepository(db) {
|
|
|
174
179
|
});
|
|
175
180
|
run();
|
|
176
181
|
return true;
|
|
182
|
+
},
|
|
183
|
+
remove(input) {
|
|
184
|
+
var _a;
|
|
185
|
+
const tag = read(input.id);
|
|
186
|
+
if (!tag) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
190
|
+
const run = db.transaction(() => {
|
|
191
|
+
var _a2;
|
|
192
|
+
deleteTag.run(input.id);
|
|
193
|
+
(0, import_audit.writeAuditLog)(db, {
|
|
194
|
+
atUtc: now,
|
|
195
|
+
actorId: input.actorId,
|
|
196
|
+
action: "rfid.delete",
|
|
197
|
+
entity: "rfid_tag",
|
|
198
|
+
entityId: input.id,
|
|
199
|
+
detail: { uid: tag.uid, userId: tag.userId, isActive: tag.isActive },
|
|
200
|
+
ip: (_a2 = input.actorIp) != null ? _a2 : null
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
run();
|
|
204
|
+
return true;
|
|
205
|
+
},
|
|
206
|
+
update(input) {
|
|
207
|
+
var _a;
|
|
208
|
+
const tag = read(input.id);
|
|
209
|
+
if (!tag) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const userId = input.userId === void 0 ? tag.userId : input.userId;
|
|
213
|
+
if (userId !== null && (!Number.isInteger(userId) || userId <= 0)) {
|
|
214
|
+
throw new import_errors.ValidationError(`userId must be a positive whole number (got ${input.userId})`);
|
|
215
|
+
}
|
|
216
|
+
const expiresAt = input.expiresAt === void 0 ? tag.expiresAt : input.expiresAt;
|
|
217
|
+
if (expiresAt !== null && (!Number.isInteger(expiresAt) || expiresAt <= 0)) {
|
|
218
|
+
throw new import_errors.ValidationError(`expiresAt must be a positive instant (got ${input.expiresAt})`);
|
|
219
|
+
}
|
|
220
|
+
if (input.signature !== void 0 && !input.signature) {
|
|
221
|
+
throw new import_errors.ValidationError("signature must not be empty");
|
|
222
|
+
}
|
|
223
|
+
const label = input.label === void 0 ? tag.label : input.label;
|
|
224
|
+
const isActive = input.activate === void 0 ? tag.isActive : input.activate;
|
|
225
|
+
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
226
|
+
const run = db.transaction(() => {
|
|
227
|
+
var _a2;
|
|
228
|
+
changeFields.run(userId, label, expiresAt, isActive ? 1 : 0, input.id);
|
|
229
|
+
if (input.signature !== void 0) {
|
|
230
|
+
changeSignature.run(input.signature, input.id);
|
|
231
|
+
}
|
|
232
|
+
(0, import_audit.writeAuditLog)(db, {
|
|
233
|
+
atUtc: now,
|
|
234
|
+
actorId: input.actorId,
|
|
235
|
+
action: "rfid.update",
|
|
236
|
+
entity: "rfid_tag",
|
|
237
|
+
entityId: input.id,
|
|
238
|
+
// the signature itself stays out of the audit trail, whether it was replaced is what counts
|
|
239
|
+
detail: {
|
|
240
|
+
uid: tag.uid,
|
|
241
|
+
userId,
|
|
242
|
+
label,
|
|
243
|
+
expiresAt,
|
|
244
|
+
isActive,
|
|
245
|
+
reissued: input.signature !== void 0
|
|
246
|
+
},
|
|
247
|
+
ip: (_a2 = input.actorIp) != null ? _a2 : null
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
run();
|
|
251
|
+
return read(input.id);
|
|
177
252
|
}
|
|
178
253
|
};
|
|
179
254
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/lib/db/repositories/rfid.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * RFID/NFC tags (`rfid_tags`).\n *\n * A tag is a **signed deep link**: `uid.userId.exp.signature`, where the signature is\n * `HMAC-SHA256(hmacSecret, \"uid.userId.exp\")`. That way a tag needs no login on the scanning phone, cannot be\n * guessed (a forged payload fails the signature) and can be revoked or given an expiry. The stored `token_hash`\n * is the signature itself; the extra `uid` row keeps the lookup cheap and lets a tag be revoked individually.\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport type { Db } from \"../database\";\nimport { ValidationError } from \"../../errors\";\nimport { writeAuditLog } from \"./audit\";\n\n/** A tag as it is stored (without the signature). */\nexport interface RfidTagRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Identifier of the tag (`uid` of the NFC tag or a generated value) */\n\tuid: string;\n\t/** Owner of the tag, `null` after the user was deleted */\n\tuserId: number | null;\n\t/** Free-form label */\n\tlabel: string | null;\n\t/** False for revoked tags */\n\tisActive: boolean;\n\t/** Instant the tag stops working, `null` = never */\n\texpiresAt: number | null;\n\t/** Instant of the last scan, `null` if never used */\n\tlastUsedAt: number | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n}\n\n/** Tag storage operations. */\nexport interface RfidRepository {\n\t/** Creates a tag and audits it (the signature is never written to the audit trail) */\n\tcreate(input: {\n\t\tuid: string;\n\t\tuserId: number;\n\t\tsignature: string;\n\t\tlabel?: string | null;\n\t\t/** Instant the tag stops working, `null` = never */\n\t\texpiresAt?: number | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): RfidTagRecord;\n\t/** All tags, optionally including the revoked ones */\n\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[];\n\t/** Reads a tag by id */\n\tfindById(id: number): RfidTagRecord | null;\n\t/** Checks a scanned token and returns the tag when signature, activity, owner and expiry match */\n\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null;\n\t/** Records a successful scan */\n\ttouch(input: { id: number; now?: number }): void;\n\t/** Revokes a tag */\n\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n}\n\n/**\n * Creates a random tag identifier.\n *\n * @returns base64url encoded identifier\n */\nexport function newTagUid(): string {\n\treturn randomBytes(16).toString(\"base64url\");\n}\n\n/**\n * Signs a tag payload.\n *\n * @param secret - instance secret (`hmacSecret`)\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload, UTC epoch seconds\n * @returns base64url encoded signature\n */\nexport function signTag(secret: string, uid: string, userId: number, expiresAt: number): string {\n\treturn createHmac(\"sha256\", secret).update(`${uid}.${userId}.${expiresAt}`).digest(\"base64url\");\n}\n\n/**\n * Builds the token a tag carries.\n *\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload\n * @param signature - signature of the payload\n * @returns the token\n */\nexport function buildTagToken(uid: string, userId: number, expiresAt: number, signature: string): string {\n\treturn `${uid}.${userId}.${expiresAt}.${signature}`;\n}\n\n/**\n * Splits a scanned token.\n *\n * @param token - token from the deep link or the QR code\n * @returns the parsed payload or `null` when the token is malformed\n */\nexport function parseTagToken(\n\ttoken: string,\n): { uid: string; userId: number; expiresAt: number; signature: string } | null {\n\tconst parts = token.trim().split(\".\");\n\tif (parts.length !== 4) {\n\t\treturn null;\n\t}\n\tconst [uid, rawUserId, rawExpires, signature] = parts;\n\tconst userId = Number(rawUserId);\n\tconst expiresAt = Number(rawExpires);\n\tif (!uid || !signature || !Number.isInteger(userId) || !Number.isInteger(expiresAt)) {\n\t\treturn null;\n\t}\n\treturn { uid, userId, expiresAt, signature };\n}\n\n/**\n * Compares two signatures without leaking the position of the first difference.\n *\n * @param expected - signature computed by the server\n * @param received - signature from the token\n * @returns true when both are identical\n */\nexport function sameSignature(expected: string, received: string): boolean {\n\tconst left = Buffer.from(expected);\n\tconst right = Buffer.from(received);\n\treturn left.length === right.length && timingSafeEqual(left, right);\n}\n\ninterface RfidRow {\n\tid: number;\n\tuid: string;\n\ttoken_hash: string;\n\tuser_id: number | null;\n\tlabel: string | null;\n\tis_active: number;\n\texpires_at: number | null;\n\tlast_used_at: number | null;\n\tcreated_at: number;\n}\n\nconst TAG_COLUMNS = `id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at`;\n\n/**\n * Maps a database row to a tag record (the signature stays inside the repository).\n *\n * @param row - raw database row\n * @returns tag record\n */\nfunction mapTagRow(row: RfidRow): RfidTagRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuid: row.uid,\n\t\tuserId: row.user_id,\n\t\tlabel: row.label,\n\t\tisActive: row.is_active !== 0,\n\t\texpiresAt: row.expires_at,\n\t\tlastUsedAt: row.last_used_at,\n\t\tcreatedAt: row.created_at,\n\t};\n}\n\n/**\n * Creates the tag repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createRfidRepository(db: Db): RfidRepository {\n\tconst insertTag = db.prepare(\n\t\t`INSERT INTO rfid_tags (uid, token_hash, user_id, label, is_active, expires_at, created_at)\n\t\t VALUES (?, ?, ?, ?, 1, ?, ?)`,\n\t);\n\tconst selectById = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE id = ?`);\n\tconst selectByUid = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE uid = ?`);\n\tconst selectAll = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags ORDER BY created_at DESC, id DESC`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE is_active = 1 ORDER BY created_at DESC, id DESC`,\n\t);\n\tconst updateLastUsed = db.prepare(\"UPDATE rfid_tags SET last_used_at = ? WHERE id = ?\");\n\tconst deactivate = db.prepare(\"UPDATE rfid_tags SET is_active = 0 WHERE id = ?\");\n\n\t/**\n\t * Reads a tag.\n\t *\n\t * @param id - tag id\n\t * @returns tag or `null`\n\t */\n\tconst read = (id: number): RfidTagRecord | null => {\n\t\tconst row = selectById.get(id) as RfidRow | undefined;\n\t\treturn row ? mapTagRow(row) : null;\n\t};\n\n\treturn {\n\t\tcreate(input: {\n\t\t\tuid: string;\n\t\t\tuserId: number;\n\t\t\tsignature: string;\n\t\t\tlabel?: string | null;\n\t\t\texpiresAt?: number | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): RfidTagRecord {\n\t\t\tconst uid = input.uid.trim();\n\t\t\tif (!uid) {\n\t\t\t\tthrow new ValidationError(\"uid is required\");\n\t\t\t}\n\t\t\t// the caller passes the expiry it signed, so the stored value and the token cannot drift apart\n\t\t\tconst expiresAt = input.expiresAt ?? null;\n\t\t\tif (expiresAt !== null && (!Number.isInteger(expiresAt) || expiresAt <= 0)) {\n\t\t\t\tthrow new ValidationError(`expiresAt must be a positive instant (got ${input.expiresAt})`);\n\t\t\t}\n\t\t\tif (!input.signature) {\n\t\t\t\tthrow new ValidationError(\"signature is required\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tlet tagId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertTag.run(uid, input.signature, input.userId, input.label ?? null, expiresAt, now);\n\t\t\t\ttagId = Number(result.lastInsertRowid);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.create\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: tagId,\n\t\t\t\t\t// the signature is a credential and never part of the audit trail\n\t\t\t\t\tdetail: { uid, userId: input.userId, label: input.label ?? null, expiresAt },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst tag = read(tagId);\n\t\t\tif (!tag) {\n\t\t\t\tthrow new Error(`tag ${tagId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn tag;\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as RfidRow[];\n\t\t\treturn rows.map(mapTagRow);\n\t\t},\n\n\t\tfindById: read,\n\n\t\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null {\n\t\t\tconst row = selectByUid.get(input.uid.trim()) as RfidRow | undefined;\n\t\t\tif (!row) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst tag = mapTagRow(row);\n\t\t\tif (!tag.isActive) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// an expired tag (or payload) is worthless even when the signature is correct\n\t\t\tif (tag.expiresAt !== null && tag.expiresAt <= now) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tif (tag.userId !== input.userId) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// the stored signature has to match the signature of the scanned payload\n\t\t\treturn sameSignature(row.token_hash, input.signature) ? tag : null;\n\t\t},\n\n\t\ttouch(input: { id: number; now?: number }): void {\n\t\t\tupdateLastUsed.run(input.now ?? Math.floor(Date.now() / 1000), input.id);\n\t\t},\n\n\t\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag || !tag.isActive) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeactivate.run(input.id);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.revoke\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { uid: tag.uid, userId: tag.userId },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\t};\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASA,yBAAyD;AAEzD,oBAAgC;AAChC,mBAA8B;
|
|
4
|
+
"sourcesContent": ["/**\n * RFID/NFC tags (`rfid_tags`).\n *\n * A tag is a **signed deep link**: `uid.userId.exp.signature`, where the signature is\n * `HMAC-SHA256(hmacSecret, \"uid.userId.exp\")`. That way a tag needs no login on the scanning phone, cannot be\n * guessed (a forged payload fails the signature) and can be revoked or given an expiry. The stored `token_hash`\n * is the signature itself; the extra `uid` row keeps the lookup cheap and lets a tag be revoked individually.\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport type { Db } from \"../database\";\nimport { ValidationError } from \"../../errors\";\nimport { writeAuditLog } from \"./audit\";\n\n/** A tag as it is stored (without the signature). */\nexport interface RfidTagRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Identifier of the tag (`uid` of the NFC tag or a generated value) */\n\tuid: string;\n\t/** Owner of the tag, `null` after the user was deleted */\n\tuserId: number | null;\n\t/** Free-form label */\n\tlabel: string | null;\n\t/** False for revoked tags */\n\tisActive: boolean;\n\t/** Instant the tag stops working, `null` = never */\n\texpiresAt: number | null;\n\t/** Instant of the last scan, `null` if never used */\n\tlastUsedAt: number | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n}\n\n/** Tag storage operations. */\nexport interface RfidRepository {\n\t/** Creates a tag and audits it (the signature is never written to the audit trail) */\n\tcreate(input: {\n\t\tuid: string;\n\t\tuserId: number;\n\t\tsignature: string;\n\t\tlabel?: string | null;\n\t\t/** Instant the tag stops working, `null` = never */\n\t\texpiresAt?: number | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): RfidTagRecord;\n\t/** All tags, optionally including the revoked ones */\n\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[];\n\t/** Reads a tag by id */\n\tfindById(id: number): RfidTagRecord | null;\n\t/** Checks a scanned token and returns the tag when signature, activity, owner and expiry match */\n\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null;\n\t/** Records a successful scan */\n\ttouch(input: { id: number; now?: number }): void;\n\t/** Revokes a tag */\n\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n\t/** Deletes a tag for good \u2014 for a revoked badge that should disappear from the list */\n\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n\t/**\n\t * Changes label, owner, expiry and \u2014 when a new signature is passed \u2014 the link of a badge.\n\t *\n\t * The signature binds `uid`, owner and expiry, so handing in `signature` is what issues a new link: the stored\n\t * hash is replaced and every link given out before stops working immediately. `activate` brings a revoked badge\n\t * back into use.\n\t */\n\tupdate(input: {\n\t\tid: number;\n\t\tlabel?: string | null;\n\t\tuserId?: number | null;\n\t\texpiresAt?: number | null;\n\t\tsignature?: string;\n\t\tactivate?: boolean;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): RfidTagRecord | null;\n}\n\n/**\n * Creates a random tag identifier.\n *\n * @returns base64url encoded identifier\n */\nexport function newTagUid(): string {\n\treturn randomBytes(16).toString(\"base64url\");\n}\n\n/**\n * Signs a tag payload.\n *\n * @param secret - instance secret (`hmacSecret`)\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload, UTC epoch seconds\n * @returns base64url encoded signature\n */\nexport function signTag(secret: string, uid: string, userId: number, expiresAt: number): string {\n\treturn createHmac(\"sha256\", secret).update(`${uid}.${userId}.${expiresAt}`).digest(\"base64url\");\n}\n\n/**\n * Builds the token a tag carries.\n *\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload\n * @param signature - signature of the payload\n * @returns the token\n */\nexport function buildTagToken(uid: string, userId: number, expiresAt: number, signature: string): string {\n\treturn `${uid}.${userId}.${expiresAt}.${signature}`;\n}\n\n/**\n * Splits a scanned token.\n *\n * @param token - token from the deep link or the QR code\n * @returns the parsed payload or `null` when the token is malformed\n */\nexport function parseTagToken(\n\ttoken: string,\n): { uid: string; userId: number; expiresAt: number; signature: string } | null {\n\tconst parts = token.trim().split(\".\");\n\tif (parts.length !== 4) {\n\t\treturn null;\n\t}\n\tconst [uid, rawUserId, rawExpires, signature] = parts;\n\tconst userId = Number(rawUserId);\n\tconst expiresAt = Number(rawExpires);\n\tif (!uid || !signature || !Number.isInteger(userId) || !Number.isInteger(expiresAt)) {\n\t\treturn null;\n\t}\n\treturn { uid, userId, expiresAt, signature };\n}\n\n/**\n * Compares two signatures without leaking the position of the first difference.\n *\n * @param expected - signature computed by the server\n * @param received - signature from the token\n * @returns true when both are identical\n */\nexport function sameSignature(expected: string, received: string): boolean {\n\tconst left = Buffer.from(expected);\n\tconst right = Buffer.from(received);\n\treturn left.length === right.length && timingSafeEqual(left, right);\n}\n\ninterface RfidRow {\n\tid: number;\n\tuid: string;\n\ttoken_hash: string;\n\tuser_id: number | null;\n\tlabel: string | null;\n\tis_active: number;\n\texpires_at: number | null;\n\tlast_used_at: number | null;\n\tcreated_at: number;\n}\n\nconst TAG_COLUMNS = `id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at`;\n\n/**\n * Maps a database row to a tag record (the signature stays inside the repository).\n *\n * @param row - raw database row\n * @returns tag record\n */\nfunction mapTagRow(row: RfidRow): RfidTagRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuid: row.uid,\n\t\tuserId: row.user_id,\n\t\tlabel: row.label,\n\t\tisActive: row.is_active !== 0,\n\t\texpiresAt: row.expires_at,\n\t\tlastUsedAt: row.last_used_at,\n\t\tcreatedAt: row.created_at,\n\t};\n}\n\n/**\n * Creates the tag repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createRfidRepository(db: Db): RfidRepository {\n\tconst insertTag = db.prepare(\n\t\t`INSERT INTO rfid_tags (uid, token_hash, user_id, label, is_active, expires_at, created_at)\n\t\t VALUES (?, ?, ?, ?, 1, ?, ?)`,\n\t);\n\tconst selectById = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE id = ?`);\n\tconst selectByUid = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE uid = ?`);\n\tconst selectAll = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags ORDER BY created_at DESC, id DESC`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE is_active = 1 ORDER BY created_at DESC, id DESC`,\n\t);\n\tconst updateLastUsed = db.prepare(\"UPDATE rfid_tags SET last_used_at = ? WHERE id = ?\");\n\tconst deactivate = db.prepare(\"UPDATE rfid_tags SET is_active = 0 WHERE id = ?\");\n\tconst deleteTag = db.prepare(\"DELETE FROM rfid_tags WHERE id = ?\");\n\t// the visible fields and the credential are changed separately: a label edit must not touch the signature\n\tconst changeFields = db.prepare(\n\t\t\"UPDATE rfid_tags SET user_id = ?, label = ?, expires_at = ?, is_active = ? WHERE id = ?\",\n\t);\n\tconst changeSignature = db.prepare(\"UPDATE rfid_tags SET token_hash = ? WHERE id = ?\");\n\n\t/**\n\t * Reads a tag.\n\t *\n\t * @param id - tag id\n\t * @returns tag or `null`\n\t */\n\tconst read = (id: number): RfidTagRecord | null => {\n\t\tconst row = selectById.get(id) as RfidRow | undefined;\n\t\treturn row ? mapTagRow(row) : null;\n\t};\n\n\treturn {\n\t\tcreate(input: {\n\t\t\tuid: string;\n\t\t\tuserId: number;\n\t\t\tsignature: string;\n\t\t\tlabel?: string | null;\n\t\t\texpiresAt?: number | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): RfidTagRecord {\n\t\t\tconst uid = input.uid.trim();\n\t\t\tif (!uid) {\n\t\t\t\tthrow new ValidationError(\"uid is required\");\n\t\t\t}\n\t\t\t// the caller passes the expiry it signed, so the stored value and the token cannot drift apart\n\t\t\tconst expiresAt = input.expiresAt ?? null;\n\t\t\tif (expiresAt !== null && (!Number.isInteger(expiresAt) || expiresAt <= 0)) {\n\t\t\t\tthrow new ValidationError(`expiresAt must be a positive instant (got ${input.expiresAt})`);\n\t\t\t}\n\t\t\tif (!input.signature) {\n\t\t\t\tthrow new ValidationError(\"signature is required\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tlet tagId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertTag.run(uid, input.signature, input.userId, input.label ?? null, expiresAt, now);\n\t\t\t\ttagId = Number(result.lastInsertRowid);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.create\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: tagId,\n\t\t\t\t\t// the signature is a credential and never part of the audit trail\n\t\t\t\t\tdetail: { uid, userId: input.userId, label: input.label ?? null, expiresAt },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst tag = read(tagId);\n\t\t\tif (!tag) {\n\t\t\t\tthrow new Error(`tag ${tagId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn tag;\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as RfidRow[];\n\t\t\treturn rows.map(mapTagRow);\n\t\t},\n\n\t\tfindById: read,\n\n\t\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null {\n\t\t\tconst row = selectByUid.get(input.uid.trim()) as RfidRow | undefined;\n\t\t\tif (!row) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst tag = mapTagRow(row);\n\t\t\tif (!tag.isActive) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// an expired tag (or payload) is worthless even when the signature is correct\n\t\t\tif (tag.expiresAt !== null && tag.expiresAt <= now) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tif (tag.userId !== input.userId) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// the stored signature has to match the signature of the scanned payload\n\t\t\treturn sameSignature(row.token_hash, input.signature) ? tag : null;\n\t\t},\n\n\t\ttouch(input: { id: number; now?: number }): void {\n\t\t\tupdateLastUsed.run(input.now ?? Math.floor(Date.now() / 1000), input.id);\n\t\t},\n\n\t\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag || !tag.isActive) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeactivate.run(input.id);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.revoke\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { uid: tag.uid, userId: tag.userId },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\n\t\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeleteTag.run(input.id);\n\t\t\t\t// the row is gone, the audit trail keeps the trace of it\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.delete\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { uid: tag.uid, userId: tag.userId, isActive: tag.isActive },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\n\t\tupdate(input: {\n\t\t\tid: number;\n\t\t\tlabel?: string | null;\n\t\t\tuserId?: number | null;\n\t\t\texpiresAt?: number | null;\n\t\t\tsignature?: string;\n\t\t\tactivate?: boolean;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): RfidTagRecord | null {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst userId = input.userId === undefined ? tag.userId : input.userId;\n\t\t\tif (userId !== null && (!Number.isInteger(userId) || userId <= 0)) {\n\t\t\t\tthrow new ValidationError(`userId must be a positive whole number (got ${input.userId})`);\n\t\t\t}\n\t\t\tconst expiresAt = input.expiresAt === undefined ? tag.expiresAt : input.expiresAt;\n\t\t\tif (expiresAt !== null && (!Number.isInteger(expiresAt) || expiresAt <= 0)) {\n\t\t\t\tthrow new ValidationError(`expiresAt must be a positive instant (got ${input.expiresAt})`);\n\t\t\t}\n\t\t\t// a new link always carries a signature: an empty one would leave a signatureless badge behind\n\t\t\tif (input.signature !== undefined && !input.signature) {\n\t\t\t\tthrow new ValidationError(\"signature must not be empty\");\n\t\t\t}\n\t\t\tconst label = input.label === undefined ? tag.label : input.label;\n\t\t\tconst isActive = input.activate === undefined ? tag.isActive : input.activate;\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tchangeFields.run(userId, label, expiresAt, isActive ? 1 : 0, input.id);\n\t\t\t\tif (input.signature !== undefined) {\n\t\t\t\t\tchangeSignature.run(input.signature, input.id);\n\t\t\t\t}\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.update\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\t// the signature itself stays out of the audit trail, whether it was replaced is what counts\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\tuid: tag.uid,\n\t\t\t\t\t\tuserId,\n\t\t\t\t\t\tlabel,\n\t\t\t\t\t\texpiresAt,\n\t\t\t\t\t\tisActive,\n\t\t\t\t\t\treissued: input.signature !== undefined,\n\t\t\t\t\t},\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn read(input.id);\n\t\t},\n\t};\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["_a", "_b"]
|
|
7
7
|
}
|
package/build/lib/web/api.js
CHANGED
|
@@ -2131,6 +2131,39 @@ function createApi(deps) {
|
|
|
2131
2131
|
}
|
|
2132
2132
|
return secret;
|
|
2133
2133
|
};
|
|
2134
|
+
const tagLink = (context, token) => {
|
|
2135
|
+
var _a2;
|
|
2136
|
+
const host = (_a2 = context.header("host")) != null ? _a2 : "localhost";
|
|
2137
|
+
const scheme = host.includes("://") ? "" : `${context.secure ? "https" : "http"}://`;
|
|
2138
|
+
return `${scheme}${host}/?tag=${token}`;
|
|
2139
|
+
};
|
|
2140
|
+
const reissueLink = (context, tag, ttlDays, label) => {
|
|
2141
|
+
var _a2;
|
|
2142
|
+
if (!context.auth) {
|
|
2143
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2144
|
+
}
|
|
2145
|
+
if (tag.userId === null) {
|
|
2146
|
+
throw new import_errors.ValidationError("the badge has no employee any more \u2014 give it one first");
|
|
2147
|
+
}
|
|
2148
|
+
const timestamp = now();
|
|
2149
|
+
const expiresAt = timestamp + ttlDays * 86400;
|
|
2150
|
+
const signature = (0, import_rfid.signTag)(requireHmacSecret(), tag.uid, tag.userId, expiresAt);
|
|
2151
|
+
const updated = rfid.update({
|
|
2152
|
+
id: tag.id,
|
|
2153
|
+
userId: tag.userId,
|
|
2154
|
+
expiresAt,
|
|
2155
|
+
signature,
|
|
2156
|
+
activate: true,
|
|
2157
|
+
...label === void 0 ? {} : { label },
|
|
2158
|
+
actorId: context.auth.user.id,
|
|
2159
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2160
|
+
now: timestamp
|
|
2161
|
+
});
|
|
2162
|
+
if (!updated) {
|
|
2163
|
+
throw new import_errors.NotFoundError(`tag ${tag.id} not found`);
|
|
2164
|
+
}
|
|
2165
|
+
return { tag: updated, token: (0, import_rfid.buildTagToken)(updated.uid, tag.userId, expiresAt, signature) };
|
|
2166
|
+
};
|
|
2134
2167
|
route(
|
|
2135
2168
|
"GET",
|
|
2136
2169
|
"/rfid/tags",
|
|
@@ -2138,7 +2171,7 @@ function createApi(deps) {
|
|
|
2138
2171
|
() => (0, import_router.json)(200, { tags: rfid.list({ includeInactive: true }) })
|
|
2139
2172
|
);
|
|
2140
2173
|
route("POST", "/rfid/tags", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2141
|
-
var _a2, _b2, _c2, _d2
|
|
2174
|
+
var _a2, _b2, _c2, _d2;
|
|
2142
2175
|
if (!context.auth) {
|
|
2143
2176
|
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2144
2177
|
}
|
|
@@ -2167,9 +2200,7 @@ function createApi(deps) {
|
|
|
2167
2200
|
now: timestamp
|
|
2168
2201
|
});
|
|
2169
2202
|
const token = (0, import_rfid.buildTagToken)(uid, target, expiresAt, signature);
|
|
2170
|
-
|
|
2171
|
-
const scheme = host.includes("://") ? "" : `${context.secure ? "https" : "http"}://`;
|
|
2172
|
-
return (0, import_router.json)(201, { tag, token, url: `${scheme}${host}/?tag=${token}` }, { location: `/rfid/tags/${tag.id}` });
|
|
2203
|
+
return (0, import_router.json)(201, { tag, token, url: tagLink(context, token) }, { location: `/rfid/tags/${tag.id}` });
|
|
2173
2204
|
});
|
|
2174
2205
|
route("DELETE", "/rfid/tags/:id", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2175
2206
|
var _a2;
|
|
@@ -2187,6 +2218,75 @@ function createApi(deps) {
|
|
|
2187
2218
|
}
|
|
2188
2219
|
return (0, import_router.noContent)();
|
|
2189
2220
|
});
|
|
2221
|
+
route("DELETE", "/rfid/tags/:id/permanent", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2222
|
+
var _a2;
|
|
2223
|
+
if (!context.auth) {
|
|
2224
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2225
|
+
}
|
|
2226
|
+
const removed = rfid.remove({
|
|
2227
|
+
id: numberParam(context, "id"),
|
|
2228
|
+
actorId: context.auth.user.id,
|
|
2229
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2230
|
+
now: now()
|
|
2231
|
+
});
|
|
2232
|
+
if (!removed) {
|
|
2233
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2234
|
+
}
|
|
2235
|
+
return (0, import_router.noContent)();
|
|
2236
|
+
});
|
|
2237
|
+
route("PATCH", "/rfid/tags/:id", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2238
|
+
var _a2, _b2;
|
|
2239
|
+
if (!context.auth) {
|
|
2240
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2241
|
+
}
|
|
2242
|
+
const id = numberParam(context, "id");
|
|
2243
|
+
const body = context.jsonBody();
|
|
2244
|
+
const current = rfid.findById(id);
|
|
2245
|
+
if (!current) {
|
|
2246
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2247
|
+
}
|
|
2248
|
+
const userId = body.userId === void 0 ? current.userId : Number(body.userId);
|
|
2249
|
+
if (userId === null || !Number.isInteger(userId) || !users.findById(userId)) {
|
|
2250
|
+
throw new import_errors.ValidationError("userId must reference an existing employee");
|
|
2251
|
+
}
|
|
2252
|
+
const ttlDays = (_a2 = optionalNumber(body, "ttlDays")) != null ? _a2 : void 0;
|
|
2253
|
+
if (ttlDays !== void 0 && (!Number.isInteger(ttlDays) || ttlDays <= 0)) {
|
|
2254
|
+
throw new import_errors.ValidationError(`ttlDays must be a positive whole number (got ${ttlDays})`);
|
|
2255
|
+
}
|
|
2256
|
+
const label = body.label === void 0 ? void 0 : optionalString(body, "label");
|
|
2257
|
+
if (userId !== current.userId || ttlDays !== void 0) {
|
|
2258
|
+
const signed = reissueLink(context, { ...current, userId }, ttlDays != null ? ttlDays : 365, label);
|
|
2259
|
+
return (0, import_router.json)(200, { tag: signed.tag, token: signed.token, url: tagLink(context, signed.token) });
|
|
2260
|
+
}
|
|
2261
|
+
const changed = rfid.update({
|
|
2262
|
+
id,
|
|
2263
|
+
userId,
|
|
2264
|
+
...label === void 0 ? {} : { label },
|
|
2265
|
+
actorId: context.auth.user.id,
|
|
2266
|
+
actorIp: (_b2 = context.request.remoteAddress) != null ? _b2 : null,
|
|
2267
|
+
now: now()
|
|
2268
|
+
});
|
|
2269
|
+
if (!changed) {
|
|
2270
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2271
|
+
}
|
|
2272
|
+
return (0, import_router.json)(200, { tag: changed });
|
|
2273
|
+
});
|
|
2274
|
+
route("POST", "/rfid/tags/:id/link", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2275
|
+
var _a2;
|
|
2276
|
+
if (!context.auth) {
|
|
2277
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2278
|
+
}
|
|
2279
|
+
const tag = rfid.findById(numberParam(context, "id"));
|
|
2280
|
+
if (!tag) {
|
|
2281
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2282
|
+
}
|
|
2283
|
+
const ttlDays = (_a2 = optionalNumber(context.jsonBody(), "ttlDays")) != null ? _a2 : 365;
|
|
2284
|
+
if (!Number.isInteger(ttlDays) || ttlDays <= 0) {
|
|
2285
|
+
throw new import_errors.ValidationError(`ttlDays must be a positive whole number (got ${ttlDays})`);
|
|
2286
|
+
}
|
|
2287
|
+
const signed = reissueLink(context, tag, ttlDays);
|
|
2288
|
+
return (0, import_router.json)(200, { tag: signed.tag, token: signed.token, url: tagLink(context, signed.token) });
|
|
2289
|
+
});
|
|
2190
2290
|
route(
|
|
2191
2291
|
"POST",
|
|
2192
2292
|
"/rfid/scan",
|