From c049bdb6000479e2bb05b099294add91eec22b94 Mon Sep 17 00:00:00 2001
From: Patrick Schiller
Date: Sun, 11 Oct 2026 21:26:59 +0200
Subject: [PATCH] feat: release OpenClockwork 2.1.0 PIN tablet terminals
Add PIN identification and explicit device-bound clock-in/out alongside QR,
HR credential management, shared lockout, atomic retries and terminal audit.
Ship the additive migration, restricted runtime-role and audit tooling,
regression coverage and release/upgrade documentation.
Refs #33; RFID remains a separately planned extension.
Signed-off-by: Patrick Schiller
---
.dockerignore | 2 +
.env.example | 11 +
.env.prod.example | 11 +-
.github/workflows/ci.yml | 39 +
.github/workflows/release.yml | 3 +
Dockerfile.api | 4 +-
FEATURES.md | 58 +-
README.md | 54 +-
RELEASE_NOTES.md | 65 ++
RELEASING.md | 18 +-
ROADMAP.md | 74 +-
SECURITY.md | 13 +
UPGRADING.md | 87 ++-
apps/api-e2e/package.json | 8 +
.../api-e2e/src/api/terminal-auth.e2e.spec.ts | 611 ++++++++++++++++
.../src/api/terminal-booking.e2e.spec.ts | 644 ++++++++++++++++
.../src/api/terminal-credentials.e2e.spec.ts | 305 ++++++++
apps/api-e2e/src/support/test-app.ts | 4 +
apps/api-e2e/src/support/test-setup.ts | 6 +
apps/api/openapi.json | 628 +++++++++++++++-
apps/api/package.json | 1 +
.../src/app/employees/employees.controller.ts | 9 +-
.../src/app/employees/employees.service.ts | 45 +-
apps/api/src/app/health/health.controller.ts | 2 +-
.../app/terminals/terminal-audit.service.ts | 173 +++++
.../app/terminals/terminal-auth.controller.ts | 106 +++
.../src/app/terminals/terminal-auth.dto.ts | 158 ++++
.../app/terminals/terminal-auth.service.ts | 690 ++++++++++++++++++
.../app/terminals/terminal-booking.service.ts | 147 ++++
.../terminal-credentials.controller.ts | 71 ++
.../terminals/terminal-credentials.service.ts | 119 +++
.../app/terminals/terminal-device.guard.ts | 10 +-
.../src/app/terminals/terminal-pin.service.ts | 119 +++
.../terminals/terminal-rate-limit.guard.ts | 19 +-
.../app/terminals/terminal-security-locks.ts | 34 +
.../src/app/terminals/terminals.controller.ts | 22 +-
apps/api/src/app/terminals/terminals.dto.ts | 29 +-
.../api/src/app/terminals/terminals.module.ts | 27 +-
.../src/app/terminals/terminals.service.ts | 571 +++++++++------
apps/api/src/generate-openapi.ts | 2 +-
apps/api/src/main.ts | 2 +-
apps/web-e2e/playwright.config.ts | 20 +-
apps/web-e2e/src/smoke.spec.ts | 4 +-
apps/web-e2e/src/terminal-pin.spec.ts | 257 +++++++
apps/web/src/api/client.ts | 70 ++
apps/web/src/api/generated.ts | 402 +++++++++-
apps/web/src/api/terminal-device.spec.ts | 69 ++
apps/web/src/api/terminal-device.ts | 104 ++-
apps/web/src/app/i18n.tsx | 3 +
apps/web/src/app/terminal-pin-i18n.ts | 191 +++++
apps/web/src/routes/AdminEmployeesPage.tsx | 17 +
apps/web/src/routes/AdminTerminalsPage.tsx | 46 +-
apps/web/src/routes/KioskPage.spec.tsx | 31 +-
apps/web/src/routes/KioskPage.tsx | 130 ++--
apps/web/src/routes/KioskPinPanel.spec.tsx | 278 +++++++
apps/web/src/routes/KioskPinPanel.tsx | 503 +++++++++++++
.../src/routes/TerminalAuditPanel.spec.tsx | 34 +
apps/web/src/routes/TerminalAuditPanel.tsx | 181 +++++
.../web/src/routes/TerminalPinDialog.spec.tsx | 84 +++
apps/web/src/routes/TerminalPinDialog.tsx | 222 ++++++
apps/web/vite.config.mts | 3 +-
docker-compose.pin-test.yml | 76 ++
docker-compose.solo-test.yml | 4 +-
docker-compose.terminal-pin.yml | 30 +
docker-compose.test.yml | 66 ++
docs/IPAD_TERMINAL_SETUP.de.md | 16 +-
docs/LOCAL_DOCKER_TEST.de.md | 143 ++++
docs/LOCAL_PIN_DOCKER_TEST.de.md | 74 ++
docs/OPERATING_MODES.md | 2 +-
docs/SOLO_MODE.md | 6 +-
docs/TEAM_MODE.md | 14 +-
docs/TERMINAL_AUTH_OPERATIONS.de.md | 134 ++++
ops/configure-terminal-runtime.mjs | 100 +++
ops/terminal-audit-chain.mjs | 99 +++
ops/terminal-audit-chain.test.mjs | 137 ++++
ops/terminal-audit.mjs | 96 +++
ops/terminal-pin-runtime-smoke.mjs | 560 ++++++++++++++
ops/upgrade-smoke.mjs | 23 +
package.json | 3 +-
pnpm-lock.yaml | 38 +
.../migration.sql | 48 ++
prisma/schema.prisma | 77 ++
82 files changed, 9012 insertions(+), 384 deletions(-)
create mode 100644 apps/api-e2e/src/api/terminal-auth.e2e.spec.ts
create mode 100644 apps/api-e2e/src/api/terminal-booking.e2e.spec.ts
create mode 100644 apps/api-e2e/src/api/terminal-credentials.e2e.spec.ts
create mode 100644 apps/api/src/app/terminals/terminal-audit.service.ts
create mode 100644 apps/api/src/app/terminals/terminal-auth.controller.ts
create mode 100644 apps/api/src/app/terminals/terminal-auth.dto.ts
create mode 100644 apps/api/src/app/terminals/terminal-auth.service.ts
create mode 100644 apps/api/src/app/terminals/terminal-booking.service.ts
create mode 100644 apps/api/src/app/terminals/terminal-credentials.controller.ts
create mode 100644 apps/api/src/app/terminals/terminal-credentials.service.ts
create mode 100644 apps/api/src/app/terminals/terminal-pin.service.ts
create mode 100644 apps/api/src/app/terminals/terminal-security-locks.ts
create mode 100644 apps/web-e2e/src/terminal-pin.spec.ts
create mode 100644 apps/web/src/api/terminal-device.spec.ts
create mode 100644 apps/web/src/app/terminal-pin-i18n.ts
create mode 100644 apps/web/src/routes/KioskPinPanel.spec.tsx
create mode 100644 apps/web/src/routes/KioskPinPanel.tsx
create mode 100644 apps/web/src/routes/TerminalAuditPanel.spec.tsx
create mode 100644 apps/web/src/routes/TerminalAuditPanel.tsx
create mode 100644 apps/web/src/routes/TerminalPinDialog.spec.tsx
create mode 100644 apps/web/src/routes/TerminalPinDialog.tsx
create mode 100644 docker-compose.pin-test.yml
create mode 100644 docker-compose.terminal-pin.yml
create mode 100644 docker-compose.test.yml
create mode 100644 docs/LOCAL_DOCKER_TEST.de.md
create mode 100644 docs/LOCAL_PIN_DOCKER_TEST.de.md
create mode 100644 docs/TERMINAL_AUTH_OPERATIONS.de.md
create mode 100644 ops/configure-terminal-runtime.mjs
create mode 100644 ops/terminal-audit-chain.mjs
create mode 100644 ops/terminal-audit-chain.test.mjs
create mode 100644 ops/terminal-audit.mjs
create mode 100644 ops/terminal-pin-runtime-smoke.mjs
create mode 100644 prisma/migrations/20260930090000_terminal_pin_credentials/migration.sql
diff --git a/.dockerignore b/.dockerignore
index 7954374..fa7ad0b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -19,3 +19,5 @@ legacy
docs
backups
**/*.dump
+.pnpm-store
+.planning
diff --git a/.env.example b/.env.example
index a84aebf..1315915 100644
--- a/.env.example
+++ b/.env.example
@@ -40,6 +40,17 @@ JWT_SECRET=change-me-jwt-secret
# Never reuse JWT_SECRET in a real deployment.
TERMINAL_QR_SECRET=change-me-independent-terminal-qr-secret
+# Independent secrets for optional terminal PIN and signed audit events.
+# Generate each separately (at least 32 characters); never reuse other secrets.
+# Leaving these absent preserves QR-only operation and disables PIN activation.
+# TERMINAL_PIN_PEPPER=
+TERMINAL_PIN_PEPPER_VERSION=1
+# TERMINAL_PIN_PREVIOUS_PEPPERS={"old-version":"old-independent-secret"}
+# TERMINAL_AUDIT_KEY=
+TERMINAL_AUDIT_KEY_VERSION=1
+# Export/verification tool only: retain historical keys for audit verification.
+# TERMINAL_AUDIT_PREVIOUS_KEYS={"old-version":"old-audit-secret"}
+
# Short-lived QR challenge: 30–60 seconds. Pairing code: 60–1800 seconds.
TERMINAL_CHALLENGE_TTL_SECONDS=45
TERMINAL_PAIRING_TTL_SECONDS=600
diff --git a/.env.prod.example b/.env.prod.example
index 497635e..6a6eeab 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -3,7 +3,7 @@
# Pin deployments to an immutable OpenClockwork release. Do not use `latest`
# for production upgrades.
-OPENCLOCKWORK_VERSION=2.0.1
+OPENCLOCKWORK_VERSION=2.1.0
# Keep these names unchanged across upgrades.
OPENCLOCKWORK_DB_VOLUME=openclockwork-db-data-prod
OPENCLOCKWORK_ATTACHMENTS_VOLUME=openclockwork-attachments-prod
@@ -19,6 +19,15 @@ JWT_SECRET=change-me-long-random-jwt-secret
TERMINAL_QR_SECRET=change-me-independent-terminal-qr-secret
TERMINAL_CHALLENGE_TTL_SECONDS=45
TERMINAL_PAIRING_TTL_SECONDS=600
+# Optional PIN pilot: use docker-compose.terminal-pin.yml with separate roles.
+# DATABASE_URL must then select openclockwork_runtime, not the migration owner.
+# MIGRATION_DATABASE_URL=postgresql://:@db:5432/openclockwork?schema=public
+# TERMINAL_RUNTIME_DB_PASSWORD=
+# TERMINAL_PIN_PEPPER=
+# TERMINAL_PIN_PEPPER_VERSION=1
+# TERMINAL_PIN_PREVIOUS_PEPPERS={"old-version":"old-pin-pepper"}
+# TERMINAL_AUDIT_KEY=
+# TERMINAL_AUDIT_KEY_VERSION=1
ERP_API_KEY=change-me-erp-api-key
CRON_API_KEY=change-me-cron-api-key
API_CORS_ORIGINS=https://time.example.com
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5557e0f..d8e1f24 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -161,6 +161,9 @@ jobs:
- name: Apply migrations to test DB
run: pnpm prisma migrate deploy
+ - name: Verify generated API contract
+ run: pnpm verify:api
+
- name: API end-to-end tests
run: pnpm nx e2e api-e2e
@@ -240,3 +243,39 @@ jobs:
- name: Verify existing data survived the update
run: node ops/upgrade-smoke.mjs verify
+
+ terminal-pin-browser:
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ env:
+ BASE_URL: http://localhost:18082
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v6
+ with:
+ version: 9.15.9
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 20
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm prisma generate
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+ - name: Build and start isolated PIN stack
+ run: docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml up -d --build --wait
+ - name: Seed synthetic Team pilot
+ run: >-
+ docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml run --rm
+ -e NODE_ENV=development
+ -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test_pin
+ migrate node --import tsx prisma/seed.ts
+ - name: Verify real device/PIN/browser contract
+ run: pnpm nx e2e web-e2e --args="--project=chromium --grep='Terminal PIN'"
+ - name: Start three actual API processes
+ run: docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml up -d --no-build --scale api=3 --wait
+ - name: Verify shared lockout, restart and parallel grant booking
+ run: pnpm nx run api-e2e:test-terminal-runtime
+ - name: Stop pilot
+ if: always()
+ run: docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml down
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8f55c20..05b54d3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -88,6 +88,9 @@ jobs:
- name: Apply release migrations to a fresh database
run: pnpm prisma migrate deploy
+ - name: Verify generated API contract
+ run: pnpm verify:api
+
- name: Run API end-to-end tests
run: pnpm nx e2e api-e2e
diff --git a/Dockerfile.api b/Dockerfile.api
index eb028bb..76ae90e 100644
--- a/Dockerfile.api
+++ b/Dockerfile.api
@@ -20,6 +20,7 @@ COPY libs ./libs
COPY prisma ./prisma
COPY ops/db-target-safety.cjs ./ops/db-target-safety.cjs
COPY ops/safe-db-reset.mjs ./ops/safe-db-reset.mjs
+COPY ops/configure-terminal-runtime.mjs ops/terminal-audit.mjs ops/terminal-audit-chain.mjs ./ops/
# Generate Prisma client + build the API bundle.
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
@@ -47,11 +48,12 @@ COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/ops/db-target-safety.cjs ./ops/db-target-safety.cjs
COPY --from=builder /app/ops/safe-db-reset.mjs ./ops/safe-db-reset.mjs
+COPY --from=builder /app/ops/configure-terminal-runtime.mjs /app/ops/terminal-audit.mjs /app/ops/terminal-audit-chain.mjs ./ops/
# Seed the mount point with app-user ownership. Docker copies this metadata
# into a newly created named volume so local attachments stay writable after
# container replacement.
-RUN mkdir -p /app/data/attachments && chown -R app:app /app/data
+RUN mkdir -p /app/data/attachments /app/data/audit && chown -R app:app /app/data
USER app
EXPOSE 3000
diff --git a/FEATURES.md b/FEATURES.md
index 6cb3c37..d1d061e 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -1,9 +1,13 @@
# OpenClockwork Features
-OpenClockwork 2.0.1 provides **Solo** personal work tracking and **Team** time and
+OpenClockwork 2.1.0 provides **Solo** personal work tracking and **Team** time and
attendance in one responsive, self-hosted application. Both keep deployment,
data, and configuration under the operator's control, with German and English UI.
+Version 2.1.0 adds direct PIN identification and explicit time booking at Team
+tablets, HR PIN administration, and an authentication audit. It retains the
+personnel-data access restrictions introduced in 2.0.1. RFID is planned separately.
+
This page describes implemented capabilities, not a guarantee that every rule,
device, or external browser handoff has been certified. See
[release notes](RELEASE_NOTES.md) for release-specific validation and
@@ -28,14 +32,14 @@ Invoices, rates, payments, and CAUR coding-agent usage import/accounting are
## At a glance
-| Audience | Main capabilities |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
-| Solo owner | Personal timer, direct corrections, calendar, customers, billable projects/orders, timesheets, optional effective-dated accounts |
-| Employees | Clock in/out, scan tablet QR codes, book daily targets, manage requests and absences, view calendars and time accounts |
-| Managers | Review team requests, handle substitute workflows, approve or return corrections, use bulk actions, inspect project data |
-| HR administrators | Manage employees, schedules, leave, projects, reports, terminal kiosks, geofences, devices, and production bootstrap |
-| Kiosk devices | Pair once, display a branded rotating challenge, refresh automatically, report health, and operate with least-privilege credentials |
-| Integrations | Consume documented REST endpoints, generated types, realtime events, ERP exports, and health checks |
+| Audience | Main capabilities |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| Solo owner | Personal timer, direct corrections, calendar, customers, billable projects/orders, timesheets, optional effective-dated accounts |
+| Employees | Clock in/out, use tablet QR or PIN, book daily targets, manage requests and absences, view calendars and time accounts |
+| Managers | Review team requests, handle substitute workflows, approve or return corrections, use bulk actions, inspect project data |
+| HR administrators | Manage employees, schedules, leave, projects, reports, terminal methods and PINs, authentication audits, devices, and bootstrap |
+| Kiosk devices | Pair once, offer configured QR/PIN methods, request explicit IN/OUT, clear personal data, and use least-privilege device credentials |
+| Integrations | Consume documented REST endpoints, generated types, realtime events, ERP exports, and health checks |
Employee, manager, HR, kiosk, and ERP capabilities in the table refer to Team.
Solo exposes only the owner's authorised personal endpoints; old Team routes
@@ -83,7 +87,7 @@ contains effective work or time off, the accounting mode starts on the next
working-timezone calendar date. See [the mode guide](docs/OPERATING_MODES.md).
Solo customer management and personal billable reports are not Team screens in
-2.0. Preserving records across a switch does not make every mode-specific
+2.1.0. Preserving records across a switch does not make every mode-specific
workflow available in both modes.
## Mobile PWA
@@ -119,7 +123,7 @@ using an embedded browser surface.
The screenshots on this page show the Team and kiosk workflows.
-## QR tablet terminals (Team)
+## QR/PIN tablet terminals (Team)
OpenClockwork turns a standard tablet into a shared, branded time-clock display
without requiring RFID badges, proprietary readers, or biometrics. The mounted
@@ -131,15 +135,29 @@ device never acts as an employee and cannot call normal HR or time-entry APIs.
| Capability | What it provides |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
-| Terminal configuration | Internal name, visible location, custom message, optional PNG/JPEG/WebP logo, IANA time zone, and active state |
+| Terminal configuration | Internal name, visible location, custom message, optional PNG/JPEG/WebP logo, IANA time zone, active state, and QR/PIN/both methods |
| Optional GPS | Run without any geofence, or require a fresh position inside a configured radius and maximum accuracy |
| One-time pairing | Short-lived code and kiosk URL; a new pairing revokes the previous active device |
| Kiosk PWA | Dedicated manifest and full-screen view with time, date, location, custom branding, connection state, and automatic challenge refresh |
| Employee scanner | Explicit clock-in or clock-out choice, camera preview, local QR decoding, and GPS only when the terminal requires it |
| Device operations | Last-seen monitoring, re-pairing, immediate device revocation, terminal deactivation, and permanent deletion |
| Time-zone handling | IANA selection with the browser timezone suggested, UTC fallback, and server-side validation |
+| PIN identification | Personal number plus separate eight-digit PIN; explicit IN/OUT, shared lockout, short-lived device-bound permission |
+| HR PIN management | Generate a random PIN shown once, reset, disable, and unlock; no employee password reuse |
+| Shared-screen privacy | Minimal confirmation identity, no employee session, and cleanup after completion, cancellation, inactivity, or background changes |
+| Booking confirmation | Explicit clock-in/out after identification; an uncertain response can retry the same operation without creating a second booking |
+| Authentication audit | HR-filtered events, restricted runtime role, signed chain, and independent-anchor export/verification tooling |
| Durable audit trail | Historical bookings retain terminal/GPS evidence snapshots even when kiosk-only records are removed |
+QR/PIN terminals are included in 2.1.0 and available only in Team mode. Existing
+terminals remain QR-only after upgrade until HR changes their configuration.
+PIN is optional and requires independent secrets and restricted runtime DB rights.
+[PIN operations](docs/TERMINAL_AUTH_OPERATIONS.de.md) documents setup, rotation,
+retention, and audit limits. Operators configure and administer the independent
+archive; the local file adapter does not provide a WORM guarantee. No PINs,
+personal numbers, or booking grants are included in authentication audit events.
+RFID readers and badge management remain [planned](ROADMAP.md#4-optional-rfid-terminal-extension).
+
The local iPad pilot includes a trusted-HTTPS Compose overlay, generated test
CA/server certificates, loopback-only raw service ports, an Nginx TLS gateway,
Guided Access guidance, a site acceptance checklist, and MDM recommendations.
@@ -152,6 +170,7 @@ See [the German iPad setup guide](docs/IPAD_TERMINAL_SETUP.de.md).
| Personal dashboard | Leave balance, overtime account, current booking, open requests, and detected core-time violations |
| Clock in and out | PWA booking with optional GPS, project/service-order selection, and recent booking history |
| QR terminal booking | Scan a short-lived kiosk challenge and let the server validate identity, replay state, and optional geofence |
+| PIN terminal booking | Enter the exact personal number and separate PIN at the tablet, then explicitly confirm clock-in or clock-out |
| Direct daily-target block | With per-employee HR permission, book the contractual daily target as one completed and directly approved block |
| Core-time violation details | Date, affected core window, violation type, boundary, and uncovered minutes |
| Project and service-order booking | Book only to assigned active projects; active service orders become the required booking level |
@@ -289,6 +308,8 @@ local rules and calendar coverage for each period in use.
- JWT access/refresh authentication for interactive users
- Employee identity taken from the authenticated token for live booking APIs
- Role-based guards for employee, manager, and HR endpoints
+- Personnel/account access scoped to self, managed team, or HR; colleague
+ selectors use an authenticated names-only directory
- Dedicated API-key protection for machine-to-machine ERP exports
- Authenticated Socket.IO connections
- Password hashing and refresh-token rotation
@@ -296,6 +317,12 @@ local rules and calendar coverage for each period in use.
- Owner-only Solo access and stale-revision checks on personal mutations
- Separate `TERMINAL_QR_SECRET` for kiosk pairing and QR signing material
- Hash-only device credential storage and immediate revocation
+- Separate Argon2id terminal PINs, shared attempt limits and lockouts, and
+ device-bound booking permissions invalidated by credential/configuration changes
+- Explicit PIN booking actions and transactional duplicate protection; shared
+ tablets do not receive employee access or refresh tokens
+- Authentication audit with a signed chain, restricted database writes, and
+ export/verification tools; external archive and retention remain operator setup
- Short-lived challenges, daily key rotation, rate limiting, expiry, and
per-employee replay protection
- Optional server-enforced geofences with maximum-accuracy validation
@@ -315,6 +342,8 @@ The generated and committed OpenAPI specification lives at
- Authenticated employee clock-in/out and terminal scanning
- HR terminal/device lifecycle endpoints and least-privilege kiosk endpoints
+- HR PIN reset/disable/unlock and paginated authentication audit; device-only
+ challenge, PIN identification, and explicit booking endpoints
- Employee daily-target capability and direct-block creation
- Paginated ERP time-entry exports with project, service-order, and activity
references
@@ -341,6 +370,8 @@ in this release.
- Azure Container Apps, ACR, PostgreSQL Flexible Server, Key Vault, Blob
Storage, and Log Analytics reference infrastructure
- Dedicated terminal QR secret in Compose and Azure Key Vault
+- Optional PIN Compose overlay separating database migration and runtime roles,
+ independent PIN/audit secrets, and a separate local QR/PIN test stack
- Stable versioned images and documented backup/migration/rollback procedures
- Nx targets for lint, type-check, build, unit, integration, E2E, and browser
tests
@@ -359,8 +390,9 @@ installation identity is sent back to OpenClockwork.
- [Choose an operating mode](docs/OPERATING_MODES.md)
- [Use Solo mode](docs/SOLO_MODE.md)
- [Use Team mode](docs/TEAM_MODE.md)
-- [Roadmap: billing, agent usage, and internationalisation](ROADMAP.md)
+- [Roadmap: delivered PIN, planned RFID, billing, and agent usage](ROADMAP.md)
- [Set up and test an iPad terminal](docs/IPAD_TERMINAL_SETUP.de.md)
+- [Operate PIN terminals and verify their audit](docs/TERMINAL_AUTH_OPERATIONS.de.md)
- [Upgrade an existing installation](UPGRADING.md)
- [Review published releases](https://github.com/patrickschiller/openclockwork/releases)
- [Contribute with DCO sign-off](CONTRIBUTING.md)
diff --git a/README.md b/README.md
index 18cdf3b..1835e56 100644
--- a/README.md
+++ b/README.md
@@ -14,12 +14,17 @@
-OpenClockwork 2.0 brings two operating modes to one open-source application.
+OpenClockwork 2.1.0 provides two operating modes in one open-source application.
**Solo** helps an independent professional track personal work, organise customer
projects, and produce timesheets without inventing an HR department. **Team**
supports employee attendance, work schedules, leave, approvals, and tablet
terminals. Both use the same self-hosted PostgreSQL, API, and responsive PWA.
+**New in 2.1.0:** Team employees can identify themselves directly at a paired
+tablet with their personal number and a separate terminal PIN, then explicitly
+confirm clock-in or clock-out. HR manages PINs, terminal authentication methods,
+and the authentication audit. The access-control fixes from 2.0.1 are retained.
+
The interface is available in German and English on phones, tablets, and
desktops. Language is independent of working-time rules: targets, breaks,
holidays, and leave are explicit configuration, not assumptions about your
@@ -28,7 +33,7 @@ country. No proprietary time-clock hardware or hosted subscription is required.
**Existing installations remain in Team mode after an upgrade.** A mode change
is a separate, authenticated administrator action with a preview and safety
checks. Invoice creation, monetary billing, and CAUR coding-agent usage import
-are not included in 2.0.1; see the [roadmap](ROADMAP.md).
+are not included in 2.1.0; see the [roadmap](ROADMAP.md).
## Choose your mode
@@ -38,7 +43,7 @@ are not included in 2.0.1; see the [roadmap](ROADMAP.md).
| Daily workflow | Timer, manual entries, direct corrections, personal calendar | Employee bookings, requests, manager/HR workflows |
| Work rules | Optional targets, leave account, breaks, and personal hints | Employee contracts, schedules, leave allowances, and approvals |
| Customer work | Customers, projects/orders, billable classification, personal timesheets | Assigned projects/orders, PLAN/IST reports, HR working-time reports |
-| Shared terminal | Not available in Solo | Paired tablet QR terminals with optional geofencing |
+| Shared terminal | Not available in Solo | Paired QR/PIN terminals with optional geofencing |
| Start here | [Solo guide](docs/SOLO_MODE.md) | [Team guide](docs/TEAM_MODE.md) |
Read [Operating modes](docs/OPERATING_MODES.md) for boundaries and safe
@@ -69,9 +74,10 @@ Solo customer-management and billable-timesheet UI.
timesheets. Private notes stay out of customer reports.
- **Flexible time capture.** Clock actual start/end times, add an optional GPS
position and project, or use an HR-enabled daily-target block.
-- **QR tablet terminal.** Pair an iPad or another tablet once, show a rotating
- short-lived challenge, and let employees clock in or out from their own
- authenticated phones—without badges, chips, or biometric readers.
+- **QR/PIN tablet terminal.** Pair a tablet once. Employees scan a rotating
+ challenge from their authenticated phone or enter their personal number and
+ separate terminal PIN, then explicitly confirm clock-in or clock-out. HR can
+ enable either method or both; existing terminals retain QR on upgrade.
- **Optional geofencing.** A terminal can work entirely without GPS or require a
fresh employee position inside a server-validated radius with a configured
accuracy limit.
@@ -116,8 +122,20 @@ maintained regional calendars are covered in the [roadmap](ROADMAP.md).
An HR administrator creates a terminal with an internal name, visible location,
custom message, optional logo, IANA time zone, and optional GPS geofence. After
activation, a one-time code pairs exactly one kiosk device. The kiosk then shows
-a continuously refreshed QR challenge while the employee PWA performs the
-authenticated booking.
+a continuously refreshed QR challenge and/or a PIN form according to HR's
+configuration. PIN identification issues a short-lived device-bound booking
+permission; it does not create an employee session or book time by itself.
+HR generates an eight-digit PIN shown once, and can replace, disable, or unlock
+it independently of the employee's web password. PIN entry preserves leading
+zeros in personal numbers. The shared screen clears personal data after the
+interaction, inactivity, or a switch to the background.
+
+The HR audit records authentication, credential changes, and booking outcomes.
+Its signed chain and export/verification tools support an independently managed
+archive; an immutable archive is not created automatically. Before enabling
+PIN in production, configure the separate secrets, restricted database role,
+and archive procedures in [PIN and audit operations](docs/TERMINAL_AUTH_OPERATIONS.de.md).
+RFID readers and badges remain [planned work](ROADMAP.md#4-optional-rfid-terminal-extension).
@@ -130,7 +148,8 @@ The security model is deliberately separate from an employee session:
replay per employee;
- daily signing material is derived from a dedicated `TERMINAL_QR_SECRET`, not
from `JWT_SECRET`;
-- clock-in/out uses the authenticated employee identity from the bearer token;
+- QR uses the authenticated employee identity; PIN uses a separate Argon2id
+ credential with shared lockouts and mandatory explicit confirmation;
- terminal location labels, geofence positions, accuracy, and radius snapshots
remain in the historical booking audit record even if the terminal is later
deleted permanently;
@@ -237,6 +256,12 @@ to an existing checkout; do not rerun initial setup or copy over its secrets.
### Full local Docker stack
+To try Solo and Team side by side with isolated local databases, follow the
+[local Docker test guide (German)](docs/LOCAL_DOCKER_TEST.de.md).
+The separate [QR/PIN terminal test stack](docs/LOCAL_PIN_DOCKER_TEST.de.md)
+uses port 18082. Read the [PIN and audit operations guide](docs/TERMINAL_AUTH_OPERATIONS.de.md)
+before enabling PIN in production. RFID remains a separate future increment.
+
```bash
cp .env.dev.example .env.dev
@@ -310,7 +335,7 @@ repository and enter its directory:
```bash
git clone https://github.com/patrickschiller/openclockwork.git
cd openclockwork
-git switch --detach v2.0.1
+git switch --detach v2.1.0
cp .env.prod.example .env.prod
```
@@ -341,7 +366,7 @@ openssl rand -hex 32
Open `.env.prod` in an editor and replace every `change-me` value:
-- Set `OPENCLOCKWORK_VERSION=2.0.1` for this release. Use the same explicit
+- Set `OPENCLOCKWORK_VERSION=2.1.0` for this release. Use the same explicit
version for the API and web images; never deploy `latest`.
- Put the first command's output into `POSTGRES_PASSWORD` and replace
`change-me-database-password` inside `DATABASE_URL` with that exact same
@@ -468,7 +493,9 @@ start interactive prompts: supply the required identity fields as well.
work. Follow [the Solo walkthrough](docs/SOLO_MODE.md).
- **Team:** review the administrator's employee data, then create schedules,
employees, reporting relationships, projects, and assignments. Configure
- terminal kiosks only if wanted. Follow [the Team walkthrough](docs/TEAM_MODE.md).
+ terminal kiosks only if wanted. PIN requires the additional secrets and
+ database-role setup in [PIN operations](docs/TERMINAL_AUTH_OPERATIONS.de.md);
+ QR remains available without enabling PIN. Follow [the Team walkthrough](docs/TEAM_MODE.md).
Nothing from `prisma/seed.ts` belongs in a production database. Switching mode
later is explicit and guarded; it is not a substitute for completing setup.
@@ -525,8 +552,9 @@ You can also support the project through
- [Operating modes: choose and switch safely](docs/OPERATING_MODES.md)
- [Solo: personal setup, customer work, reports, and recovery](docs/SOLO_MODE.md)
- [Team: employees, schedules, approvals, terminals, and reports](docs/TEAM_MODE.md)
-- [Roadmap: billing, agent usage, and further internationalisation](ROADMAP.md)
+- [Roadmap: PIN delivery, planned RFID, billing, and agent usage](ROADMAP.md)
- [iPad terminal setup and operations (German)](docs/IPAD_TERMINAL_SETUP.de.md)
+- [PIN and authentication audit operations (German)](docs/TERMINAL_AUTH_OPERATIONS.de.md)
- [Upgrade procedure](UPGRADING.md)
- [Release process](RELEASING.md)
- [Azure reference deployment](infra/azure/README.md)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index e75aaf1..b6a5a75 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,68 @@
+# OpenClockwork v2.1.0
+
+## PIN tablet terminals
+
+- Team installations can offer personal-number/PIN identification alongside
+ the existing smartphone QR flow. HR chooses QR, PIN or both per terminal.
+ Employees identify at the paired tablet and explicitly confirm clock-in or
+ clock-out; identifying alone never books time. No employee smartphone is
+ required for PIN.
+- HR can generate an independent eight-digit terminal PIN, see it once, reset
+ it, disable it or unlock a temporary lockout. PINs preserve leading zeros and
+ use salted Argon2id hashes with an independent versioned pepper. Account
+ passwords and employee sessions remain separate.
+- Device-bound challenges and short-lived booking grants, shared database rate
+ limits and lockouts protect the flow. Credential/device/method changes revoke
+ pending permissions. Atomic booking and bounded identical retries prevent
+ duplicate time entries after a lost network response.
+- German and English kiosk/HR screens clear identity and credentials after
+ completion, cancellation, backgrounding or timeout. There is no offline
+ booking queue. Existing QR, geofence and working-time rules remain supported;
+ shared terminals remain unavailable in Solo mode.
+
+## Audit and operations
+
+- HR has a filtered terminal audit view. Events record operations and outcomes
+ without PINs, personal numbers, access tokens or booking grants. An HMAC chain,
+ database mutation protection and a restricted runtime role support export and
+ integrity verification. Successful PIN bookings and their audit commit together.
+- The release includes runtime-role configuration, audit export/verification
+ tooling and a production PIN Compose overlay. Independently protected external
+ audit anchors, retention and key custody remain operator responsibilities;
+ local file permissions do not provide a WORM or compliance guarantee.
+- [README](https://github.com/patrickschiller/openclockwork/blob/v2.1.0/README.md),
+ [FEATURES](https://github.com/patrickschiller/openclockwork/blob/v2.1.0/FEATURES.md)
+ and [ROADMAP](https://github.com/patrickschiller/openclockwork/blob/v2.1.0/ROADMAP.md)
+ document shipped PIN support and the separate planned RFID extension.
+
+## Upgrade and compatibility
+
+- One additive migration, `20260930090000_terminal_pin_credentials`, extends
+ 2.0.1. Existing terminals retain QR-only defaults; no employee PIN is invented
+ and PIN is not enabled automatically. Preserve existing database/attachment
+ volumes, configuration and working timezone. Back up before migration; do not
+ seed or reset an existing installation.
+- Enabling PIN requires independent `TERMINAL_PIN_PEPPER` and
+ `TERMINAL_AUDIT_KEY` values plus separate migration and restricted runtime
+ database roles. Existing QR-only installations do not require those new keys.
+ See [upgrade instructions](https://github.com/patrickschiller/openclockwork/blob/v2.1.0/UPGRADING.md)
+ and [PIN operations](https://github.com/patrickschiller/openclockwork/blob/v2.1.0/docs/TERMINAL_AUTH_OPERATIONS.de.md).
+- Deploy matching API and web images. If deployed separately, update the API
+ first, wait for `/api/health` to report `2.1.0`, then deploy the web image.
+ Reopen existing browser/PWA tabs to load the new interface. The personnel-data
+ access controls introduced in 2.0.1 are retained.
+- This delivers the PIN-first increment of
+ [Issue #33](https://github.com/patrickschiller/openclockwork/issues/33).
+ RFID/keyboard-wedge readers and RFID plus PIN remain future work.
+
+## Docker images
+
+- `ghcr.io/patrickschiller/openclockwork-api:2.1.0`
+- `ghcr.io/patrickschiller/openclockwork-web:2.1.0`
+
+The release workflow also updates each image's `latest` tag. Pin `2.1.0` or a
+verified digest for production and use the same release for both images.
+
# OpenClockwork v2.0.1
## Security fix
diff --git a/RELEASING.md b/RELEASING.md
index fbf44b2..755f353 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -25,6 +25,16 @@ public-contract impact. Use matching metadata in five places:
5. Run `RELEASE_TAG=vMAJOR.MINOR.PATCH pnpm release:verify-tag` and the full CI
suite.
+For 2.1.0, include the additive PIN migration and matching QR/PIN API contract,
+upgrade instructions, role-setup tools and audit verification tools. Retain the
+2.0.1 personnel-data access fixes. Verify QR defaults and existing records across
+the upgrade, PIN and credential-access tests, concurrent booking/revocation,
+audit integrity and the separate runtime-role Docker path. Record the accepted
+local user check without claiming an unperformed target-device or external
+archive test. Production PIN activation still requires the operator's protected
+archive and retention configuration; publishing the source and images does not
+provision or certify that deployment.
+
## Publish
After the release pull request is merged and `main` is green, create an
@@ -33,8 +43,8 @@ annotated tag on the exact merge commit and push it:
```bash
git switch main
git pull --ff-only
-git tag -a v2.0.1 -m "OpenClockwork v2.0.1"
-git push origin v2.0.1
+git tag -a v2.1.0 -m "OpenClockwork v2.1.0"
+git push origin v2.1.0
```
The release workflow verifies the tag and notes, runs Nx and API end-to-end
@@ -50,8 +60,8 @@ pulled anonymously by self-hosted installations.
## Verify the published release
```bash
-docker pull ghcr.io/patrickschiller/openclockwork-api:2.0.1
-docker pull ghcr.io/patrickschiller/openclockwork-web:2.0.1
+docker pull ghcr.io/patrickschiller/openclockwork-api:2.1.0
+docker pull ghcr.io/patrickschiller/openclockwork-web:2.1.0
```
Confirm that the GitHub Release is marked latest, contains the curated upgrade
diff --git a/ROADMAP.md b/ROADMAP.md
index 707c2be..19c02ff 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,8 +1,9 @@
# OpenClockwork Roadmap
OpenClockwork is a public, Apache-2.0 self-hosted application for personal work
-and Team time and attendance. Version **2.0.0** makes Solo and Team first-class
-operating modes. Customer invoicing and coding-agent usage accounting remain
+and Team time and attendance. Version **2.1.0** adds PIN tablet terminals to
+Team, building on the Solo/Team workflows from 2.0 and the access-control fixes
+from 2.0.1. Customer invoicing and coding-agent usage accounting remain
future work; a billable time flag does not mean those features already exist.
This roadmap expresses priorities and intended scope, not release dates or
@@ -12,13 +13,15 @@ boundaries, and [README.md](README.md) for release installation.
## Delivery order
-| Stage | Scope | Status / dependency |
-| ----- | ---------------------------------------------- | --------------------------------------- |
-| 1 | Country-neutral configuration and defaults | Included in 1.4.0; retained in 2.0.0 |
-| 2 | Complete personal Solo workflow alongside Team | Included in 2.0.0 |
-| 3 | Customer billing and invoice creation | Planned; intended for Solo and Team |
-| 4 | CAUR usage import, attribution, and review | Planned; can progress alongside stage 3 |
-| 5 | Agent billing and combined customer invoices | Planned; depends on stages 3 and 4 |
+| Stage | Scope | Status / dependency |
+| ----- | --------------------------------------------------- | --------------------------------------- |
+| 1 | Country-neutral configuration and defaults | Included in 1.4.0; retained in 2.1.0 |
+| 2 | Complete personal Solo workflow alongside Team | Included in 2.0.0 |
+| 3 | QR/PIN tablet terminals and HR authentication audit | Included in 2.1.0; Team only |
+| 4 | Optional RFID terminal extension | Planned separately after PIN acceptance |
+| 5 | Customer billing and invoice creation | Planned; intended for Solo and Team |
+| 6 | CAUR usage import, attribution, and review | Planned; can progress alongside stage 5 |
+| 7 | Agent billing and combined customer invoices | Planned; depends on stages 5 and 6 |
Version 2.0 deliberately marks a new product generation, not an invented
breaking Team API or a required data reset. Existing installations migrate
@@ -91,7 +94,52 @@ Follow-up work, not part of this release:
- [ ] Further device/PWA coverage and any additional export delivery formats.
Online-first operation is the current contract, not an offline write queue.
-## 3. Customer billing and invoice creation
+## 3. QR/PIN tablet terminals in 2.1.0
+
+The PIN increment of [issue #33](https://github.com/patrickschiller/openclockwork/issues/33)
+is implemented and locally accepted on 11 October 2026. It is available in Team
+mode; Solo does not expose terminal authentication or booking.
+
+- [x] Per-terminal QR, PIN, or combined authentication, preserving QR-only
+ configuration for existing terminals after an upgrade.
+- [x] Personal number and a separate eight-digit terminal PIN; HR generation
+ with one-time display, replacement, disabling, and unlocking.
+- [x] Short-lived device-bound permission after identification, followed by an
+ explicit clock-in/out action without an employee session on the kiosk.
+- [x] Shared attempt limits, credential/configuration revocation, transactional
+ duplicate protection, and safe retries of an unconfirmed booking.
+- [x] German/English kiosk and HR interfaces, personal-data cleanup, online-only
+ booking, and optional server-validated geofencing.
+- [x] HR authentication audit, restricted runtime database rights, signed event
+ chain, and export/verification tools for independent archive anchors.
+- [x] Forward-only migration, QR/PIN and access-control regression tests, local
+ Docker verification, and operator documentation.
+
+See [release notes](RELEASE_NOTES.md) for validation and
+[PIN operations](docs/TERMINAL_AUTH_OPERATIONS.de.md) for deployment requirements.
+Local acceptance does not configure a production archive or certify every
+tablet/browser combination. Operators validate their devices, GPS requirements,
+sleep/resume behaviour, and independently managed archive and retention setup.
+
+## 4. Optional RFID terminal extension
+
+RFID remains the separate, unimplemented increment B of issue #33. No particular
+reader is required by the PIN release. Future reader support must be tested with
+the devices and badges chosen by the operator.
+
+- [ ] Keyboard-wedge reader support through an explicit scan dialog, with a
+ configurable manual submit or reader-Enter submit mode per terminal.
+- [ ] Card enrolment, replacement, revocation, and transfer with HR permissions,
+ protected card identifiers, and authentication audit coverage.
+- [ ] RFID identification using the same limited booking permission and separate
+ explicit clock-in/out confirmation; a scan never clocks time automatically.
+- [ ] Reader format/focus validation, real-device acceptance, operational guidance,
+ and QR/PIN regression coverage.
+
+RFID work can proceed independently of the billing roadmap. PIN acceptance does
+not mark the remaining RFID scope of issue #33 as completed.
+
+## 5. Customer billing and invoice creation
Invoices should be available in both Solo and Team mode, using the same customer,
project, permission, and audit model.
@@ -125,12 +173,12 @@ project, permission, and audit model.
**Done when:** a Solo owner or authorised team member can create a reproducible
customer invoice from tracked work and follow it through payment or correction.
-## 4. CAUR coding-agent usage accounting
+## 6. CAUR coding-agent usage accounting
[CAUR — Coding Agent Usage Record](https://github.com/patrickschiller/caur)
defines a vendor-neutral usage record for a completed or interrupted coding-agent
run. It is a separate specification project, not an invoice format or pricing
-catalogue, and is not imported by OpenClockwork 2.0.0. A future integration should
+catalogue, and is not imported by OpenClockwork 2.1.0. A future integration should
consume explicitly supported schema versions and provide attribution, review,
and billing around them. Its design should follow the
[CAUR specification](https://github.com/patrickschiller/caur/blob/main/SPEC.md)
@@ -169,7 +217,7 @@ and [JSON Schema](https://github.com/patrickschiller/caur/blob/main/schema/caur-
**Done when:** records from independent agent harnesses can be imported, attributed,
and reviewed with reproducible totals and no duplicated usage or sensitive payloads.
-## 5. Agent billing and combined invoices
+## 7. Agent billing and combined invoices
- [ ] **Commercial rules:** explicitly configure whether to pass through verified
costs, apply a markup, bill usage units or agent time, or use an agreed fixed
diff --git a/SECURITY.md b/SECURITY.md
index fb269d3..cae4f33 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -43,3 +43,16 @@ Out of scope:
Good-faith security research conducted in line with this policy will not be pursued legally by the project authors. Do not access, modify, or destroy data that does not belong to you, do not pivot to systems outside this scope, and do not run automated scanners against systems you do not own.
Thank you for helping keep OpenClockwork and its users safe.
+
+## Terminal PIN and audit deployment
+
+Terminal PINs are separate from employee login passwords, use Argon2id with a
+versioned independent pepper, and require a paired device and explicit booking
+action. All API instances must share the same secrets and PostgreSQL limits.
+Production PIN operation requires a separate runtime DB role without audit
+UPDATE/DELETE/TRUNCATE rights, and retained external integrity anchors. The
+local file adapter proves chain verification, not immutable production storage.
+See [terminal operations](docs/TERMINAL_AUTH_OPERATIONS.de.md) for key rotation,
+retention, privacy, backup and incident handling. Harden the kiosk and use TLS;
+a compromised kiosk can capture PIN inputs, and browser GPS is not strong
+proof of presence. No RFID capability is included in Increment A.
diff --git a/UPGRADING.md b/UPGRADING.md
index 20716af..99fc404 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -25,6 +25,56 @@ Compose from managing their deletion, but this does not protect against manual
volume removal, pruning unused volumes, Docker data loss or host failure. Do
not replace a populated installation's volume with a new name during an upgrade.
+## Required for 2.1.0
+
+Version 2.1.0 adds personal-number/PIN identification to paired Team terminals.
+Upgrade directly from 2.0.1 with matching 2.1.0 API/web images. The 2.0.1
+personnel-data access restrictions described below remain in effect.
+
+The additive migration `20260930090000_terminal_pin_credentials` adds terminal
+authentication configuration, PIN credentials, shared attempt counters,
+short-lived booking grants and immutable terminal audit events. Existing
+terminals default to QR-only, and existing challenges retain their QR purpose.
+Employee, terminal and time-entry identities and historical snapshots remain.
+Solo does not gain terminal access. Keep all existing migration files unchanged;
+run `prisma migrate deploy`, never a reset or seed. A local preview that already
+applied this migration must retain it and its data rather than recreate it.
+
+### Opting in to PIN
+
+QR-only installations can upgrade without the new PIN/audit secrets. Enabling
+PIN is an explicit operator and HR configuration step:
+
+1. Provide independent `TERMINAL_PIN_PEPPER` and `TERMINAL_AUDIT_KEY` values of
+ at least 32 characters to every API instance. Neither may equal the other or
+ the existing JWT/QR secrets. Keep the same versioned keyset across instances,
+ preserve historical verification keys and back them up separately from the
+ database. Do not change the installation's working timezone.
+2. Separate the migration/role-administration connection from the API runtime
+ connection. Use `docker-compose.terminal-pin.yml` together with the existing
+ production Compose configuration. `MIGRATION_DATABASE_URL` supplies the
+ privileged migration job; `DATABASE_URL` must use the restricted runtime
+ role and its matching `TERMINAL_RUNTIME_DB_PASSWORD` of at least 20 characters.
+ The job applies migrations and grants before the API starts. The API must not
+ receive the migration credentials. Reapply runtime grants after migrations;
+ the production API refuses unsafe audit ownership or modification rights.
+3. Before production PIN use, configure and verify an independently retained
+ audit archive, its retention, access controls, regular exports, monitoring
+ and recovery procedure. The supplied local export/anchor adapter tests the
+ integrity chain; a file in the app's volume is not an immutable external
+ archive. These are deployment requirements, not services provisioned by
+ installing or publishing the release.
+4. In HR administration, enable PIN for the selected terminal and issue each
+ employee a separate terminal PIN. Confirm identity first, then explicitly
+ select clock-in or clock-out. Verify QR, reset/revocation, retries and any
+ configured geofence on the deployment's actual kiosk devices.
+
+Include the PIN overlay in every subsequent Compose operation, including
+backup and upgrade commands. See the [PIN operations guide](docs/TERMINAL_AUTH_OPERATIONS.de.md)
+for role setup, key rotation and audit verification, and the
+[isolated local test guide](docs/LOCAL_PIN_DOCKER_TEST.de.md) for synthetic tests.
+RFID is not part of 2.1.0.
+
## Required for 2.0.1
Version 2.0.1 fixes read access in Team mode to personnel records, time accounts, vacation
@@ -32,7 +82,9 @@ balances, leave allowances, absences and working-time violations. Updating
existing installations is recommended. From 2.0.0,
this update adds no database migrations and requires no seed or reset.
Keep the existing data, volumes, configuration and working timezone, take a
-consistent backup, and deploy matching 2.0.1 API/web images as described below.
+consistent backup, and deploy matching API/web versions. These access changes
+also apply when upgrading directly to 2.1.0; follow its additional requirements
+above and the deployment procedure below.
Custom API clients must handle `401` and `403` responses and the scoped
`GET /api/employees` list. Use the authenticated `GET /api/employees/directory`
@@ -335,7 +387,7 @@ runs in a non-production runtime; the API retains its production runtime.
## 2. Select and pull the release
Set `OPENCLOCKWORK_VERSION` in `.env.prod` to the exact version from the GitHub
-Release, **`2.0.1` for this upgrade**, only after preserving the old configuration
+Release, **`2.1.0` for this upgrade**, only after preserving the old configuration
in the backup set. Pin matching API/web versions or verified digests. Do not use
`latest`, a mutable `:local` image, or a source-build override for an unattended
production upgrade. Keep the same PostgreSQL major version; a PostgreSQL major
@@ -347,17 +399,22 @@ Use the `oc` function and exact deployment configuration selected in step 1:
oc pull api web
```
+With the PIN overlay, also run `oc pull migrate`; its image must match the API
+version. The `oc` function must include the same overlay as the deployed stack.
+
## 3. Apply the update
```bash
oc up -d
```
-The API waits for PostgreSQL and runs `prisma migrate deploy` before starting.
+The standard stack waits for PostgreSQL and runs `prisma migrate deploy` before
+starting the API. With the PIN overlay, the separate `migrate` service applies
+migrations and runtime grants; the API waits for its successful completion.
Prisma records applied migrations in `_prisma_migrations` and skips them on
subsequent starts. Production startup never seeds or resets the database.
If API/web are deployed independently, deploy the API first, wait for its
-health endpoint to report `2.0.1`, then deploy the matching web image. The web
+health endpoint to report `2.1.0`, then deploy the matching web image. The web
startup version check is not a promise of zero-downtime mixed-version operation;
keep additional writers paused until verification completes.
@@ -380,19 +437,32 @@ existing installation. Keep production isolated from demo/reset workflows.
```bash
oc ps
+oc exec -T api wget -qO- http://127.0.0.1:3000/api/health
+```
-oc exec -T api ./node_modules/.bin/prisma migrate status
+Check migration status with the migration-capable connection. For the standard
+QR-only stack, run `oc exec -T api ./node_modules/.bin/prisma migrate status`.
+With the PIN overlay, use its separate migration service instead:
-oc exec -T api wget -qO- http://127.0.0.1:3000/api/health
+```bash
+oc run --rm --no-deps -T migrate ./node_modules/.bin/prisma migrate status
```
-Confirm `2.0.1` through the public web/proxy health route as well as the
+The PIN runtime role deliberately has no access to `_prisma_migrations`; do not
+broaden its permissions to run this maintenance check from the API container.
+
+Confirm `2.1.0` through the public web/proxy health route as well as the
container check. Sign in and verify the actual deployed mode, a known employee,
an existing time entry, historic break totals, leave balances and configured
attachment storage. A 1.4.0 upgrade must still be Team unless an administrator
has explicitly switched it. Reopen old PWA/browser tabs if they show a cached
bundle, and verify that exports/printing work in the browsers you operate.
+On a QR-only upgrade, confirm terminals remain QR-only and existing pairing
+works. If PIN was deliberately enabled, verify PIN identification makes no time
+entry, an explicit action books once, resets/revocations invalidate outstanding
+grants, and audit export verifies against the independently retained anchor.
+
For an intentional Solo transition, verify the owner, inherited or deliberately
disabled personal rules, customer/project eligibility, timer state and history.
Confirm existing records remain accessible in their intended mode. Do not
@@ -407,6 +477,9 @@ Application containers can only be changed back to an earlier
compatible with that version. Forward migrations are not automatically
reversed. **2.0.0 does not declare its migrated database backward-compatible
with 1.4.0 application binaries.** Do not simply point an older API at it.
+Likewise, 2.1.0 does not declare a downgrade to 2.0.1 safe after PIN migration
+and use. To stop using PIN, disable the terminal's PIN method in HR, explicitly
+enable QR and verify the kiosk while retaining the schema and audit history.
For a full rollback:
diff --git a/apps/api-e2e/package.json b/apps/api-e2e/package.json
index 3dbf31b..1c37de7 100644
--- a/apps/api-e2e/package.json
+++ b/apps/api-e2e/package.json
@@ -14,6 +14,14 @@
"cwd": "."
}
},
+ "test-terminal-runtime": {
+ "executor": "nx:run-commands",
+ "cache": false,
+ "options": {
+ "command": "node ops/terminal-pin-runtime-smoke.mjs",
+ "cwd": "."
+ }
+ },
"e2e": {
"executor": "@nx/jest:jest",
"outputs": [
diff --git a/apps/api-e2e/src/api/terminal-auth.e2e.spec.ts b/apps/api-e2e/src/api/terminal-auth.e2e.spec.ts
new file mode 100644
index 0000000..0e28fa3
--- /dev/null
+++ b/apps/api-e2e/src/api/terminal-auth.e2e.spec.ts
@@ -0,0 +1,611 @@
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
+
+describe('Device-bound terminal PIN identification', () => {
+ let ctx: TestContext;
+
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ });
+
+ async function fixture() {
+ const hr = await seedEmployee(ctx.prisma, {
+ personalNo: 'AUTH-HR',
+ firstName: 'Hannah',
+ lastName: 'Administrator',
+ email: 'hr@pin-auth.test',
+ role: 'HRAdmin',
+ });
+ const employee = await seedEmployee(ctx.prisma, {
+ personalNo: '000007',
+ firstName: 'Erika',
+ lastName: 'Employee',
+ email: 'employee@pin-auth.test',
+ });
+ const hrToken = await login(ctx.http, hr.email);
+ const employeeToken = await login(ctx.http, employee.email);
+ const reset = await ctx.http
+ .post(`/api/employees/${employee.id}/terminal-credentials/pin/reset`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({})
+ .expect(201);
+ const device = await terminal(hrToken);
+ return {
+ hr,
+ employee,
+ hrToken,
+ employeeToken,
+ pin: reset.body.pin as string,
+ ...device,
+ };
+ }
+
+ async function terminal(hrToken: string, methods = ['QR', 'PIN']) {
+ const created = await ctx.http
+ .post('/api/terminals')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({
+ name: 'Synthetic PIN authentication',
+ displayText: 'Synthetic terminal',
+ locationLabel: 'Synthetic reception',
+ timeZone: 'Europe/Berlin',
+ enforceGeofence: false,
+ isActive: true,
+ allowedAuthMethods: methods,
+ })
+ .expect(201);
+ const pairing = await ctx.http
+ .post(`/api/terminals/${created.body.id}/pairing`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(201);
+ const paired = await ctx.http
+ .post('/api/terminals/pair')
+ .send({
+ pairingCode: pairing.body.pairingCode,
+ deviceName: 'Synthetic test kiosk',
+ })
+ .expect(201);
+ return {
+ terminalId: created.body.id as string,
+ deviceToken: paired.body.deviceToken as string,
+ };
+ }
+
+ async function challenge(deviceToken: string, instance = ctx) {
+ const result = await instance.http
+ .post('/api/terminals/kiosk/challenge')
+ .set('Authorization', `Bearer ${deviceToken}`)
+ .send({})
+ .expect(201);
+ expect(result.headers['cache-control']).toContain('no-store');
+ return result.body.payload as string;
+ }
+
+ function identify(
+ instance: TestContext,
+ deviceToken: string,
+ payload: string,
+ personalNo: string,
+ pin: string,
+ extra: Record = {},
+ ) {
+ return instance.http
+ .post('/api/terminals/kiosk/identify')
+ .set('Authorization', `Bearer ${deviceToken}`)
+ .send({
+ method: 'PIN',
+ challengePayload: payload,
+ personalNo,
+ pin,
+ ...extra,
+ });
+ }
+
+ const wrongPin = (pin: string) =>
+ `${pin[0] === '0' ? '1' : '0'}${pin.slice(1)}`;
+
+ async function withConfiguration(
+ values: Record,
+ run: (instance: TestContext) => Promise,
+ ) {
+ const prior = new Map(
+ Object.keys(values).map((key) => [key, process.env[key]]),
+ );
+ for (const [key, value] of Object.entries(values)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ let instance: TestContext | undefined;
+ try {
+ instance = await createTestApp();
+ await run(instance);
+ } finally {
+ if (instance) await instance.close();
+ for (const [key, value] of prior) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ }
+ }
+
+ it('preserves personal-number zeros, identifies without booking and returns only a scoped grant', async () => {
+ const f = await fixture();
+ const payload = await challenge(f.deviceToken);
+ await identify(ctx, f.deviceToken, payload, '7', f.pin).expect(401);
+ const result = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ { employeeId: f.hr.id },
+ ).expect(201);
+ expect(result.headers['cache-control']).toContain('no-store');
+ expect(Object.keys(result.body).sort()).toEqual([
+ 'action',
+ 'displayName',
+ 'expiresAt',
+ 'grant',
+ 'requiresLocation',
+ ]);
+ expect(result.body).toMatchObject({
+ action: 'clock-in',
+ requiresLocation: false,
+ });
+ expect(result.body.displayName).toContain(f.employee.firstName);
+ expect(result.body.displayName).not.toContain(f.employee.lastName);
+ expect(result.body.expiresAt).toBeTruthy();
+ expect(
+ new Date(result.body.expiresAt).getTime() - Date.now(),
+ ).toBeLessThanOrEqual(30_000);
+ expect(JSON.stringify(result.body)).not.toMatch(
+ /personalNo|email|accessToken|refreshToken|employeeId/,
+ );
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ const stored = await ctx.prisma.terminalBookingGrant.findFirstOrThrow();
+ expect(stored.employeeId).toBe(f.employee.id);
+ expect(stored.consumedAt).toBeNull();
+ expect(stored.tokenHash).not.toBe(result.body.grant);
+ expect(stored.tokenHash).not.toContain(result.body.grant);
+ for (const token of [f.deviceToken, result.body.grant]) {
+ await ctx.http
+ .get('/api/auth/me')
+ .set('Authorization', `Bearer ${token}`)
+ .expect(401);
+ await ctx.http
+ .get('/api/employees')
+ .set('Authorization', `Bearer ${token}`)
+ .expect(401);
+ await ctx.http
+ .post('/api/auth/refresh')
+ .send({ refreshToken: token })
+ .expect(401);
+ }
+ await ctx.http
+ .post('/api/terminals/kiosk/challenge')
+ .set('Authorization', `Bearer ${f.employeeToken}`)
+ .send({})
+ .expect(401);
+ });
+
+ it('separates display QR challenges from kiosk-identify challenges and rejects a foreign device', async () => {
+ const f = await fixture();
+ const foreign = await terminal(f.hrToken);
+ const display = await ctx.http
+ .get('/api/terminals/kiosk')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .expect(200);
+ await identify(
+ ctx,
+ f.deviceToken,
+ display.body.challenge.payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(409);
+ const payload = await challenge(f.deviceToken);
+ await identify(
+ ctx,
+ foreign.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(409);
+ await ctx.http
+ .post('/api/terminals/scan')
+ .set('Authorization', `Bearer ${f.employeeToken}`)
+ .send({ qrPayload: payload })
+ .expect(400);
+ await ctx.http
+ .post('/api/terminals/kiosk/identify')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({
+ method: 'RFID',
+ challengePayload: payload,
+ cardValue: '00001234',
+ })
+ .expect(400);
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ });
+
+ it('returns neutral errors for wrong, unknown, inactive and disabled credentials without unpairing', async () => {
+ const f = await fixture();
+ const payload = await challenge(f.deviceToken);
+ const wrong = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ wrongPin(f.pin),
+ ).expect(401);
+ const unknown = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ 'UNKNOWN-PRIVATE-NUMBER',
+ f.pin,
+ ).expect(401);
+ await ctx.prisma.employee.update({
+ where: { id: f.employee.id },
+ data: { isActive: false },
+ });
+ const inactive = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(401);
+ await ctx.prisma.employee.update({
+ where: { id: f.employee.id },
+ data: { isActive: true },
+ });
+ await ctx.http
+ .delete(`/api/employees/${f.employee.id}/terminal-credentials/pin`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .expect(200);
+ const disabled = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(401);
+ for (const result of [wrong, unknown, inactive, disabled]) {
+ expect(result.body).toEqual(wrong.body);
+ expect(result.body.code).toBe('TERMINAL_AUTH_FAILED');
+ expect(JSON.stringify(result.body)).not.toMatch(
+ /Erika|Employee|000007|lockedUntil|employeeId/,
+ );
+ expect(result.headers['cache-control']).toContain('no-store');
+ }
+ await ctx.http
+ .get('/api/terminals/kiosk')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .expect(200);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ const failures = await ctx.prisma.terminalAuditEvent.findMany({
+ where: { event: 'IdentificationRejected', result: 'AUTH_FAILED' },
+ });
+ expect(failures).toHaveLength(4);
+ expect(
+ failures.filter((event) => event.employeeId === f.employee.id),
+ ).toHaveLength(3);
+ expect(failures.filter((event) => event.employeeId === null)).toHaveLength(
+ 1,
+ );
+ for (const event of failures) {
+ expect(event.canonicalPayload).not.toContain(f.pin);
+ expect(event.canonicalPayload).not.toContain('UNKNOWN-PRIVATE-NUMBER');
+ expect(event.canonicalPayload).not.toContain(f.deviceToken);
+ }
+ });
+
+ it('rejects numeric, oversized and malformed credentials before granting access', async () => {
+ const f = await fixture();
+ const payload = await challenge(f.deviceToken);
+ for (const body of [
+ { personalNo: 7, pin: f.pin },
+ { personalNo: f.employee.personalNo, pin: Number(f.pin) },
+ { personalNo: 'X'.repeat(101), pin: f.pin },
+ { personalNo: f.employee.personalNo, pin: '1234567' },
+ { personalNo: f.employee.personalNo, pin: 'abcdefgh' },
+ ]) {
+ await ctx.http
+ .post('/api/terminals/kiosk/identify')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({ method: 'PIN', challengePayload: payload, ...body })
+ .expect(400);
+ }
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ });
+
+ it('shares employee lockout across three independent API instances and retains it after restart', async () => {
+ const f = await fixture();
+ const secondTerminal = await terminal(f.hrToken);
+ let second: TestContext | undefined;
+ let third: TestContext | undefined;
+ try {
+ second = await createTestApp();
+ third = await createTestApp();
+ const firstPayload = await challenge(f.deviceToken);
+ const secondPayload = await challenge(secondTerminal.deviceToken, second);
+ const instances = [ctx, second, third];
+ const attempts = await Promise.all(
+ Array.from({ length: 5 }, (_, index) =>
+ identify(
+ instances[index % 3],
+ index % 2 ? secondTerminal.deviceToken : f.deviceToken,
+ index % 2 ? secondPayload : firstPayload,
+ f.employee.personalNo,
+ wrongPin(f.pin),
+ ),
+ ),
+ );
+ expect(attempts.map((response) => response.status)).toEqual([
+ 401, 401, 401, 401, 401,
+ ]);
+ const state = await ctx.prisma.terminalAuthAttemptState.findUniqueOrThrow(
+ {
+ where: { key: `pin:${f.employee.id}` },
+ },
+ );
+ expect(state.counter).toBe(5);
+ expect(state.lockedUntil).not.toBeNull();
+ expect(state.lockedUntil?.getTime()).toBeGreaterThan(
+ Date.now() + 14 * 60_000,
+ );
+ await second.close();
+ second = await createTestApp();
+ const blocked = await identify(
+ second,
+ secondTerminal.deviceToken,
+ secondPayload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(401);
+ expect(blocked.body).toEqual(attempts[0].body);
+ await ctx.http
+ .post(`/api/employees/${f.employee.id}/terminal-credentials/pin/unlock`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({})
+ .expect(201);
+ await identify(
+ third,
+ f.deviceToken,
+ firstPayload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(201);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ } finally {
+ if (second) await second.close();
+ if (third) await third.close();
+ }
+ });
+
+ it('enforces a device budget before unbounded unknown-person state can grow', async () => {
+ const f = await fixture();
+ const payload = await challenge(f.deviceToken);
+ let limited: Awaited> | undefined;
+ const base = Date.now();
+ jest.useFakeTimers({
+ doNotFake: ['nextTick', 'setImmediate', 'setInterval', 'setTimeout'],
+ });
+ jest.setSystemTime(base);
+ try {
+ for (let attempt = 0; attempt < 62; attempt += 1) {
+ // Advance only between bursts so the minute budget is exercised too.
+ jest.setSystemTime(base + Math.floor(attempt / 20) * 11_000);
+ const result = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ `UNKNOWN-${attempt}`,
+ f.pin,
+ );
+ if (result.status === 429) {
+ expect(attempt).toBe(60);
+ limited = result;
+ break;
+ }
+ expect(result.status).toBe(401);
+ }
+ } finally {
+ jest.useRealTimers();
+ }
+ expect(limited).toBeDefined();
+ expect(limited?.body.code).toBe('TERMINAL_AUTH_RATE_LIMITED');
+ expect(Number(limited?.headers['retry-after'])).toBeGreaterThan(0);
+ expect(
+ await ctx.prisma.terminalAuthAttemptState.count({
+ where: { key: { startsWith: 'pin:' } },
+ }),
+ ).toBe(0);
+ expect(await ctx.prisma.terminalAuthAttemptState.count()).toBeLessThan(10);
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ });
+
+ it('checks the current method and device state before issuing grants', async () => {
+ const f = await fixture();
+ const payload = await challenge(f.deviceToken);
+ await ctx.http
+ .put(`/api/terminals/${f.terminalId}`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({ allowedAuthMethods: ['QR'] })
+ .expect(200);
+ const methodDisabled = await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(403);
+ expect(methodDisabled.body.code).toBe('TERMINAL_METHOD_DISABLED');
+ await ctx.http
+ .put(`/api/terminals/${f.terminalId}`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({ allowedAuthMethods: ['QR', 'PIN'] })
+ .expect(200);
+ const admin = await ctx.http
+ .get(`/api/terminals/${f.terminalId}`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .expect(200);
+ const device = admin.body.devices.find(
+ (item: { revokedAt: string | null }) => item.revokedAt === null,
+ );
+ await ctx.http
+ .delete(`/api/terminals/${f.terminalId}/devices/${device.id}`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .expect(200);
+ await identify(
+ ctx,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(401);
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ });
+
+ it.each(['TERMINAL_PIN_PEPPER', 'TERMINAL_AUDIT_KEY'])(
+ 'fails PIN operations safely while QR remains available if %s is missing after restart',
+ async (key) => {
+ const f = await fixture();
+ const qrOnly = await terminal(f.hrToken, ['QR']);
+ await withConfiguration({ [key]: undefined }, async (instance) => {
+ const display = await instance.http
+ .get('/api/terminals/kiosk')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .expect(200);
+ expect(display.body.challenge.payload).toMatch(/^ocw1\.n\./);
+ await instance.http
+ .post(
+ `/api/employees/${f.employee.id}/terminal-credentials/pin/reset`,
+ )
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({})
+ .expect(503);
+ await instance.http
+ .put(`/api/terminals/${qrOnly.terminalId}`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({ allowedAuthMethods: ['QR', 'PIN'] })
+ .expect(503);
+ await instance.http
+ .post('/api/terminals/kiosk/challenge')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({})
+ .expect(503);
+ await instance.http
+ .post('/api/terminals/scan')
+ .set('Authorization', `Bearer ${f.employeeToken}`)
+ .send({
+ qrPayload: display.body.challenge.payload,
+ action: 'clock-in',
+ })
+ .expect(201);
+ });
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ },
+ );
+
+ it('rejects a PIN pepper reused as the audit key', async () => {
+ const f = await fixture();
+ await withConfiguration(
+ { TERMINAL_PIN_PEPPER: process.env.TERMINAL_AUDIT_KEY },
+ async (instance) => {
+ await instance.http
+ .post(
+ `/api/employees/${f.employee.id}/terminal-credentials/pin/reset`,
+ )
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({})
+ .expect(503);
+ await instance.http
+ .post('/api/terminals/kiosk/challenge')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({})
+ .expect(503);
+ await instance.http
+ .get('/api/terminals/kiosk')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .expect(200);
+ },
+ );
+ expect(await ctx.prisma.terminalBookingGrant.count()).toBe(0);
+ });
+
+ it('accepts an old credential only with its retained pepper and resets it using the new key version', async () => {
+ const f = await fixture();
+ const previousVersion = process.env.TERMINAL_PIN_PEPPER_VERSION ?? '1';
+ const previousPepper = process.env.TERMINAL_PIN_PEPPER ?? '';
+ const rotatedVersion =
+ previousVersion === 'rotated-e2e' ? 'next-e2e' : 'rotated-e2e';
+ await withConfiguration(
+ {
+ TERMINAL_PIN_PEPPER: 'synthetic-rotated-e2e-pepper-distinct-and-long',
+ TERMINAL_PIN_PEPPER_VERSION: rotatedVersion,
+ TERMINAL_PIN_PREVIOUS_PEPPERS: '{}',
+ },
+ async (instance) => {
+ const payload = await challenge(f.deviceToken, instance);
+ await identify(
+ instance,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(401);
+ process.env.TERMINAL_PIN_PREVIOUS_PEPPERS = JSON.stringify({
+ [previousVersion]: previousPepper,
+ });
+ const oldGrant = await identify(
+ instance,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ f.pin,
+ ).expect(201);
+ const replacement = await instance.http
+ .post(
+ `/api/employees/${f.employee.id}/terminal-credentials/pin/reset`,
+ )
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({})
+ .expect(201);
+ const stored = await ctx.prisma.terminalCredential.findUniqueOrThrow({
+ where: { employeeId: f.employee.id },
+ });
+ expect(stored.pepperVersion).toBe(rotatedVersion);
+ await instance.http
+ .post('/api/terminals/kiosk/book')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({ grant: oldGrant.body.grant, action: 'clock-in' })
+ .expect(409);
+ await identify(
+ instance,
+ f.deviceToken,
+ payload,
+ f.employee.personalNo,
+ replacement.body.pin,
+ ).expect(201);
+ await login(instance.http, f.employee.email);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ },
+ );
+ });
+});
diff --git a/apps/api-e2e/src/api/terminal-booking.e2e.spec.ts b/apps/api-e2e/src/api/terminal-booking.e2e.spec.ts
new file mode 100644
index 0000000..b2e63a8
--- /dev/null
+++ b/apps/api-e2e/src/api/terminal-booking.e2e.spec.ts
@@ -0,0 +1,644 @@
+import { createHmac } from 'node:crypto';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
+
+describe('Atomic, explicit terminal PIN bookings and audits', () => {
+ let ctx: TestContext;
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ });
+
+ async function terminal(hrToken: string, geofence = false) {
+ const result = await ctx.http
+ .post('/api/terminals')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({
+ name: 'Synthetic PIN booking',
+ displayText: 'Synthetic test terminal',
+ locationLabel: 'Synthetic reception',
+ timeZone: 'Europe/Berlin',
+ enforceGeofence: geofence,
+ isActive: true,
+ allowedAuthMethods: ['QR', 'PIN'],
+ ...(geofence
+ ? {
+ latitude: 49.7913,
+ longitude: 9.9534,
+ radiusMeters: 100,
+ maxAccuracyMeters: 50,
+ }
+ : {}),
+ })
+ .expect(201);
+ const pair = await ctx.http
+ .post(`/api/terminals/${result.body.id}/pairing`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(201);
+ const paired = await ctx.http
+ .post('/api/terminals/pair')
+ .send({
+ pairingCode: pair.body.pairingCode,
+ deviceName: 'Synthetic booking kiosk',
+ })
+ .expect(201);
+ return {
+ terminalId: result.body.id as string,
+ deviceToken: paired.body.deviceToken as string,
+ };
+ }
+
+ async function fixture(geofence = false, ownerOnly = false) {
+ const hr = await seedEmployee(ctx.prisma, {
+ personalNo: 'BOOK-HR',
+ firstName: 'Hannah',
+ lastName: 'Administrator',
+ email: 'hr@pin-booking.test',
+ role: 'HRAdmin',
+ });
+ const employee = ownerOnly
+ ? hr
+ : await seedEmployee(ctx.prisma, {
+ personalNo: '000007',
+ firstName: 'Erika',
+ lastName: 'Employee',
+ email: 'employee@pin-booking.test',
+ });
+ const hrToken = await login(ctx.http, hr.email);
+ const reset = await ctx.http
+ .post(`/api/employees/${employee.id}/terminal-credentials/pin/reset`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({})
+ .expect(201);
+ return {
+ hr,
+ employee,
+ hrToken,
+ pin: reset.body.pin as string,
+ ...(await terminal(hrToken, geofence)),
+ };
+ }
+
+ async function grant(f: {
+ deviceToken: string;
+ pin: string;
+ employee: { personalNo: string };
+ }) {
+ const challenge = await ctx.http
+ .post('/api/terminals/kiosk/challenge')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({})
+ .expect(201);
+ const identified = await ctx.http
+ .post('/api/terminals/kiosk/identify')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({
+ method: 'PIN',
+ challengePayload: challenge.body.payload,
+ personalNo: f.employee.personalNo,
+ pin: f.pin,
+ })
+ .expect(201);
+ return identified.body as {
+ grant: string;
+ action: 'clock-in' | 'clock-out';
+ requiresLocation: boolean;
+ };
+ }
+
+ function book(
+ deviceToken: string,
+ token: string,
+ action?: string,
+ extra: Record = {},
+ ) {
+ return ctx.http
+ .post('/api/terminals/kiosk/book')
+ .set('Authorization', `Bearer ${deviceToken}`)
+ .send({ grant: token, ...(action ? { action } : {}), ...extra });
+ }
+
+ it('requires an explicit action, rejects a status mismatch, and commits one minimal confirmation', async () => {
+ const f = await fixture();
+ const identified = await grant(f);
+ await book(f.deviceToken, identified.grant).expect(400);
+ const mismatch = await book(
+ f.deviceToken,
+ identified.grant,
+ 'clock-out',
+ ).expect(409);
+ expect(mismatch.body.code).toBe('TERMINAL_ACTION_MISMATCH');
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ const booked = await book(f.deviceToken, identified.grant, 'clock-in', {
+ employeeId: f.hr.id,
+ }).expect(201);
+ expect(booked.headers['cache-control']).toContain('no-store');
+ expect(Object.keys(booked.body).sort()).toEqual([
+ 'action',
+ 'bookedAt',
+ 'code',
+ 'receiptId',
+ ]);
+ expect(booked.body).toMatchObject({
+ code: 'TERMINAL_BOOKED',
+ action: 'clock-in',
+ });
+ const entry = await ctx.prisma.timeEntry.findFirstOrThrow();
+ expect(entry).toMatchObject({
+ employeeId: f.employee.id,
+ source: 'Terminal',
+ terminalId: f.terminalId,
+ clockOut: null,
+ latitude: null,
+ longitude: null,
+ });
+ expect(entry.clockIn.toISOString()).toBe(booked.body.bookedAt);
+ const stored = await ctx.prisma.terminalBookingGrant.findFirstOrThrow();
+ expect(stored).toMatchObject({ timeEntryId: entry.id, action: 'clock-in' });
+ expect(stored.consumedAt).not.toBeNull();
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(1);
+ expect(
+ await ctx.prisma.terminalAuditEvent.count({
+ where: {
+ timeEntryId: entry.id,
+ method: 'PIN',
+ action: 'clock-in',
+ result: 'SUCCESS',
+ },
+ }),
+ ).toBe(1);
+ expect(JSON.stringify(booked.body)).not.toMatch(
+ /employeeId|personalNo|latitude|note|clockIn|entry/,
+ );
+ });
+
+ it('deduplicates concurrent commits and returns the same receipt after a lost response and grant expiry', async () => {
+ const f = await fixture();
+ const identified = await grant(f);
+ const results = await Promise.all(
+ [1, 2].map(() => book(f.deviceToken, identified.grant, 'clock-in')),
+ );
+ expect(results.map((result) => result.status)).toEqual([201, 201]);
+ expect(results[0].body).toEqual(results[1].body);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(1);
+ await ctx.prisma.terminalBookingGrant.updateMany({
+ data: { expiresAt: new Date(Date.now() - 1_000) },
+ });
+ const retry = await book(
+ f.deviceToken,
+ identified.grant,
+ 'clock-in',
+ ).expect(201);
+ expect(retry.body).toEqual(results[0].body);
+ await book(f.deviceToken, identified.grant, 'clock-out').expect(409);
+ expect((await ctx.prisma.timeEntry.findFirstOrThrow()).clockOut).toBeNull();
+ await ctx.prisma.terminalBookingGrant.updateMany({
+ data: { consumedAt: new Date(Date.now() - 121_000) },
+ });
+ await book(f.deviceToken, identified.grant, 'clock-in').expect(409);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ });
+
+ it('serializes two terminals so simultaneous clock-ins cannot silently become a clock-out', async () => {
+ const f = await fixture();
+ const other = await terminal(f.hrToken);
+ const first = await grant(f);
+ const second = await grant({ ...f, ...other });
+ const results = await Promise.all([
+ book(f.deviceToken, first.grant, 'clock-in'),
+ book(other.deviceToken, second.grant, 'clock-in'),
+ ]);
+ expect(results.map((result) => result.status).sort()).toEqual([201, 409]);
+ expect(results.find((result) => result.status === 409)?.body.code).toBe(
+ 'TERMINAL_ACTION_MISMATCH',
+ );
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ expect(
+ await ctx.prisma.timeEntry.count({ where: { clockOut: null } }),
+ ).toBe(1);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(1);
+ const stopped = await grant(f);
+ expect(stopped.action).toBe('clock-out');
+ await book(f.deviceToken, stopped.grant, 'clock-out').expect(201);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ const entry = await ctx.prisma.timeEntry.findFirstOrThrow();
+ expect(entry.clockOut).not.toBeNull();
+ expect(entry.clockOutTerminalId).toBe(f.terminalId);
+ });
+
+ it.each([
+ 'reset',
+ 'disable',
+ 'unlock',
+ 'lock',
+ 'employee',
+ 'method',
+ 'device',
+ 'terminal',
+ ] as const)(
+ 'invalidates an outstanding grant after committed %s revocation',
+ async (change) => {
+ const f = await fixture();
+ const identified = await grant(f);
+ const auth = `Bearer ${f.hrToken}`;
+ const credentials = `/api/employees/${f.employee.id}/terminal-credentials/pin`;
+ switch (change) {
+ case 'reset':
+ await ctx.http
+ .post(`${credentials}/reset`)
+ .set('Authorization', auth)
+ .send({})
+ .expect(201);
+ break;
+ case 'disable':
+ await ctx.http
+ .delete(credentials)
+ .set('Authorization', auth)
+ .expect(200);
+ break;
+ case 'unlock':
+ await ctx.http
+ .post(`${credentials}/unlock`)
+ .set('Authorization', auth)
+ .send({})
+ .expect(201);
+ break;
+ case 'lock': {
+ const challenge = await ctx.http
+ .post('/api/terminals/kiosk/challenge')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({})
+ .expect(201);
+ const wrong = `${f.pin[0] === '0' ? '1' : '0'}${f.pin.slice(1)}`;
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ await ctx.http
+ .post('/api/terminals/kiosk/identify')
+ .set('Authorization', `Bearer ${f.deviceToken}`)
+ .send({
+ method: 'PIN',
+ challengePayload: challenge.body.payload,
+ personalNo: f.employee.personalNo,
+ pin: wrong,
+ })
+ .expect(401);
+ }
+ break;
+ }
+ case 'employee':
+ await ctx.http
+ .delete(`/api/employees/${f.employee.id}`)
+ .set('Authorization', auth)
+ .expect(200);
+ break;
+ case 'method':
+ await ctx.http
+ .put(`/api/terminals/${f.terminalId}`)
+ .set('Authorization', auth)
+ .send({ allowedAuthMethods: ['QR'] })
+ .expect(200);
+ break;
+ case 'terminal':
+ await ctx.http
+ .delete(`/api/terminals/${f.terminalId}`)
+ .set('Authorization', auth)
+ .expect(200);
+ break;
+ case 'device': {
+ const devices = await ctx.http
+ .get(`/api/terminals/${f.terminalId}`)
+ .set('Authorization', auth)
+ .expect(200);
+ const active = devices.body.devices.find(
+ (item: { revokedAt: string | null }) => item.revokedAt === null,
+ );
+ await ctx.http
+ .delete(`/api/terminals/${f.terminalId}/devices/${active.id}`)
+ .set('Authorization', auth)
+ .expect(200);
+ break;
+ }
+ }
+ const denied = await book(f.deviceToken, identified.grant, 'clock-in');
+ expect([401, 403, 409]).toContain(denied.status);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ expect(
+ (await ctx.prisma.terminalBookingGrant.findFirstOrThrow()).consumedAt,
+ ).toBeNull();
+ },
+ );
+
+ it('rejects foreign-device grants and expired unconsumed grants or challenges without partial writes', async () => {
+ const f = await fixture();
+ const other = await terminal(f.hrToken);
+ const identified = await grant(f);
+ await book(other.deviceToken, identified.grant, 'clock-in').expect(409);
+ await ctx.prisma.terminalBookingGrant.updateMany({
+ data: { expiresAt: new Date(Date.now() - 1_000) },
+ });
+ const expired = await book(
+ f.deviceToken,
+ identified.grant,
+ 'clock-in',
+ ).expect(409);
+ expect(expired.body.code).toBe('TERMINAL_GRANT_EXPIRED');
+ const current = await grant(f);
+ const latest = await ctx.prisma.terminalBookingGrant.findFirstOrThrow({
+ orderBy: { createdAt: 'desc' },
+ });
+ await ctx.prisma.terminalChallenge.update({
+ where: { id: latest.challengeId },
+ data: { expiresAt: new Date(Date.now() - 1_000) },
+ });
+ await book(f.deviceToken, current.grant, 'clock-in').expect(409);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ expect(
+ await ctx.prisma.terminalBookingGrant.count({
+ where: { consumedAt: { not: null } },
+ }),
+ ).toBe(0);
+ });
+
+ it('rechecks the credential after a real PIN reset commits ahead of a waiting booking', async () => {
+ const f = await fixture();
+ const identified = await grant(f);
+ let acquired!: () => void;
+ let release!: () => void;
+ const ready = new Promise((resolve) => {
+ acquired = resolve;
+ });
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const blocker = ctx.prisma.$transaction(async (tx) => {
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ acquired();
+ await gate;
+ });
+ await ready;
+ const reset = ctx.http
+ .post(`/api/employees/${f.employee.id}/terminal-credentials/pin/reset`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .send({})
+ .then((result) => result);
+ async function waitFor(count: number) {
+ let waiting = 0;
+ for (let attempt = 0; attempt < 100 && waiting < count; attempt += 1) {
+ const locks = await ctx.prisma.$queryRaw>`
+ SELECT COUNT(*)::int AS count FROM pg_locks
+ WHERE locktype = 'advisory' AND objid = 7261500 AND NOT granted
+ AND database = (SELECT oid FROM pg_database WHERE datname = current_database())`;
+ waiting = locks[0]?.count ?? 0;
+ if (waiting < count)
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ return waiting;
+ }
+ let firstWaiting = 0;
+ let bothWaiting = 0;
+ let pending: Promise<{ status: number }> | undefined;
+ try {
+ firstWaiting = await waitFor(1);
+ pending = book(f.deviceToken, identified.grant, 'clock-in').then(
+ (result) => result,
+ );
+ bothWaiting = await waitFor(2);
+ } finally {
+ release();
+ await blocker;
+ }
+ expect(firstWaiting).toBe(1);
+ expect(bothWaiting).toBe(2);
+ const replacement = await reset;
+ expect(replacement.status).toBe(201);
+ const denied = await pending;
+ expect(denied?.status).toBe(409);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ expect(
+ (await ctx.prisma.terminalBookingGrant.findFirstOrThrow()).consumedAt,
+ ).toBeNull();
+ });
+
+ it('caps an identify grant at local midnight on a DST day', async () => {
+ const f = await fixture();
+ jest.useFakeTimers({
+ doNotFake: ['nextTick', 'setImmediate', 'setInterval', 'setTimeout'],
+ });
+ jest.setSystemTime(new Date('2026-03-29T21:59:45.000Z'));
+ try {
+ const identified = await grant(f);
+ const stored = await ctx.prisma.terminalBookingGrant.findFirstOrThrow();
+ expect(stored.expiresAt.toISOString()).toBe('2026-03-29T22:00:00.000Z');
+ jest.setSystemTime(new Date('2026-03-29T22:00:00.001Z'));
+ await book(f.deviceToken, identified.grant, 'clock-in').expect(409);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('retains an unconsumed grant after missing, stale or outside-geofence positions', async () => {
+ const f = await fixture(true);
+ const identified = await grant(f);
+ expect(identified.requiresLocation).toBe(true);
+ await book(f.deviceToken, identified.grant, 'clock-in').expect(400);
+ const position = {
+ latitude: 49.7913,
+ longitude: 9.9534,
+ accuracyMeters: 8,
+ positionTimestamp: new Date().toISOString(),
+ };
+ await book(f.deviceToken, identified.grant, 'clock-in', {
+ ...position,
+ positionTimestamp: new Date(Date.now() - 5 * 60_000).toISOString(),
+ }).expect(403);
+ await book(f.deviceToken, identified.grant, 'clock-in', {
+ ...position,
+ latitude: 50.1,
+ }).expect(403);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(
+ (await ctx.prisma.terminalBookingGrant.findFirstOrThrow()).consumedAt,
+ ).toBeNull();
+ await book(f.deviceToken, identified.grant, 'clock-in', position).expect(
+ 201,
+ );
+ const entry = await ctx.prisma.timeEntry.findFirstOrThrow();
+ expect(Number(entry.latitude)).toBeCloseTo(position.latitude);
+ expect(Number(entry.terminalDistanceMeters)).toBe(0);
+ const audit = await ctx.prisma.terminalAuditEvent.findMany();
+ for (const event of audit)
+ expect(event.canonicalPayload).not.toMatch(
+ /latitude|longitude|accuracyMeters|positionTimestamp/,
+ );
+ });
+
+ it('checks Team mode inside a booking transaction queued behind a committed Solo switch', async () => {
+ const f = await fixture(false, true);
+ const identified = await grant(f);
+ await ctx.prisma.installationSettings.upsert({
+ where: { id: 1 },
+ create: { id: 1, mode: 'Team' },
+ update: {},
+ });
+ let acquired!: () => void;
+ let release!: () => void;
+ const ready = new Promise((resolve) => {
+ acquired = resolve;
+ });
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const switchMode = ctx.prisma.$transaction(async (tx) => {
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ acquired();
+ await gate;
+ await tx.installationSettings.update({
+ where: { id: 1 },
+ data: {
+ mode: 'Solo',
+ ownerEmployeeId: f.hr.id,
+ revision: { increment: 1 },
+ },
+ });
+ });
+ await ready;
+ const pending = book(f.deviceToken, identified.grant, 'clock-in').then(
+ (result) => result,
+ );
+ let waiting = 0;
+ try {
+ for (let attempt = 0; attempt < 100 && waiting === 0; attempt += 1) {
+ const locks = await ctx.prisma.$queryRaw>`
+ SELECT COUNT(*)::int AS count FROM pg_locks
+ WHERE locktype = 'advisory' AND objid = 7261500 AND NOT granted
+ AND database = (SELECT oid FROM pg_database WHERE datname = current_database())`;
+ waiting = locks[0]?.count ?? 0;
+ if (!waiting) await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ } finally {
+ release();
+ await switchMode;
+ }
+ const denied = await pending;
+ expect(waiting).toBe(1);
+ expect(denied.status).toBe(403);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(
+ (await ctx.prisma.terminalBookingGrant.findFirstOrThrow()).consumedAt,
+ ).toBeNull();
+ });
+
+ it('keeps redacted, chained audit events after terminal deletion and rejects in-place mutation', async () => {
+ const f = await fixture();
+ const identified = await grant(f);
+ await book(f.deviceToken, identified.grant, 'clock-in').expect(201);
+ const oldEvents = await ctx.prisma.terminalAuditEvent.findMany({
+ orderBy: { sequence: 'asc' },
+ });
+ const oldIds = oldEvents.map((event) => event.id);
+ await ctx.http
+ .delete(`/api/terminals/${f.terminalId}/permanent`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .expect(204);
+ expect(
+ await ctx.prisma.terminalAuditEvent.count({
+ where: { id: { in: oldIds } },
+ }),
+ ).toBe(oldIds.length);
+ const events = await ctx.prisma.terminalAuditEvent.findMany({
+ orderBy: { sequence: 'asc' },
+ });
+ let previous = '0'.repeat(64);
+ for (const event of events) {
+ expect(event.previousHash).toBe(previous);
+ expect(event.hash).toBe(
+ createHmac('sha256', process.env.TERMINAL_AUDIT_KEY ?? '')
+ .update(`${previous}\n${event.canonicalPayload}`)
+ .digest('hex'),
+ );
+ expect(event.canonicalPayload).not.toContain(f.pin);
+ expect(event.canonicalPayload).not.toContain(identified.grant);
+ expect(event.canonicalPayload).not.toContain(f.deviceToken);
+ expect(event.canonicalPayload).not.toMatch(
+ /pinHash|pepper|password|latitude|longitude/,
+ );
+ previous = event.hash;
+ }
+ const audit = await ctx.http
+ .get(`/api/terminals/audit-events?terminalId=${f.terminalId}&limit=2`)
+ .set('Authorization', `Bearer ${f.hrToken}`)
+ .expect(200);
+ expect(audit.headers['cache-control']).toContain('no-store');
+ expect(audit.body.items).toHaveLength(2);
+ expect(audit.body.nextCursor).toMatch(/^\d+$/);
+ expect(JSON.stringify(audit.body)).not.toMatch(
+ /canonicalPayload|previousHash|keyVersion|pinHash/,
+ );
+ const event = events[0];
+ await expect(
+ ctx.prisma.terminalAuditEvent.update({
+ where: { sequence: event.sequence },
+ data: { result: 'TAMPERED' },
+ }),
+ ).rejects.toThrow();
+ await expect(
+ ctx.prisma.terminalAuditEvent.delete({
+ where: { sequence: event.sequence },
+ }),
+ ).rejects.toThrow();
+ expect(
+ await ctx.prisma.terminalAuditEvent.findUnique({
+ where: { sequence: event.sequence },
+ }),
+ ).toMatchObject({ result: event.result, hash: event.hash });
+ });
+
+ it('rolls back the booking, redemption and grant consumption if the success audit cannot be inserted', async () => {
+ const f = await fixture();
+ const identified = await grant(f);
+ // This transient fault exists only in the connection checked by ctx.reset().
+ // It rejects a success audit while allowing the durable rejection audit.
+ await ctx.prisma
+ .$executeRawUnsafe(`CREATE FUNCTION e2e_reject_terminal_booking_audit()
+ RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
+ IF NEW."timeEntryId" IS NOT NULL THEN RAISE EXCEPTION 'Synthetic E2E audit write failure'; END IF;
+ RETURN NEW; END $$`);
+ try {
+ await ctx.prisma
+ .$executeRawUnsafe(`CREATE TRIGGER e2e_terminal_booking_audit_failure
+ BEFORE INSERT ON "TerminalAuditEvent" FOR EACH ROW
+ EXECUTE FUNCTION e2e_reject_terminal_booking_audit()`);
+ const result = await book(f.deviceToken, identified.grant, 'clock-in');
+ expect(result.status).toBeGreaterThanOrEqual(500);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ expect(await ctx.prisma.terminalChallengeRedemption.count()).toBe(0);
+ expect(
+ (await ctx.prisma.terminalBookingGrant.findFirstOrThrow()).consumedAt,
+ ).toBeNull();
+ } finally {
+ await ctx.prisma.$executeRawUnsafe(
+ 'DROP TRIGGER IF EXISTS e2e_terminal_booking_audit_failure ON "TerminalAuditEvent"',
+ );
+ await ctx.prisma.$executeRawUnsafe(
+ 'DROP FUNCTION IF EXISTS e2e_reject_terminal_booking_audit()',
+ );
+ }
+ await book(f.deviceToken, identified.grant, 'clock-in').expect(201);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ });
+});
diff --git a/apps/api-e2e/src/api/terminal-credentials.e2e.spec.ts b/apps/api-e2e/src/api/terminal-credentials.e2e.spec.ts
new file mode 100644
index 0000000..1e72619
--- /dev/null
+++ b/apps/api-e2e/src/api/terminal-credentials.e2e.spec.ts
@@ -0,0 +1,305 @@
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
+
+describe('Terminal PIN credentials and configuration', () => {
+ let ctx: TestContext;
+
+ beforeAll(async () => {
+ process.env.TERMINAL_PIN_PEPPER ??=
+ 'synthetic-e2e-pin-pepper-at-least-32-characters';
+ process.env.TERMINAL_AUDIT_KEY ??=
+ 'synthetic-e2e-audit-key-at-least-32-characters';
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ });
+
+ async function actors() {
+ const hr = await seedEmployee(ctx.prisma, {
+ personalNo: 'PIN-HR',
+ firstName: 'Hannah',
+ lastName: 'Administrator',
+ email: 'hr@pin-credentials.test',
+ role: 'HRAdmin',
+ });
+ const manager = await seedEmployee(ctx.prisma, {
+ personalNo: 'PIN-MANAGER',
+ firstName: 'Mark',
+ lastName: 'Manager',
+ email: 'manager@pin-credentials.test',
+ role: 'Manager',
+ });
+ const employee = await seedEmployee(ctx.prisma, {
+ personalNo: '000007',
+ firstName: 'Erika',
+ lastName: 'Employee',
+ email: 'employee@pin-credentials.test',
+ });
+ return {
+ hr,
+ manager,
+ employee,
+ hrToken: await login(ctx.http, hr.email),
+ managerToken: await login(ctx.http, manager.email),
+ employeeToken: await login(ctx.http, employee.email),
+ };
+ }
+
+ function credentials(employeeId: string) {
+ return `/api/employees/${employeeId}/terminal-credentials`;
+ }
+
+ async function createTerminal(hrToken: string, methods?: string[]) {
+ return ctx.http
+ .post('/api/terminals')
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({
+ name: 'Synthetic PIN test terminal',
+ displayText: 'Synthetic terminal',
+ locationLabel: 'Synthetic test reception',
+ timeZone: 'Europe/Berlin',
+ enforceGeofence: false,
+ isActive: true,
+ ...(methods ? { allowedAuthMethods: methods } : {}),
+ })
+ .expect(201);
+ }
+
+ it('defaults to QR only and rejects empty, unknown and RFID method selections', async () => {
+ const { hrToken } = await actors();
+ const created = await createTerminal(hrToken);
+ expect(created.body.allowedAuthMethods).toEqual(['QR']);
+ for (const methods of [[], ['RFID'], ['UNKNOWN'], ['PIN', 'PIN']]) {
+ await ctx.http
+ .put(`/api/terminals/${created.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ allowedAuthMethods: methods })
+ .expect(400);
+ }
+ const before = await ctx.prisma.terminal.findUniqueOrThrow({
+ where: { id: created.body.id },
+ });
+ const updated = await ctx.http
+ .put(`/api/terminals/${created.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ allowedAuthMethods: ['QR', 'PIN'] })
+ .expect(200);
+ expect(updated.body.allowedAuthMethods).toEqual(['QR', 'PIN']);
+ const after = await ctx.prisma.terminal.findUniqueOrThrow({
+ where: { id: created.body.id },
+ });
+ expect(after.authConfigVersion).toBeGreaterThan(before.authConfigVersion);
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ });
+
+ it('allows only HR to read, reset, disable and unlock another employee PIN', async () => {
+ const { employee, hrToken, managerToken, employeeToken } = await actors();
+ for (const token of [managerToken, employeeToken]) {
+ const auth = `Bearer ${token}`;
+ await ctx.http
+ .get(credentials(employee.id))
+ .set('Authorization', auth)
+ .expect(403);
+ await ctx.http
+ .post(`${credentials(employee.id)}/pin/reset`)
+ .set('Authorization', auth)
+ .send({})
+ .expect(403);
+ await ctx.http
+ .delete(`${credentials(employee.id)}/pin`)
+ .set('Authorization', auth)
+ .expect(403);
+ await ctx.http
+ .post(`${credentials(employee.id)}/pin/unlock`)
+ .set('Authorization', auth)
+ .send({})
+ .expect(403);
+ }
+ await ctx.http
+ .post(`${credentials(employee.id)}/pin/reset`)
+ .send({})
+ .expect(401);
+ const before = await ctx.http
+ .get(credentials(employee.id))
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(before.body).toMatchObject({
+ method: 'PIN',
+ isConfigured: false,
+ enabled: false,
+ });
+ expect(await ctx.prisma.terminalCredential.count()).toBe(0);
+ await ctx.http
+ .post(`${credentials(employee.id)}/rfid`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ cardValue: '00001234' })
+ .expect(404);
+ });
+
+ it('disables QR on the server and omits its display challenge on a PIN-only terminal', async () => {
+ const { hrToken, employeeToken } = await actors();
+ const created = await createTerminal(hrToken, ['QR', 'PIN']);
+ const pairing = await ctx.http
+ .post(`/api/terminals/${created.body.id}/pairing`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(201);
+ const paired = await ctx.http
+ .post('/api/terminals/pair')
+ .send({ pairingCode: pairing.body.pairingCode })
+ .expect(201);
+ const display = await ctx.http
+ .get('/api/terminals/kiosk')
+ .set('Authorization', `Bearer ${paired.body.deviceToken}`)
+ .expect(200);
+ await ctx.http
+ .put(`/api/terminals/${created.body.id}`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({ allowedAuthMethods: ['PIN'] })
+ .expect(200);
+ const current = await ctx.http
+ .get('/api/terminals/kiosk')
+ .set('Authorization', `Bearer ${paired.body.deviceToken}`)
+ .expect(200);
+ expect(current.body.challenge).toBeNull();
+ expect(current.body.terminal.allowedAuthMethods).toEqual(['PIN']);
+ const disabled = await ctx.http
+ .post('/api/terminals/scan')
+ .set('Authorization', `Bearer ${employeeToken}`)
+ .send({ qrPayload: display.body.challenge.payload })
+ .expect(403);
+ expect(disabled.body.code).toBe('TERMINAL_METHOD_DISABLED');
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ });
+
+ it('shows a random eight-digit PIN once and stores only a salted Argon2id hash', async () => {
+ const { employee, hrToken, employeeToken } = await actors();
+ const reset = await ctx.http
+ .post(`${credentials(employee.id)}/pin/reset`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({})
+ .expect(201);
+ expect(reset.headers['cache-control']).toContain('no-store');
+ expect(reset.body.pin).toMatch(/^\d{8}$/);
+ expect(reset.body.credential).toMatchObject({
+ method: 'PIN',
+ isConfigured: true,
+ enabled: true,
+ });
+ const stored = await ctx.prisma.terminalCredential.findUniqueOrThrow({
+ where: { employeeId: employee.id },
+ });
+ expect(stored.pinHash).toMatch(/^\$argon2id\$v=19\$/);
+ expect(stored.pinHash).not.toContain(reset.body.pin);
+ expect(stored.pepperVersion).toBeTruthy();
+ const status = await ctx.http
+ .get(credentials(employee.id))
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ const employeeView = await ctx.http
+ .get(`/api/employees/${employee.id}`)
+ .set('Authorization', `Bearer ${employeeToken}`)
+ .expect(200);
+ const employeeList = await ctx.http
+ .get('/api/employees')
+ .set('Authorization', `Bearer ${employeeToken}`)
+ .expect(200);
+ for (const body of [status.body, employeeView.body, employeeList.body]) {
+ const serialized = JSON.stringify(body);
+ expect(serialized).not.toContain(reset.body.pin);
+ expect(serialized).not.toContain(stored.pinHash);
+ expect(serialized).not.toMatch(
+ /pinHash|pepperVersion|terminalCredentials?/,
+ );
+ }
+ const replaced = await ctx.http
+ .post(`${credentials(employee.id)}/pin/reset`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({})
+ .expect(201);
+ const replacedStored =
+ await ctx.prisma.terminalCredential.findUniqueOrThrow({
+ where: { employeeId: employee.id },
+ });
+ expect(replacedStored.version).toBeGreaterThan(stored.version);
+ expect(replacedStored.pinHash).not.toBe(stored.pinHash);
+ expect(await ctx.prisma.terminalCredential.count()).toBe(1);
+ expect(replaced.body.pin).toMatch(/^\d{8}$/);
+ });
+
+ it('does not replace a web password when a PIN is reset, disabled or unlocked', async () => {
+ const { employee, hrToken } = await actors();
+ const reset = await ctx.http
+ .post(`${credentials(employee.id)}/pin/reset`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({})
+ .expect(201);
+ const disabled = await ctx.http
+ .delete(`${credentials(employee.id)}/pin`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(disabled.body.enabled).toBe(false);
+ await ctx.http
+ .post(`${credentials(employee.id)}/pin/unlock`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send({})
+ .expect(404);
+ const status = await ctx.http
+ .get(credentials(employee.id))
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(status.body.enabled).toBe(false);
+ await login(ctx.http, employee.email);
+ await ctx.http
+ .post('/api/auth/login')
+ .send({ email: employee.email, password: reset.body.pin })
+ .expect(401);
+ const stored = await ctx.prisma.employee.findUniqueOrThrow({
+ where: { id: employee.id },
+ });
+ expect(stored.passwordHash).toBe(employee.passwordHash);
+ });
+
+ it('rejects credential and terminal administration in Solo mode', async () => {
+ const hr = await seedEmployee(ctx.prisma, {
+ personalNo: 'SOLO-PIN-HR',
+ firstName: 'Solo',
+ lastName: 'Owner',
+ email: 'solo@pin-credentials.test',
+ role: 'HRAdmin',
+ });
+ const token = await login(ctx.http, hr.email);
+ await ctx.http
+ .post('/api/installation/mode')
+ .set('Authorization', `Bearer ${token}`)
+ .send({ mode: 'Solo', revision: 0 })
+ .expect(201);
+ await ctx.http
+ .post(`${credentials(hr.id)}/pin/reset`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({})
+ .expect(403);
+ await ctx.http
+ .get(credentials(hr.id))
+ .set('Authorization', `Bearer ${token}`)
+ .expect(403);
+ await ctx.http
+ .post('/api/terminals')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ name: 'Forbidden Solo terminal',
+ enforceGeofence: false,
+ allowedAuthMethods: ['PIN'],
+ })
+ .expect(403);
+ expect(await ctx.prisma.terminalCredential.count()).toBe(0);
+ expect(await ctx.prisma.terminal.count()).toBe(0);
+ });
+});
diff --git a/apps/api-e2e/src/support/test-app.ts b/apps/api-e2e/src/support/test-app.ts
index 6c6eb4a..f7a5bb0 100644
--- a/apps/api-e2e/src/support/test-app.ts
+++ b/apps/api-e2e/src/support/test-app.ts
@@ -24,6 +24,10 @@ const RESET_SQL = `
"PersonalDay",
"TimeEntryAudit",
"Customer",
+ "TerminalAuditEvent",
+ "TerminalBookingGrant",
+ "TerminalAuthAttemptState",
+ "TerminalCredential",
"TerminalChallengeRedemption",
"TerminalChallenge",
"TerminalDevice",
diff --git a/apps/api-e2e/src/support/test-setup.ts b/apps/api-e2e/src/support/test-setup.ts
index 290b263..cf57206 100644
--- a/apps/api-e2e/src/support/test-setup.ts
+++ b/apps/api-e2e/src/support/test-setup.ts
@@ -10,6 +10,12 @@ process.env.DATABASE_URL = assertE2eTarget().databaseUrl;
// Core-time and off-hours assertions use these explicit wall-clock times.
process.env.TZ = 'Europe/Berlin';
process.env.JWT_SECRET = process.env.JWT_SECRET ?? 'e2e-test-secret-change-me';
+process.env.TERMINAL_PIN_PEPPER =
+ process.env.TERMINAL_PIN_PEPPER ??
+ 'synthetic-e2e-pin-pepper-at-least-32-characters';
+process.env.TERMINAL_AUDIT_KEY =
+ process.env.TERMINAL_AUDIT_KEY ??
+ 'synthetic-e2e-audit-key-at-least-32-characters';
process.env.ERP_API_KEY = 'e2e-erp-key'; // force — the e2e suite hard-codes this
process.env.CRON_API_KEY = 'e2e-cron-key'; // force — the e2e suite hard-codes this
process.env.API_CORS_ORIGINS =
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 37bcbc7..d4e214e 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -2392,6 +2392,306 @@
"tags": ["time-entries"]
}
},
+ "/api/terminals/kiosk/challenge": {
+ "post": {
+ "operationId": "TerminalAuthController_challenge",
+ "parameters": [],
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IdentifyChallengeDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-auth"]
+ }
+ },
+ "/api/terminals/kiosk/identify": {
+ "post": {
+ "operationId": "TerminalAuthController_identify",
+ "parameters": [],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IdentifyTerminalDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IdentifyTerminalResultDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-auth"]
+ }
+ },
+ "/api/terminals/kiosk/book": {
+ "post": {
+ "operationId": "TerminalAuthController_book",
+ "parameters": [],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BookTerminalDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BookTerminalResultDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-auth"]
+ }
+ },
+ "/api/terminals/audit-events": {
+ "get": {
+ "operationId": "TerminalAuthController_events",
+ "parameters": [
+ {
+ "name": "limit",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "minimum": 1,
+ "maximum": 100,
+ "type": "number"
+ }
+ },
+ {
+ "name": "cursor",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "terminalId",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "employeeId",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "method",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "enum": ["QR", "PIN"]
+ }
+ },
+ {
+ "name": "result",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TerminalAuditPageDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-auth"]
+ }
+ },
+ "/api/employees/{id}/terminal-credentials": {
+ "get": {
+ "operationId": "TerminalCredentialsController_get",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TerminalCredentialStatusDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-credentials"]
+ }
+ },
+ "/api/employees/{id}/terminal-credentials/pin/reset": {
+ "post": {
+ "operationId": "TerminalCredentialsController_reset",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ResetTerminalPinResultDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-credentials"]
+ }
+ },
+ "/api/employees/{id}/terminal-credentials/pin": {
+ "delete": {
+ "operationId": "TerminalCredentialsController_revoke",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TerminalCredentialStatusDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-credentials"]
+ }
+ },
+ "/api/employees/{id}/terminal-credentials/pin/unlock": {
+ "post": {
+ "operationId": "TerminalCredentialsController_unlock",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TerminalCredentialStatusDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["terminal-credentials"]
+ }
+ },
"/api/terminals/support-prompt": {
"get": {
"operationId": "TerminalsController_supportPrompt",
@@ -3869,7 +4169,7 @@
"info": {
"title": "OpenClockwork API",
"description": "Self-hostable working-time tracker — REST + WebSocket surface.",
- "version": "2.0.1",
+ "version": "2.1.0",
"contact": {}
},
"tags": [],
@@ -4690,7 +4990,7 @@
},
"version": {
"type": "string",
- "example": "2.0.1"
+ "example": "2.1.0"
},
"utcTimestamp": {
"type": "string",
@@ -6501,6 +6801,256 @@
},
"required": ["at"]
},
+ "IdentifyChallengeDto": {
+ "type": "object",
+ "properties": {
+ "payload": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": ["payload", "expiresAt"]
+ },
+ "IdentifyTerminalDto": {
+ "type": "object",
+ "properties": {
+ "method": {
+ "type": "string",
+ "enum": ["PIN"]
+ },
+ "challengePayload": {
+ "type": "string"
+ },
+ "personalNo": {
+ "type": "string",
+ "description": "Exact personal number, preserving leading zeros."
+ },
+ "pin": {
+ "type": "string",
+ "minLength": 8,
+ "maxLength": 8,
+ "writeOnly": true
+ }
+ },
+ "required": ["method", "challengePayload", "personalNo", "pin"]
+ },
+ "IdentifyTerminalResultDto": {
+ "type": "object",
+ "properties": {
+ "grant": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string",
+ "enum": ["clock-in", "clock-out"]
+ },
+ "requiresLocation": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "grant",
+ "expiresAt",
+ "displayName",
+ "action",
+ "requiresLocation"
+ ]
+ },
+ "BookTerminalDto": {
+ "type": "object",
+ "properties": {
+ "grant": {
+ "type": "string"
+ },
+ "action": {
+ "type": "string",
+ "enum": ["clock-in", "clock-out"]
+ },
+ "latitude": {
+ "type": "number",
+ "minimum": -90,
+ "maximum": 90
+ },
+ "longitude": {
+ "type": "number",
+ "minimum": -180,
+ "maximum": 180
+ },
+ "accuracyMeters": {
+ "type": "number",
+ "minimum": 0
+ },
+ "positionTimestamp": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": ["grant", "action"]
+ },
+ "BookTerminalResultDto": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string",
+ "enum": ["TERMINAL_BOOKED"]
+ },
+ "action": {
+ "type": "string",
+ "enum": ["clock-in", "clock-out"]
+ },
+ "bookedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "receiptId": {
+ "type": "string",
+ "format": "uuid"
+ }
+ },
+ "required": ["code", "action", "bookedAt", "receiptId"]
+ },
+ "TerminalAuditEventDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "sequence": {
+ "type": "string"
+ },
+ "occurredAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "event": {
+ "type": "string"
+ },
+ "result": {
+ "type": "string"
+ },
+ "method": {
+ "type": "string",
+ "nullable": true
+ },
+ "action": {
+ "type": "string",
+ "nullable": true
+ },
+ "employeeId": {
+ "type": "string",
+ "nullable": true
+ },
+ "actorId": {
+ "type": "string",
+ "nullable": true
+ },
+ "terminalId": {
+ "type": "string",
+ "nullable": true
+ },
+ "deviceId": {
+ "type": "string",
+ "nullable": true
+ },
+ "terminalName": {
+ "type": "string",
+ "nullable": true
+ },
+ "locationLabel": {
+ "type": "string",
+ "nullable": true
+ },
+ "timeEntryId": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "required": [
+ "id",
+ "sequence",
+ "occurredAt",
+ "event",
+ "result",
+ "method",
+ "action",
+ "employeeId",
+ "actorId",
+ "terminalId",
+ "deviceId",
+ "terminalName",
+ "locationLabel",
+ "timeEntryId"
+ ]
+ },
+ "TerminalAuditPageDto": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TerminalAuditEventDto"
+ }
+ },
+ "nextCursor": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "required": ["items", "nextCursor"]
+ },
+ "TerminalCredentialStatusDto": {
+ "type": "object",
+ "properties": {
+ "method": {
+ "type": "string",
+ "enum": ["PIN"]
+ },
+ "isConfigured": {
+ "type": "boolean"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "lockedUntil": {
+ "type": "string",
+ "nullable": true
+ },
+ "updatedAt": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "required": [
+ "method",
+ "isConfigured",
+ "enabled",
+ "lockedUntil",
+ "updatedAt"
+ ]
+ },
+ "ResetTerminalPinResultDto": {
+ "type": "object",
+ "properties": {
+ "pin": {
+ "type": "string",
+ "description": "One-time secret. Never cache or log this response."
+ },
+ "credential": {
+ "$ref": "#/components/schemas/TerminalCredentialStatusDto"
+ }
+ },
+ "required": ["pin", "credential"]
+ },
"SupportPromptDto": {
"type": "object",
"properties": {
@@ -6528,6 +7078,16 @@
"KioskTerminalDto": {
"type": "object",
"properties": {
+ "allowedAuthMethods": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["QR", "PIN"]
+ }
+ },
+ "authConfigVersion": {
+ "type": "number"
+ },
"id": {
"type": "string",
"format": "uuid"
@@ -6549,7 +7109,15 @@
"type": "string"
}
},
- "required": ["id", "name", "displayText", "locationLabel", "timeZone"]
+ "required": [
+ "allowedAuthMethods",
+ "authConfigVersion",
+ "id",
+ "name",
+ "displayText",
+ "locationLabel",
+ "timeZone"
+ ]
},
"PairedTerminalDto": {
"type": "object",
@@ -6590,14 +7158,36 @@
"$ref": "#/components/schemas/KioskTerminalDto"
},
"challenge": {
- "$ref": "#/components/schemas/TerminalChallengeDto"
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/TerminalChallengeDto"
+ }
+ ]
+ },
+ "configRefreshAfterSeconds": {
+ "type": "number"
+ },
+ "requiresLocation": {
+ "type": "boolean"
+ },
+ "pinAvailable": {
+ "type": "boolean"
},
"serverTime": {
"type": "string",
"format": "date-time"
}
},
- "required": ["terminal", "challenge", "serverTime"]
+ "required": [
+ "terminal",
+ "challenge",
+ "configRefreshAfterSeconds",
+ "requiresLocation",
+ "pinAvailable",
+ "serverTime"
+ ]
},
"ScanTerminalDto": {
"type": "object",
@@ -6680,6 +7270,16 @@
"TerminalDto": {
"type": "object",
"properties": {
+ "allowedAuthMethods": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["QR", "PIN"]
+ }
+ },
+ "authConfigVersion": {
+ "type": "number"
+ },
"id": {
"type": "string",
"format": "uuid"
@@ -6754,6 +7354,8 @@
}
},
"required": [
+ "allowedAuthMethods",
+ "authConfigVersion",
"id",
"name",
"displayText",
@@ -6771,6 +7373,14 @@
"CreateTerminalDto": {
"type": "object",
"properties": {
+ "allowedAuthMethods": {
+ "type": "array",
+ "default": ["QR"],
+ "items": {
+ "type": "string",
+ "enum": ["QR", "PIN"]
+ }
+ },
"name": {
"type": "string",
"example": "Haupteingang"
@@ -6838,6 +7448,14 @@
"UpdateTerminalDto": {
"type": "object",
"properties": {
+ "allowedAuthMethods": {
+ "type": "array",
+ "default": ["QR"],
+ "items": {
+ "type": "string",
+ "enum": ["QR", "PIN"]
+ }
+ },
"name": {
"type": "string",
"example": "Haupteingang"
diff --git a/apps/api/package.json b/apps/api/package.json
index 08b752f..5e7bd18 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -90,6 +90,7 @@
"@nestjs/swagger": "^11.4.5",
"@nestjs/websockets": "^11.1.27",
"@prisma/client": "^6.19.3",
+ "argon2": "0.44.0",
"bcrypt": "*",
"class-transformer": "*",
"class-validator": "*",
diff --git a/apps/api/src/app/employees/employees.controller.ts b/apps/api/src/app/employees/employees.controller.ts
index 4606d06..830642c 100644
--- a/apps/api/src/app/employees/employees.controller.ts
+++ b/apps/api/src/app/employees/employees.controller.ts
@@ -73,8 +73,9 @@ export class EmployeesController {
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEmployeeDto,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.employees.update(id, dto);
+ return this.employees.update(id, dto, user.id);
}
@Post(':id/password')
@@ -93,8 +94,9 @@ export class EmployeesController {
@Roles('HRAdmin')
deactivate(
@Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.employees.deactivate(id);
+ return this.employees.deactivate(id, user.id);
}
@Post(':id/reactivate')
@@ -102,7 +104,8 @@ export class EmployeesController {
@Roles('HRAdmin')
reactivate(
@Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.employees.reactivate(id);
+ return this.employees.reactivate(id, user.id);
}
}
diff --git a/apps/api/src/app/employees/employees.service.ts b/apps/api/src/app/employees/employees.service.ts
index fa1752e..cf5e6b1 100644
--- a/apps/api/src/app/employees/employees.service.ts
+++ b/apps/api/src/app/employees/employees.service.ts
@@ -9,6 +9,7 @@ import type { Employee, Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import type { JwtUser } from '../auth/jwt.strategy';
import { PrismaService } from '../prisma/prisma.service';
+import { TerminalAuditService } from '../terminals/terminal-audit.service';
import {
toEmployeeDto,
type CreateEmployeeDto,
@@ -21,7 +22,10 @@ const BCRYPT_ROUNDS = 10;
@Injectable()
export class EmployeesService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly terminalAudit: TerminalAuditService,
+ ) {}
async directory(): Promise {
return this.prisma.employee.findMany({
@@ -139,7 +143,11 @@ export class EmployeesService {
}
}
- async update(id: string, dto: UpdateEmployeeDto): Promise {
+ async update(
+ id: string,
+ dto: UpdateEmployeeDto,
+ actorId?: string,
+ ): Promise {
const current = await this.getById(id);
if (dto.managerId !== undefined && dto.managerId !== null) {
if (dto.managerId === id)
@@ -218,11 +226,34 @@ export class EmployeesService {
'Cannot remove the last active administrator',
);
}
- return tx.employee.update({
+ const employee = await tx.employee.update({
where: { id },
data,
include: { workSchedule: true },
});
+ if (
+ dto.isActive !== undefined &&
+ dto.isActive !== lockedCurrent.isActive
+ ) {
+ // Credential versions ensure deactivation followed by reactivation cannot resurrect a grant.
+ await tx.terminalCredential.updateMany({
+ where: { employeeId: id },
+ data: { version: { increment: 1 } },
+ });
+ await this.terminalAudit.append(
+ tx,
+ {
+ event: dto.isActive
+ ? 'EmployeeReactivated'
+ : 'EmployeeDeactivated',
+ result: 'SUCCESS',
+ employeeId: id,
+ actorId,
+ },
+ false,
+ );
+ }
+ return employee;
});
return toEmployeeDto(updated);
} catch (err) {
@@ -239,12 +270,12 @@ export class EmployeesService {
});
}
- async deactivate(id: string): Promise {
- return this.update(id, { isActive: false });
+ async deactivate(id: string, actorId?: string): Promise {
+ return this.update(id, { isActive: false }, actorId);
}
- async reactivate(id: string): Promise {
- return this.update(id, { isActive: true });
+ async reactivate(id: string, actorId?: string): Promise {
+ return this.update(id, { isActive: true }, actorId);
}
private async assertManagerEligible(managerId: string): Promise {
diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts
index a2a0fde..d0da296 100644
--- a/apps/api/src/app/health/health.controller.ts
+++ b/apps/api/src/app/health/health.controller.ts
@@ -9,7 +9,7 @@ export class HealthResponseDto {
@ApiProperty({ example: 'openclockwork-api' })
service!: string;
- @ApiProperty({ example: '2.0.1' })
+ @ApiProperty({ example: '2.1.0' })
version!: string;
@ApiProperty({ format: 'date-time' })
diff --git a/apps/api/src/app/terminals/terminal-audit.service.ts b/apps/api/src/app/terminals/terminal-audit.service.ts
new file mode 100644
index 0000000..986608c
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-audit.service.ts
@@ -0,0 +1,173 @@
+import {
+ BadRequestException,
+ Injectable,
+ ServiceUnavailableException,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import type { Prisma } from '@prisma/client';
+import { createHmac, randomUUID } from 'node:crypto';
+import { PrismaService } from '../prisma/prisma.service';
+import {
+ assertTerminalAdmin,
+ lockTerminalSecurity,
+} from './terminal-security-locks';
+import type { TerminalAuditQueryDto } from './terminal-auth.dto';
+
+export interface TerminalAuditInput {
+ event: string;
+ result: string;
+ method?: 'QR' | 'PIN';
+ action?: 'clock-in' | 'clock-out';
+ employeeId?: string;
+ actorId?: string;
+ terminalId?: string;
+ deviceId?: string;
+ terminalName?: string;
+ locationLabel?: string;
+ timeEntryId?: string;
+}
+
+@Injectable()
+export class TerminalAuditService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly config: ConfigService,
+ ) {}
+
+ get configured() {
+ const key = this.config.get('TERMINAL_AUDIT_KEY');
+ return (
+ !!key &&
+ key.length >= 32 &&
+ !['JWT_SECRET', 'TERMINAL_QR_SECRET', 'TERMINAL_PIN_PEPPER'].some(
+ (name) => this.config.get(name) === key,
+ )
+ );
+ }
+
+ async append(
+ tx: Prisma.TransactionClient,
+ input: TerminalAuditInput,
+ required = true,
+ ) {
+ const key = this.config.get('TERMINAL_AUDIT_KEY');
+ if (!key || !this.configured) {
+ if (!required) return null; // Unconfigured legacy QR installations continue their original flow.
+ throw new ServiceUnavailableException({
+ code: 'TERMINAL_AUDIT_UNAVAILABLE',
+ message: 'Terminal audit is not configured',
+ });
+ }
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261501)`;
+ const last = await tx.terminalAuditEvent.findFirst({
+ orderBy: { sequence: 'desc' },
+ select: { sequence: true, hash: true },
+ });
+ const sequence = (last?.sequence ?? 0n) + 1n;
+ const id = randomUUID();
+ const occurredAt = new Date();
+ const keyVersion =
+ this.config.get('TERMINAL_AUDIT_KEY_VERSION') ?? '1';
+ if (!/^[A-Za-z0-9_.-]{1,40}$/.test(keyVersion))
+ throw new ServiceUnavailableException('Invalid audit key version');
+ // Explicit whitelist and fixed property order are the exported canonical v1 format.
+ // Never spread a request body or Error object here.
+ const fields = {
+ id,
+ occurredAt: occurredAt.toISOString(),
+ event: input.event,
+ result: input.result,
+ method: input.method ?? null,
+ action: input.action ?? null,
+ employeeId: input.employeeId ?? null,
+ actorId: input.actorId ?? null,
+ terminalId: input.terminalId ?? null,
+ deviceId: input.deviceId ?? null,
+ terminalName: input.terminalName ?? null,
+ locationLabel: input.locationLabel ?? null,
+ timeEntryId: input.timeEntryId ?? null,
+ };
+ const canonicalPayload = JSON.stringify({
+ format: 1,
+ sequence: sequence.toString(),
+ keyVersion,
+ ...fields,
+ });
+ const previousHash = last?.hash ?? '0'.repeat(64);
+ const hash = createHmac('sha256', key)
+ .update(`${previousHash}\n${canonicalPayload}`)
+ .digest('hex');
+ return tx.terminalAuditEvent.create({
+ data: {
+ ...fields,
+ occurredAt,
+ sequence,
+ canonicalPayload,
+ previousHash,
+ hash,
+ keyVersion,
+ },
+ });
+ }
+
+ async failure(input: TerminalAuditInput, required = true) {
+ await this.prisma.$transaction((tx) => this.append(tx, input, required));
+ }
+
+ async list(query: TerminalAuditQueryDto, actorId: string) {
+ if (
+ query.cursor &&
+ (!/^\d{1,19}$/.test(query.cursor) ||
+ BigInt(query.cursor) > 9223372036854775807n)
+ )
+ throw new BadRequestException('Invalid audit cursor');
+ return this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ await assertTerminalAdmin(tx, actorId);
+ const limit = query.limit ?? 25;
+ const rows = await tx.terminalAuditEvent.findMany({
+ where: {
+ ...(query.cursor ? { sequence: { lt: BigInt(query.cursor) } } : {}),
+ ...(query.terminalId ? { terminalId: query.terminalId } : {}),
+ ...(query.employeeId ? { employeeId: query.employeeId } : {}),
+ ...(query.method ? { method: query.method } : {}),
+ ...(query.result ? { result: query.result } : {}),
+ },
+ orderBy: { sequence: 'desc' },
+ take: limit + 1,
+ select: {
+ id: true,
+ sequence: true,
+ occurredAt: true,
+ event: true,
+ result: true,
+ method: true,
+ action: true,
+ employeeId: true,
+ actorId: true,
+ terminalId: true,
+ deviceId: true,
+ terminalName: true,
+ locationLabel: true,
+ timeEntryId: true,
+ },
+ });
+ await this.append(
+ tx,
+ { event: 'AuditViewed', result: 'SUCCESS', actorId },
+ false,
+ );
+ return {
+ items: rows
+ .slice(0, limit)
+ .map((row) => ({
+ ...row,
+ sequence: row.sequence.toString(),
+ occurredAt: row.occurredAt.toISOString(),
+ })),
+ nextCursor:
+ rows.length > limit ? rows[limit - 1].sequence.toString() : null,
+ };
+ });
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-auth.controller.ts b/apps/api/src/app/terminals/terminal-auth.controller.ts
new file mode 100644
index 0000000..e3e42c8
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-auth.controller.ts
@@ -0,0 +1,106 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Header,
+ HttpException,
+ Post,
+ Query,
+ Res,
+ UseGuards,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiCreatedResponse,
+ ApiOkResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { CurrentUser } from '../auth/current-user.decorator';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { Roles } from '../auth/roles.decorator';
+import { RolesGuard } from '../auth/roles.guard';
+import { CurrentTerminalDevice } from './current-terminal-device.decorator';
+import {
+ TerminalDeviceGuard,
+ type TerminalDevicePrincipal,
+} from './terminal-device.guard';
+import {
+ BookTerminalDto,
+ BookTerminalResultDto,
+ IdentifyChallengeDto,
+ IdentifyTerminalDto,
+ IdentifyTerminalResultDto,
+ TerminalAuditPageDto,
+ TerminalAuditQueryDto,
+} from './terminal-auth.dto';
+import { TerminalAuthService } from './terminal-auth.service';
+import { TerminalAuditService } from './terminal-audit.service';
+
+interface LimitResponse {
+ setHeader(name: string, value: string): void;
+}
+
+@ApiTags('terminal-auth')
+@ApiBearerAuth()
+@Controller('terminals')
+export class TerminalAuthController {
+ constructor(
+ private readonly auth: TerminalAuthService,
+ private readonly audit: TerminalAuditService,
+ ) {}
+ private async limitedResponse(
+ response: LimitResponse,
+ work: () => Promise,
+ ): Promise {
+ try {
+ return await work();
+ } catch (error) {
+ if (error instanceof HttpException && error.getStatus() === 429)
+ response.setHeader('Retry-After', '60');
+ throw error;
+ }
+ }
+ @Post('kiosk/challenge')
+ @UseGuards(TerminalDeviceGuard)
+ @Header('Cache-Control', 'no-store')
+ @ApiCreatedResponse({ type: IdentifyChallengeDto })
+ challenge(
+ @CurrentTerminalDevice() device: TerminalDevicePrincipal,
+ @Res({ passthrough: true }) response: LimitResponse,
+ ) {
+ return this.limitedResponse(response, () => this.auth.challenge(device));
+ }
+ @Post('kiosk/identify')
+ @UseGuards(TerminalDeviceGuard)
+ @Header('Cache-Control', 'no-store')
+ @ApiCreatedResponse({ type: IdentifyTerminalResultDto })
+ identify(
+ @Body() dto: IdentifyTerminalDto,
+ @CurrentTerminalDevice() device: TerminalDevicePrincipal,
+ @Res({ passthrough: true }) response: LimitResponse,
+ ) {
+ return this.limitedResponse(response, () =>
+ this.auth.identify(dto, device),
+ );
+ }
+ @Post('kiosk/book')
+ @UseGuards(TerminalDeviceGuard)
+ @Header('Cache-Control', 'no-store')
+ @ApiCreatedResponse({ type: BookTerminalResultDto })
+ book(
+ @Body() dto: BookTerminalDto,
+ @CurrentTerminalDevice() device: TerminalDevicePrincipal,
+ @Res({ passthrough: true }) response: LimitResponse,
+ ) {
+ return this.limitedResponse(response, () => this.auth.book(dto, device));
+ }
+ @Get('audit-events')
+ @UseGuards(JwtAuthGuard, RolesGuard)
+ @Roles('HRAdmin')
+ @Header('Cache-Control', 'no-store')
+ @ApiOkResponse({ type: TerminalAuditPageDto })
+ events(@Query() query: TerminalAuditQueryDto, @CurrentUser() user: JwtUser) {
+ return this.audit.list(query, user.id);
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-auth.dto.ts b/apps/api/src/app/terminals/terminal-auth.dto.ts
new file mode 100644
index 0000000..e7da43d
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-auth.dto.ts
@@ -0,0 +1,158 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Transform, Type } from 'class-transformer';
+import {
+ IsDateString,
+ IsIn,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Matches,
+ Max,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator';
+
+// Preserve lexical credentials even with the application's implicit conversion.
+const exactString = () =>
+ Transform(
+ ({ obj, key }: { obj: Record; key: string }) => obj[key],
+ );
+export class IdentifyTerminalDto {
+ @ApiProperty({ enum: ['PIN'] }) @IsIn(['PIN']) method!: 'PIN';
+ @ApiProperty()
+ @exactString()
+ @IsString()
+ @MinLength(32)
+ @MaxLength(256)
+ challengePayload!: string;
+ @ApiProperty({
+ description: 'Exact personal number, preserving leading zeros.',
+ })
+ @exactString()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(100)
+ personalNo!: string;
+ @ApiProperty({ minLength: 8, maxLength: 8, writeOnly: true })
+ @exactString()
+ @IsString()
+ @Matches(/^\d{8}$/)
+ pin!: string;
+}
+export class IdentifyChallengeDto {
+ @ApiProperty() payload!: string;
+ @ApiProperty({ format: 'date-time' }) expiresAt!: string;
+}
+export class IdentifyTerminalResultDto {
+ @ApiProperty() grant!: string;
+ @ApiProperty({ format: 'date-time' }) expiresAt!: string;
+ @ApiProperty() displayName!: string;
+ @ApiProperty({ enum: ['clock-in', 'clock-out'] }) action!:
+ | 'clock-in'
+ | 'clock-out';
+ @ApiProperty() requiresLocation!: boolean;
+}
+export class BookTerminalDto {
+ @ApiProperty()
+ @exactString()
+ @IsString()
+ @MinLength(32)
+ @MaxLength(256)
+ grant!: string;
+ @ApiProperty({ enum: ['clock-in', 'clock-out'] })
+ @IsIn(['clock-in', 'clock-out'])
+ action!: 'clock-in' | 'clock-out';
+ @ApiPropertyOptional({ minimum: -90, maximum: 90 })
+ @IsOptional()
+ @IsNumber()
+ @Min(-90)
+ @Max(90)
+ latitude?: number;
+ @ApiPropertyOptional({ minimum: -180, maximum: 180 })
+ @IsOptional()
+ @IsNumber()
+ @Min(-180)
+ @Max(180)
+ longitude?: number;
+ @ApiPropertyOptional({ minimum: 0 })
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ accuracyMeters?: number;
+ @ApiPropertyOptional({ format: 'date-time' })
+ @IsOptional()
+ @IsDateString()
+ positionTimestamp?: string;
+}
+export class BookTerminalResultDto {
+ @ApiProperty({ enum: ['TERMINAL_BOOKED'] }) code!: 'TERMINAL_BOOKED';
+ @ApiProperty({ enum: ['clock-in', 'clock-out'] }) action!:
+ | 'clock-in'
+ | 'clock-out';
+ @ApiProperty({ format: 'date-time' }) bookedAt!: string;
+ @ApiProperty({ format: 'uuid' }) receiptId!: string;
+}
+export class TerminalCredentialStatusDto {
+ @ApiProperty({ enum: ['PIN'] }) method!: 'PIN';
+ @ApiProperty() isConfigured!: boolean;
+ @ApiProperty() enabled!: boolean;
+ @ApiProperty({ type: String, nullable: true }) lockedUntil!: string | null;
+ @ApiProperty({ type: String, nullable: true }) updatedAt!: string | null;
+}
+export class ResetTerminalPinResultDto {
+ @ApiProperty({
+ description: 'One-time secret. Never cache or log this response.',
+ })
+ pin!: string;
+ @ApiProperty({ type: TerminalCredentialStatusDto })
+ credential!: TerminalCredentialStatusDto;
+}
+export class TerminalAuditQueryDto {
+ @ApiPropertyOptional({ minimum: 1, maximum: 100 })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ limit?: number;
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @Matches(/^\d{1,19}$/)
+ cursor?: string;
+ @ApiPropertyOptional() @IsOptional() @IsUUID() terminalId?: string;
+ @ApiPropertyOptional() @IsOptional() @IsUUID() employeeId?: string;
+ @ApiPropertyOptional({ enum: ['QR', 'PIN'] })
+ @IsOptional()
+ @IsIn(['QR', 'PIN'])
+ method?: 'QR' | 'PIN';
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @MaxLength(60)
+ result?: string;
+}
+export class TerminalAuditEventDto {
+ @ApiProperty() id!: string;
+ @ApiProperty() sequence!: string;
+ @ApiProperty({ format: 'date-time' }) occurredAt!: string;
+ @ApiProperty() event!: string;
+ @ApiProperty() result!: string;
+ @ApiProperty({ type: String, nullable: true }) method!: string | null;
+ @ApiProperty({ type: String, nullable: true }) action!: string | null;
+ @ApiProperty({ type: String, nullable: true }) employeeId!: string | null;
+ @ApiProperty({ type: String, nullable: true }) actorId!: string | null;
+ @ApiProperty({ type: String, nullable: true }) terminalId!: string | null;
+ @ApiProperty({ type: String, nullable: true }) deviceId!: string | null;
+ @ApiProperty({ type: String, nullable: true }) terminalName!: string | null;
+ @ApiProperty({ type: String, nullable: true }) locationLabel!: string | null;
+ @ApiProperty({ type: String, nullable: true }) timeEntryId!: string | null;
+}
+export class TerminalAuditPageDto {
+ @ApiProperty({ type: [TerminalAuditEventDto] })
+ items!: TerminalAuditEventDto[];
+ @ApiProperty({ type: String, nullable: true }) nextCursor!: string | null;
+}
diff --git a/apps/api/src/app/terminals/terminal-auth.service.ts b/apps/api/src/app/terminals/terminal-auth.service.ts
new file mode 100644
index 0000000..17d0620
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-auth.service.ts
@@ -0,0 +1,690 @@
+import {
+ ConflictException,
+ ForbiddenException,
+ HttpException,
+ HttpStatus,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common';
+import type { Prisma, TerminalBookingGrant } from '@prisma/client';
+import { createHash, randomBytes } from 'node:crypto';
+import { PrismaService } from '../prisma/prisma.service';
+import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
+import { EventsGateway } from '../events/events.gateway';
+import { TerminalAuditService } from './terminal-audit.service';
+import { TerminalPinService } from './terminal-pin.service';
+import { TerminalsService } from './terminals.service';
+import { lockTerminalSecurity } from './terminal-security-locks';
+import { bookTerminalTime } from './terminal-booking.service';
+import type { TerminalDevicePrincipal } from './terminal-device.guard';
+import type {
+ BookTerminalDto,
+ BookTerminalResultDto,
+ IdentifyTerminalDto,
+} from './terminal-auth.dto';
+
+const hash = (value: string) =>
+ createHash('sha256').update(value).digest('hex');
+const MINUTE = 60_000;
+const PIN_WINDOW = 15 * MINUTE;
+
+@Injectable()
+export class TerminalAuthService {
+ private hashesInFlight = 0;
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly pins: TerminalPinService,
+ private readonly audit: TerminalAuditService,
+ private readonly terminals: TerminalsService,
+ private readonly schedules: WorkSchedulesService,
+ private readonly events: EventsGateway,
+ ) {}
+
+ private async device(
+ tx: Prisma.TransactionClient,
+ principal: TerminalDevicePrincipal,
+ ) {
+ const device = await tx.terminalDevice.findUnique({
+ where: { id: principal.id },
+ include: { terminal: true },
+ });
+ if (
+ !device ||
+ device.terminalId !== principal.terminalId ||
+ device.revokedAt ||
+ !device.terminal.isActive
+ )
+ throw new UnauthorizedException({
+ code: 'TERMINAL_DEVICE_REVOKED',
+ message: 'Terminal device is inactive or revoked',
+ });
+ return device;
+ }
+ private method(methods: string[]) {
+ if (!methods.includes('PIN'))
+ throw new ForbiddenException({
+ code: 'TERMINAL_METHOD_DISABLED',
+ message: 'Terminal method is disabled',
+ });
+ this.pins.assertAvailable();
+ }
+ private limited() {
+ return new HttpException(
+ {
+ code: 'TERMINAL_AUTH_RATE_LIMITED',
+ message: 'Too many terminal requests; try again shortly',
+ },
+ HttpStatus.TOO_MANY_REQUESTS,
+ );
+ }
+ /** Caller holds installation lock. Increment is committed even for denial. */
+ private async budget(
+ tx: Prisma.TransactionClient,
+ key: string,
+ limit: number,
+ windowMs: number,
+ now: Date,
+ ) {
+ const row = await tx.terminalAuthAttemptState.findUnique({
+ where: { key },
+ });
+ const resets =
+ !row || now.getTime() - row.windowStartedAt.getTime() >= windowMs;
+ const counter = resets ? 1 : Math.min(row.counter + 1, limit + 2);
+ await tx.terminalAuthAttemptState.upsert({
+ where: { key },
+ create: {
+ key,
+ counter,
+ windowStartedAt: now,
+ expiresAt: new Date(now.getTime() + windowMs),
+ },
+ update: {
+ counter,
+ ...(resets
+ ? {
+ windowStartedAt: now,
+ expiresAt: new Date(now.getTime() + windowMs),
+ }
+ : {}),
+ },
+ });
+ return counter <= limit;
+ }
+ private async reserve(
+ principal: TerminalDevicePrincipal,
+ kind: 'challenge' | 'identify' | 'book',
+ ) {
+ const result = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ const device = await this.device(tx, principal);
+ const now = new Date();
+ const deviceAllowed = await this.budget(
+ tx,
+ `${kind}:device:${device.id}`,
+ kind === 'book' ? 120 : 60,
+ MINUTE,
+ now,
+ );
+ const terminalAllowed = await this.budget(
+ tx,
+ `${kind}:terminal:${device.terminalId}`,
+ kind === 'book' ? 240 : 120,
+ MINUTE,
+ now,
+ );
+ const burstAllowed = await this.budget(
+ tx,
+ `${kind}:burst:${device.id}`,
+ 20,
+ 10_000,
+ now,
+ );
+ let error: HttpException | null =
+ deviceAllowed && terminalAllowed && burstAllowed
+ ? null
+ : this.limited();
+ if (!error && kind !== 'book') {
+ try {
+ this.method(device.terminal.allowedAuthMethods);
+ } catch (failure) {
+ if (failure instanceof HttpException) error = failure;
+ else throw failure;
+ }
+ }
+ if (error) {
+ // One durable event per device/kind/minute prevents rejected traffic
+ // from becoming an unbounded audit write amplifier. Counters still persist.
+ if (
+ await this.budget(
+ tx,
+ `admission:${kind}:${device.id}`,
+ 1,
+ MINUTE,
+ now,
+ )
+ ) {
+ await this.audit.append(
+ tx,
+ {
+ event: 'AdmissionRejected',
+ result:
+ error.getStatus() === 429
+ ? 'RATE_LIMITED'
+ : 'METHOD_UNAVAILABLE',
+ method: 'PIN',
+ terminalId: device.terminalId,
+ deviceId: device.id,
+ terminalName: device.terminal.name,
+ locationLabel: device.terminal.locationLabel,
+ },
+ false,
+ );
+ }
+ }
+ return { error, device };
+ });
+ if (result.error) throw result.error;
+ return result.device;
+ }
+
+ private async hashAdmissionRejected(principal: TerminalDevicePrincipal) {
+ await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ const device = await this.device(tx, principal);
+ if (
+ await this.budget(
+ tx,
+ `hash-admission:${device.id}`,
+ 1,
+ MINUTE,
+ new Date(),
+ )
+ )
+ await this.audit.append(tx, {
+ event: 'AdmissionRejected',
+ result: 'HASH_BUSY',
+ method: 'PIN',
+ terminalId: device.terminalId,
+ deviceId: device.id,
+ terminalName: device.terminal.name,
+ locationLabel: device.terminal.locationLabel,
+ });
+ });
+ }
+
+ async challenge(principal: TerminalDevicePrincipal) {
+ await this.reserve(principal, 'challenge');
+ return this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ const device = await this.device(tx, principal);
+ this.method(device.terminal.allowedAuthMethods);
+ const now = new Date();
+ const payload = `ocpi_${randomBytes(32).toString('base64url')}`;
+ const expiresAt = new Date(
+ Math.min(
+ now.getTime() + 45_000,
+ this.terminals
+ .nextLocalMidnight(now, device.terminal.timeZone)
+ .getTime(),
+ ),
+ );
+ await tx.terminalChallenge.create({
+ data: {
+ terminalId: device.terminalId,
+ deviceId: device.id,
+ tokenHash: hash(payload),
+ purpose: 'KioskIdentify',
+ authConfigVersion: device.terminal.authConfigVersion,
+ rootDate: new Date(
+ `${this.terminals.dateKey(now, device.terminal.timeZone)}T00:00:00.000Z`,
+ ),
+ expiresAt,
+ },
+ });
+ // Only ephemeral records are cleaned. Audit has a separate approved retention path.
+ await tx.terminalAuthAttemptState.deleteMany({
+ where: { expiresAt: { lt: new Date(now.getTime() - MINUTE) } },
+ });
+ await tx.terminalBookingGrant.deleteMany({
+ where: { expiresAt: { lt: new Date(now.getTime() - 3 * MINUTE) } },
+ });
+ await tx.terminalChallenge.deleteMany({
+ where: {
+ deviceId: device.id,
+ expiresAt: { lt: new Date(now.getTime() - 3 * MINUTE) },
+ redemptions: { none: {} },
+ clockInTimeEntries: { none: {} },
+ clockOutTimeEntries: { none: {} },
+ },
+ });
+ return { payload, expiresAt: expiresAt.toISOString() };
+ });
+ }
+
+ private async validChallenge(
+ tx: Prisma.TransactionClient,
+ tokenHash: string,
+ principal: TerminalDevicePrincipal,
+ now: Date,
+ configVersion: number,
+ timeZone: string,
+ ) {
+ const challenge = await tx.terminalChallenge.findUnique({
+ where: { tokenHash },
+ });
+ if (
+ !challenge ||
+ challenge.purpose !== 'KioskIdentify' ||
+ challenge.deviceId !== principal.id ||
+ challenge.terminalId !== principal.terminalId ||
+ challenge.authConfigVersion !== configVersion ||
+ challenge.expiresAt <= now ||
+ challenge.rootDate.toISOString().slice(0, 10) !==
+ this.terminals.dateKey(now, timeZone)
+ )
+ throw new ConflictException({
+ code: 'TERMINAL_CHALLENGE_INVALID',
+ message: 'Identification challenge is invalid or expired',
+ });
+ return challenge;
+ }
+
+ async identify(dto: IdentifyTerminalDto, principal: TerminalDevicePrincipal) {
+ const reservedDevice = await this.reserve(principal, 'identify');
+ if (this.hashesInFlight >= 4) {
+ await this.hashAdmissionRejected(principal);
+ throw this.limited();
+ }
+ this.hashesInFlight += 1;
+ try {
+ const initial = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ const device = await this.device(tx, principal);
+ this.method(device.terminal.allowedAuthMethods);
+ await this.validChallenge(
+ tx,
+ hash(dto.challengePayload),
+ principal,
+ new Date(),
+ device.terminal.authConfigVersion,
+ device.terminal.timeZone,
+ );
+ const employee = await tx.employee.findUnique({
+ where: { personalNo: dto.personalNo },
+ include: { terminalCredential: true },
+ });
+ const attempt = employee
+ ? await tx.terminalAuthAttemptState.findUnique({
+ where: { key: `pin:${employee.id}` },
+ })
+ : null;
+ const eligible =
+ !!employee?.isActive &&
+ !!employee.terminalCredential &&
+ !employee.terminalCredential.revokedAt &&
+ !(attempt?.lockedUntil && attempt.lockedUntil > new Date());
+ return {
+ employee,
+ credential: eligible ? (employee?.terminalCredential ?? null) : null,
+ };
+ });
+ const matches = await this.pins.verify(
+ dto.pin,
+ initial.credential?.pinHash,
+ initial.credential?.pepperVersion,
+ );
+ const result = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx, initial.employee?.id);
+ const device = await this.device(tx, principal);
+ this.method(device.terminal.allowedAuthMethods);
+ const now = new Date();
+ const challenge = await this.validChallenge(
+ tx,
+ hash(dto.challengePayload),
+ principal,
+ now,
+ device.terminal.authConfigVersion,
+ device.terminal.timeZone,
+ );
+ const employee = initial.employee
+ ? await tx.employee.findUnique({
+ where: { id: initial.employee.id },
+ include: { terminalCredential: true },
+ })
+ : null;
+ const credential = employee?.terminalCredential;
+ const key = employee ? `pin:${employee.id}` : null;
+ const state = key
+ ? await tx.terminalAuthAttemptState.findUnique({ where: { key } })
+ : null;
+ const locked = !!state?.lockedUntil && state.lockedUntil > now;
+ const accepted =
+ matches &&
+ employee?.isActive &&
+ !locked &&
+ credential &&
+ !credential.revokedAt &&
+ credential.version === initial.credential?.version &&
+ employee.personalNo === dto.personalNo;
+ const auditContext = {
+ method: 'PIN' as const,
+ employeeId: employee?.id,
+ terminalId: device.terminalId,
+ deviceId: device.id,
+ terminalName: device.terminal.name,
+ locationLabel: device.terminal.locationLabel,
+ };
+ if (!accepted) {
+ let newlyLocked = false;
+ // An in-flight verification of an old PIN must not penalize its replacement.
+ if (
+ key &&
+ credential &&
+ !credential.revokedAt &&
+ employee?.isActive &&
+ !locked &&
+ credential.version === initial.credential?.version
+ ) {
+ const counter =
+ !state ||
+ state.windowStartedAt.getTime() + PIN_WINDOW <= now.getTime()
+ ? 1
+ : state.counter + 1;
+ newlyLocked = counter >= 5;
+ await tx.terminalAuthAttemptState.upsert({
+ where: { key },
+ create: {
+ key,
+ counter,
+ windowStartedAt: now,
+ lockedUntil: newlyLocked
+ ? new Date(now.getTime() + PIN_WINDOW)
+ : null,
+ expiresAt: new Date(now.getTime() + 2 * PIN_WINDOW),
+ },
+ update: {
+ counter,
+ ...(!state ||
+ state.windowStartedAt.getTime() + PIN_WINDOW <= now.getTime()
+ ? { windowStartedAt: now }
+ : {}),
+ lockedUntil: newlyLocked
+ ? new Date(now.getTime() + PIN_WINDOW)
+ : null,
+ expiresAt: new Date(now.getTime() + 2 * PIN_WINDOW),
+ },
+ });
+ }
+ await this.audit.append(tx, {
+ ...auditContext,
+ event: 'IdentificationRejected',
+ result: 'AUTH_FAILED',
+ });
+ if (newlyLocked)
+ await this.audit.append(tx, {
+ ...auditContext,
+ event: 'PinLocked',
+ result: 'LOCKED',
+ });
+ return null;
+ }
+ if (key)
+ await tx.terminalAuthAttemptState.deleteMany({ where: { key } });
+ const grant = `octg_${randomBytes(32).toString('base64url')}`;
+ const expiresAt = new Date(
+ Math.min(now.getTime() + 30_000, challenge.expiresAt.getTime()),
+ );
+ await tx.terminalBookingGrant.create({
+ data: {
+ tokenHash: hash(grant),
+ employeeId: employee.id,
+ credentialId: credential.id,
+ credentialVersion: credential.version,
+ terminalId: device.terminalId,
+ deviceId: device.id,
+ challengeId: challenge.id,
+ authConfigVersion: device.terminal.authConfigVersion,
+ expiresAt,
+ },
+ });
+ const open = await tx.timeEntry.findFirst({
+ where: { employeeId: employee.id, clockOut: null, voidedAt: null },
+ select: { id: true },
+ });
+ await this.audit.append(tx, {
+ ...auditContext,
+ event: 'IdentificationAccepted',
+ result: 'SUCCESS',
+ });
+ return {
+ grant,
+ expiresAt: expiresAt.toISOString(),
+ displayName: `${employee.firstName} ${Array.from(employee.lastName)[0] ?? ''}.`,
+ action: open ? ('clock-out' as const) : ('clock-in' as const),
+ requiresLocation: device.terminal.enforceGeofence,
+ };
+ });
+ if (!result)
+ throw new UnauthorizedException({
+ code: 'TERMINAL_AUTH_FAILED',
+ message: 'Identification failed',
+ });
+ return result;
+ } catch (error) {
+ // Credential rejections have already committed their counter and event.
+ const response =
+ error instanceof HttpException ? error.getResponse() : null;
+ if (
+ !(
+ response &&
+ typeof response === 'object' &&
+ 'code' in response &&
+ response.code === 'TERMINAL_AUTH_FAILED'
+ )
+ ) {
+ await this.audit.failure({
+ event: 'IdentificationRejected',
+ result: 'REQUEST_REJECTED',
+ method: 'PIN',
+ terminalId: reservedDevice.terminalId,
+ deviceId: reservedDevice.id,
+ terminalName: reservedDevice.terminal.name,
+ locationLabel: reservedDevice.terminal.locationLabel,
+ });
+ }
+ throw error;
+ } finally {
+ this.hashesInFlight -= 1;
+ }
+ }
+
+ private receipt(grant: TerminalBookingGrant): BookTerminalResultDto {
+ if (!grant.bookedAt || !grant.action)
+ throw new ConflictException('Incomplete booking receipt');
+ return {
+ code: 'TERMINAL_BOOKED',
+ action: grant.action as 'clock-in' | 'clock-out',
+ bookedAt: grant.bookedAt.toISOString(),
+ receiptId: grant.id,
+ };
+ }
+
+ async book(
+ dto: BookTerminalDto,
+ principal: TerminalDevicePrincipal,
+ ): Promise {
+ const reservedDevice = await this.reserve(principal, 'book');
+ let employeeId: string | undefined;
+ try {
+ const result = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ const device = await this.device(tx, principal);
+ const grant = await tx.terminalBookingGrant.findUnique({
+ where: { tokenHash: hash(dto.grant) },
+ });
+ if (
+ !grant ||
+ grant.deviceId !== device.id ||
+ grant.terminalId !== device.terminalId
+ )
+ throw new ConflictException({
+ code: 'TERMINAL_GRANT_INVALID',
+ message: 'Booking permission is invalid',
+ });
+ employeeId = grant.employeeId;
+ await lockTerminalSecurity(tx, employeeId);
+ const employee = await tx.employee.findUnique({
+ where: { id: employeeId },
+ select: { isActive: true },
+ });
+ if (!employee?.isActive)
+ throw new ConflictException({
+ code: 'TERMINAL_GRANT_INVALID',
+ message: 'Booking permission is no longer valid',
+ });
+ const now = new Date();
+ if (grant.consumedAt) {
+ if (
+ grant.action !== dto.action ||
+ now.getTime() > grant.consumedAt.getTime() + 2 * MINUTE
+ )
+ throw new ConflictException({
+ code: 'TERMINAL_GRANT_CONSUMED',
+ message: 'Booking permission was already used',
+ });
+ return {
+ response: this.receipt(grant),
+ entry: null,
+ action: dto.action,
+ };
+ }
+ this.method(device.terminal.allowedAuthMethods);
+ const credential = await tx.terminalCredential.findUnique({
+ where: { employeeId },
+ });
+ const pinState = await tx.terminalAuthAttemptState.findUnique({
+ where: { key: `pin:${employeeId}` },
+ });
+ if (
+ (pinState?.lockedUntil && pinState.lockedUntil > now) ||
+ !credential ||
+ credential.revokedAt ||
+ credential.id !== grant.credentialId ||
+ credential.version !== grant.credentialVersion ||
+ grant.authConfigVersion !== device.terminal.authConfigVersion
+ )
+ throw new ConflictException({
+ code: 'TERMINAL_GRANT_REVOKED',
+ message: 'Booking permission was revoked',
+ });
+ if (grant.expiresAt <= now)
+ throw new ConflictException({
+ code: 'TERMINAL_GRANT_EXPIRED',
+ message: 'Booking permission expired',
+ });
+ const challenge = await tx.terminalChallenge.findUnique({
+ where: { id: grant.challengeId },
+ include: { terminal: true },
+ });
+ if (
+ !challenge ||
+ challenge.purpose !== 'KioskIdentify' ||
+ challenge.deviceId !== device.id ||
+ challenge.terminalId !== device.terminalId ||
+ challenge.expiresAt <= now ||
+ challenge.rootDate.toISOString().slice(0, 10) !==
+ this.terminals.dateKey(now, device.terminal.timeZone)
+ )
+ throw new ConflictException({
+ code: 'TERMINAL_GRANT_EXPIRED',
+ message: 'Booking challenge expired',
+ });
+ if (
+ await tx.terminalChallengeRedemption.findUnique({
+ where: {
+ challengeId_employeeId: { challengeId: challenge.id, employeeId },
+ },
+ })
+ )
+ throw new ConflictException({
+ code: 'TERMINAL_CHALLENGE_REPLAYED',
+ message: 'Identification challenge was already used',
+ });
+ const position = this.terminals.evaluateTerminalPosition(
+ dto,
+ device.terminal,
+ now,
+ );
+ const schedule = await this.schedules.resolveForEmployee(employeeId);
+ const booked = await bookTerminalTime(
+ tx,
+ employeeId,
+ challenge,
+ now,
+ schedule,
+ position,
+ dto.action,
+ );
+ const consumed = await tx.terminalBookingGrant.update({
+ where: { id: grant.id },
+ data: {
+ consumedAt: now,
+ action: dto.action,
+ timeEntryId: booked.entry.id,
+ bookedAt: now,
+ },
+ });
+ await this.audit.append(tx, {
+ event: 'BookingAccepted',
+ result: 'SUCCESS',
+ method: 'PIN',
+ action: dto.action,
+ employeeId,
+ terminalId: device.terminalId,
+ deviceId: device.id,
+ terminalName: device.terminal.name,
+ locationLabel: device.terminal.locationLabel,
+ timeEntryId: booked.entry.id,
+ });
+ return {
+ response: this.receipt(consumed),
+ entry: booked.entry,
+ action: booked.action,
+ };
+ });
+ if (result.entry)
+ this.events.broadcast(
+ result.action === 'clock-in'
+ ? 'time-entry:created'
+ : 'time-entry:updated',
+ {
+ id: result.entry.id,
+ employeeId: result.entry.employeeId,
+ ...(result.action === 'clock-in'
+ ? { clockIn: result.entry.clockIn.toISOString() }
+ : { clockOut: result.entry.clockOut?.toISOString() ?? null }),
+ },
+ );
+ return result.response;
+ } catch (error) {
+ await this.audit.failure({
+ event: 'BookingRejected',
+ result: 'REQUEST_REJECTED',
+ method: 'PIN',
+ action: dto.action,
+ employeeId,
+ terminalId: reservedDevice.terminalId,
+ deviceId: reservedDevice.id,
+ terminalName: reservedDevice.terminal.name,
+ locationLabel: reservedDevice.terminal.locationLabel,
+ });
+ if ((error as { code?: string }).code === 'P2002')
+ throw new ConflictException({
+ code: 'TERMINAL_BOOKING_CONFLICT',
+ message: 'Booking state changed',
+ });
+ throw error;
+ }
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-booking.service.ts b/apps/api/src/app/terminals/terminal-booking.service.ts
new file mode 100644
index 0000000..f91e7b7
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-booking.service.ts
@@ -0,0 +1,147 @@
+import {
+ BadRequestException,
+ ConflictException,
+ NotFoundException,
+} from '@nestjs/common';
+import type {
+ Prisma,
+ Terminal,
+ TerminalChallenge,
+ TimeEntry,
+} from '@prisma/client';
+import { requiresSpecialApproval } from 'shared';
+import type { WorkSchedulesService } from '../work-schedules/work-schedules.service';
+
+export type TerminalPosition = {
+ latitude: number;
+ longitude: number;
+ accuracyMeters: number;
+ positionTimestamp: Date;
+ distanceMeters: number;
+} | null;
+const ENTRY_INCLUDE = {
+ project: { select: { code: true, name: true } },
+ serviceOrder: { select: { orderNo: true, title: true } },
+} as const;
+
+/** Shared QR/PIN write path. Caller owns security locks, validation and audit. */
+export async function bookTerminalTime(
+ tx: Prisma.TransactionClient,
+ employeeId: string,
+ challenge: TerminalChallenge & { terminal: Terminal },
+ bookingNow: Date,
+ schedule: Awaited>,
+ position: TerminalPosition,
+ expectedAction?: 'clock-in' | 'clock-out',
+) {
+ const open = await tx.timeEntry.findFirst({
+ where: { employeeId, clockOut: null, voidedAt: null },
+ orderBy: { clockIn: 'desc' },
+ });
+ const action = open ? 'clock-out' : 'clock-in';
+ if (expectedAction && expectedAction !== action) {
+ throw new ConflictException({
+ code: 'TERMINAL_ACTION_MISMATCH',
+ message: `Current employee state requires ${action}`,
+ action,
+ });
+ }
+
+ let booked: TimeEntry & {
+ project: { code: string; name: string } | null;
+ serviceOrder: { orderNo: string; title: string } | null;
+ };
+ if (action === 'clock-in') {
+ const today = localTodayAsUtcDate(bookingNow);
+ const dailyBlock = await tx.timeEntry.findFirst({
+ where: { employeeId, bookingDate: today, voidedAt: null },
+ select: { id: true },
+ });
+ if (dailyBlock) {
+ throw new ConflictException('Today already has a daily-block booking');
+ }
+ booked = await tx.timeEntry.create({
+ data: {
+ employeeId,
+ clockIn: bookingNow,
+ source: 'Terminal',
+ approvalMode: 'Team',
+ breakRules: schedule.breakRules,
+ status: 'Open',
+ requiresApproval: requiresSpecialApproval(
+ bookingNow,
+ null,
+ schedule.frame,
+ ),
+ latitude: position?.latitude ?? null,
+ longitude: position?.longitude ?? null,
+ accuracyMeters: position?.accuracyMeters ?? null,
+ terminalDistanceMeters: position?.distanceMeters ?? null,
+ terminalRadiusMeters: position ? challenge.terminal.radiusMeters : null,
+ terminalMaxAccuracyMeters: position
+ ? challenge.terminal.maxAccuracyMeters
+ : null,
+ positionTimestamp: position?.positionTimestamp ?? null,
+ terminalLocationLabel: challenge.terminal.locationLabel,
+ terminalId: challenge.terminalId,
+ clockInChallengeId: challenge.id,
+ },
+ include: ENTRY_INCLUDE,
+ });
+ } else {
+ if (!open) throw new ConflictException('No open entry to close');
+ if (bookingNow <= open.clockIn) {
+ throw new BadRequestException('Clock-out must be after clock-in');
+ }
+ const requires = requiresSpecialApproval(
+ open.clockIn,
+ bookingNow,
+ schedule.frame,
+ );
+ const updated = await tx.timeEntry.updateMany({
+ where: { id: open.id, clockOut: null },
+ data: {
+ clockOut: bookingNow,
+ revision: { increment: 1 },
+ status: requires ? 'Pending' : 'Approved',
+ requiresApproval: requires,
+ clockOutLatitude: position?.latitude ?? null,
+ clockOutLongitude: position?.longitude ?? null,
+ clockOutAccuracyMeters: position?.accuracyMeters ?? null,
+ clockOutTerminalDistanceMeters: position?.distanceMeters ?? null,
+ clockOutTerminalRadiusMeters: position
+ ? challenge.terminal.radiusMeters
+ : null,
+ clockOutTerminalMaxAccuracyMeters: position
+ ? challenge.terminal.maxAccuracyMeters
+ : null,
+ clockOutPositionTimestamp: position?.positionTimestamp ?? null,
+ clockOutTerminalLocationLabel: challenge.terminal.locationLabel,
+ clockOutTerminalId: challenge.terminalId,
+ clockOutChallengeId: challenge.id,
+ },
+ });
+ if (updated.count !== 1) {
+ throw new ConflictException('Time entry was already closed');
+ }
+ const row = await tx.timeEntry.findUnique({
+ where: { id: open.id },
+ include: ENTRY_INCLUDE,
+ });
+ if (!row) throw new NotFoundException('Time entry not found');
+ booked = row;
+ }
+ await tx.terminalChallengeRedemption.create({
+ data: {
+ challengeId: challenge.id,
+ employeeId,
+ timeEntryId: booked.id,
+ action,
+ },
+ });
+ return { entry: booked, action: action as 'clock-in' | 'clock-out' };
+}
+
+function localTodayAsUtcDate(now = new Date()): Date {
+ return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
+}
diff --git a/apps/api/src/app/terminals/terminal-credentials.controller.ts b/apps/api/src/app/terminals/terminal-credentials.controller.ts
new file mode 100644
index 0000000..0020e00
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-credentials.controller.ts
@@ -0,0 +1,71 @@
+import {
+ Controller,
+ Delete,
+ Get,
+ Header,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiCreatedResponse,
+ ApiOkResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { CurrentUser } from '../auth/current-user.decorator';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { Roles } from '../auth/roles.decorator';
+import { RolesGuard } from '../auth/roles.guard';
+import {
+ ResetTerminalPinResultDto,
+ TerminalCredentialStatusDto,
+} from './terminal-auth.dto';
+import { TerminalCredentialsService } from './terminal-credentials.service';
+
+@ApiTags('terminal-credentials')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard, RolesGuard)
+@Roles('HRAdmin')
+@Controller('employees/:id/terminal-credentials')
+export class TerminalCredentialsController {
+ constructor(private readonly credentials: TerminalCredentialsService) {}
+ @Get()
+ @Header('Cache-Control', 'no-store')
+ @ApiOkResponse({ type: TerminalCredentialStatusDto })
+ get(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ) {
+ return this.credentials.get(id, user.id);
+ }
+ @Post('pin/reset')
+ @Header('Cache-Control', 'no-store')
+ @ApiCreatedResponse({ type: ResetTerminalPinResultDto })
+ reset(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ) {
+ return this.credentials.reset(id, user.id);
+ }
+ @Delete('pin')
+ @Header('Cache-Control', 'no-store')
+ @ApiOkResponse({ type: TerminalCredentialStatusDto })
+ revoke(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ) {
+ return this.credentials.change(id, user.id, false);
+ }
+ @Post('pin/unlock')
+ @Header('Cache-Control', 'no-store')
+ @ApiCreatedResponse({ type: TerminalCredentialStatusDto })
+ unlock(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ) {
+ return this.credentials.change(id, user.id, true);
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-credentials.service.ts b/apps/api/src/app/terminals/terminal-credentials.service.ts
new file mode 100644
index 0000000..a1a414e
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-credentials.service.ts
@@ -0,0 +1,119 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import type { Prisma } from '@prisma/client';
+import { PrismaService } from '../prisma/prisma.service';
+import { TerminalPinService } from './terminal-pin.service';
+import { TerminalAuditService } from './terminal-audit.service';
+import {
+ assertTerminalAdmin,
+ lockTerminalSecurity,
+} from './terminal-security-locks';
+import type { TerminalCredentialStatusDto } from './terminal-auth.dto';
+
+@Injectable()
+export class TerminalCredentialsService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly pins: TerminalPinService,
+ private readonly audit: TerminalAuditService,
+ ) {}
+ private async context(
+ tx: Prisma.TransactionClient,
+ employeeId: string,
+ actorId: string,
+ ) {
+ await lockTerminalSecurity(tx, employeeId);
+ await assertTerminalAdmin(tx, actorId);
+ if (
+ !(await tx.employee.findUnique({
+ where: { id: employeeId },
+ select: { id: true },
+ }))
+ )
+ throw new NotFoundException('Employee not found');
+ }
+ private async status(
+ tx: Prisma.TransactionClient,
+ employeeId: string,
+ ): Promise {
+ const row = await tx.terminalCredential.findUnique({
+ where: { employeeId },
+ });
+ const state = await tx.terminalAuthAttemptState.findUnique({
+ where: { key: `pin:${employeeId}` },
+ });
+ return {
+ method: 'PIN',
+ isConfigured: !!row,
+ enabled: !!row && !row.revokedAt,
+ lockedUntil:
+ state?.lockedUntil && state.lockedUntil > new Date()
+ ? state.lockedUntil.toISOString()
+ : null,
+ updatedAt: row?.updatedAt.toISOString() ?? null,
+ };
+ }
+ async get(employeeId: string, actorId: string) {
+ return this.prisma.$transaction(async (tx) => {
+ await this.context(tx, employeeId, actorId);
+ return this.status(tx, employeeId);
+ });
+ }
+ async reset(employeeId: string, actorId: string) {
+ this.pins.assertAvailable();
+ const pin = this.pins.generate();
+ const pinHash = await this.pins.hash(pin);
+ const credential = await this.prisma.$transaction(async (tx) => {
+ await this.context(tx, employeeId, actorId);
+ await tx.terminalCredential.upsert({
+ where: { employeeId },
+ create: { employeeId, pinHash, pepperVersion: this.pins.version },
+ update: {
+ pinHash,
+ pepperVersion: this.pins.version,
+ revokedAt: null,
+ version: { increment: 1 },
+ },
+ });
+ await tx.terminalAuthAttemptState.deleteMany({
+ where: { key: `pin:${employeeId}` },
+ });
+ await this.audit.append(tx, {
+ event: 'PinReset',
+ result: 'SUCCESS',
+ method: 'PIN',
+ employeeId,
+ actorId,
+ });
+ return this.status(tx, employeeId);
+ });
+ return { pin, credential };
+ }
+ async change(employeeId: string, actorId: string, unlock: boolean) {
+ return this.prisma.$transaction(async (tx) => {
+ await this.context(tx, employeeId, actorId);
+ const credential = await tx.terminalCredential.findUnique({
+ where: { employeeId },
+ });
+ if (!credential || (unlock && credential.revokedAt))
+ throw new NotFoundException('Active PIN credential not found');
+ await tx.terminalCredential.update({
+ where: { employeeId },
+ data: {
+ version: { increment: 1 },
+ ...(unlock ? {} : { revokedAt: new Date() }),
+ },
+ });
+ await tx.terminalAuthAttemptState.deleteMany({
+ where: { key: `pin:${employeeId}` },
+ });
+ await this.audit.append(tx, {
+ event: unlock ? 'PinUnlocked' : 'PinRevoked',
+ result: 'SUCCESS',
+ method: 'PIN',
+ employeeId,
+ actorId,
+ });
+ return this.status(tx, employeeId);
+ });
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-device.guard.ts b/apps/api/src/app/terminals/terminal-device.guard.ts
index 2b79f6d..309b24c 100644
--- a/apps/api/src/app/terminals/terminal-device.guard.ts
+++ b/apps/api/src/app/terminals/terminal-device.guard.ts
@@ -29,7 +29,10 @@ export class TerminalDeviceGuard implements CanActivate {
: authorization;
const token = value?.startsWith('Bearer ') ? value.slice(7).trim() : '';
if (!token || token.length > 256) {
- throw new UnauthorizedException('Valid terminal device token required');
+ throw new UnauthorizedException({
+ code: 'TERMINAL_DEVICE_INVALID',
+ message: 'Valid terminal device token required',
+ });
}
const tokenHash = createHash('sha256').update(token).digest('hex');
@@ -44,7 +47,10 @@ export class TerminalDeviceGuard implements CanActivate {
},
});
if (!device || device.revokedAt || !device.terminal.isActive) {
- throw new UnauthorizedException('Terminal device is inactive or revoked');
+ throw new UnauthorizedException({
+ code: device ? 'TERMINAL_DEVICE_REVOKED' : 'TERMINAL_DEVICE_INVALID',
+ message: 'Terminal device is inactive or revoked',
+ });
}
const now = new Date();
diff --git a/apps/api/src/app/terminals/terminal-pin.service.ts b/apps/api/src/app/terminals/terminal-pin.service.ts
new file mode 100644
index 0000000..4e60b70
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-pin.service.ts
@@ -0,0 +1,119 @@
+import {
+ Injectable,
+ OnModuleInit,
+ ServiceUnavailableException,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { createHmac, randomInt } from 'node:crypto';
+import * as argon2 from 'argon2';
+import { PrismaService } from '../prisma/prisma.service';
+import { TerminalAuditService } from './terminal-audit.service';
+
+@Injectable()
+export class TerminalPinService implements OnModuleInit {
+ private dummyHash?: Promise;
+ constructor(
+ private readonly config: ConfigService,
+ private readonly audit: TerminalAuditService,
+ private readonly prisma: PrismaService,
+ ) {}
+ async onModuleInit() {
+ if (this.config.get('NODE_ENV') !== 'production' || !this.available)
+ return;
+ const rows = await this.prisma.$queryRaw<
+ Array<{ unsafe: boolean; canAppend: boolean }>
+ >`
+ SELECT (r.rolsuper OR r.rolcreaterole OR pg_has_role(current_user, c.relowner, 'MEMBER')
+ OR pg_has_role(current_user, n.nspowner, 'MEMBER')
+ OR pg_has_role(current_user, d.datdba, 'MEMBER')
+ OR has_table_privilege(current_user, c.oid, 'UPDATE')
+ OR has_table_privilege(current_user, c.oid, 'DELETE')
+ OR has_table_privilege(current_user, c.oid, 'TRUNCATE')) AS "unsafe",
+ (has_table_privilege(current_user, c.oid, 'INSERT') AND has_table_privilege(current_user, c.oid, 'SELECT')) AS "canAppend"
+ FROM pg_class c JOIN pg_roles r ON r.rolname = current_user
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ JOIN pg_database d ON d.datname = current_database()
+ WHERE c.oid = to_regclass('"TerminalAuditEvent"')`;
+ if (rows.length !== 1 || rows[0].unsafe || !rows[0].canAppend)
+ throw new Error(
+ 'Terminal PIN requires a separate runtime database role with only SELECT/INSERT on TerminalAuditEvent, without audit, schema or database ownership, owner-role membership, superuser or CREATEROLE privileges. Run migrations with the separate migration role.',
+ );
+ }
+ get version() {
+ return this.config.get('TERMINAL_PIN_PEPPER_VERSION') ?? '1';
+ }
+ get available() {
+ return !!this.pepper(this.version) && this.audit.configured;
+ }
+ assertAvailable() {
+ if (!this.available)
+ throw new ServiceUnavailableException({
+ code: 'TERMINAL_PIN_UNAVAILABLE',
+ message: 'Terminal PIN is not configured',
+ });
+ }
+ generate() {
+ return String(randomInt(0, 100_000_000)).padStart(8, '0');
+ }
+ private pepper(version: string): string | undefined {
+ if (!/^[A-Za-z0-9_.-]{1,40}$/.test(version)) return undefined;
+ let secret: unknown;
+ if (version === this.version)
+ secret = this.config.get('TERMINAL_PIN_PEPPER');
+ else {
+ try {
+ const previous = JSON.parse(
+ this.config.get('TERMINAL_PIN_PREVIOUS_PEPPERS') ?? '{}',
+ ) as Record;
+ secret = Object.prototype.hasOwnProperty.call(previous, version)
+ ? previous[version]
+ : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+ return typeof secret === 'string' &&
+ secret.length >= 32 &&
+ !['JWT_SECRET', 'TERMINAL_QR_SECRET', 'TERMINAL_AUDIT_KEY'].some(
+ (name) => this.config.get(name) === secret,
+ )
+ ? secret
+ : undefined;
+ }
+ private input(pin: string, version: string) {
+ const secret = this.pepper(version);
+ if (!secret) return null;
+ return createHmac('sha256', secret)
+ .update(`openclockwork-terminal-pin:v1:${pin}`)
+ .digest();
+ }
+ async hash(pin: string) {
+ this.assertAvailable();
+ const input = this.input(pin, this.version);
+ if (!input)
+ throw new ServiceUnavailableException('Terminal PIN key unavailable');
+ return argon2.hash(input, {
+ type: argon2.argon2id,
+ memoryCost: 19456,
+ timeCost: 2,
+ parallelism: 1,
+ });
+ }
+ async verify(pin: string, encoded?: string, version?: string) {
+ this.assertAvailable();
+ const input = version ? this.input(pin, version) : null;
+ if (!encoded || !input) {
+ this.dummyHash ??= this.hash('00000000');
+ const dummyInput = this.input(pin, this.version);
+ if (!dummyInput)
+ throw new ServiceUnavailableException('Terminal PIN key unavailable');
+ await argon2.verify(await this.dummyHash, dummyInput);
+ return false;
+ }
+ try {
+ return await argon2.verify(encoded, input);
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-rate-limit.guard.ts b/apps/api/src/app/terminals/terminal-rate-limit.guard.ts
index c416324..56cd083 100644
--- a/apps/api/src/app/terminals/terminal-rate-limit.guard.ts
+++ b/apps/api/src/app/terminals/terminal-rate-limit.guard.ts
@@ -10,6 +10,7 @@ import {
import { Reflector } from '@nestjs/core';
import type { JwtUser } from '../auth/jwt.strategy';
import type { TerminalDevicePrincipal } from './terminal-device.guard';
+import { TerminalAuditService } from './terminal-audit.service';
type TerminalRateLimitKind = 'pair' | 'kiosk' | 'scan';
@@ -52,9 +53,12 @@ export class TerminalRateLimitGuard implements CanActivate {
private readonly buckets = new Map();
private lastCleanupAt = 0;
- constructor(private readonly reflector: Reflector) {}
+ constructor(
+ private readonly reflector: Reflector,
+ private readonly audit: TerminalAuditService,
+ ) {}
- canActivate(context: ExecutionContext): boolean {
+ async canActivate(context: ExecutionContext): Promise {
const kind = this.reflector.getAllAndOverride(
TERMINAL_RATE_LIMIT,
[context.getHandler(), context.getClass()],
@@ -79,6 +83,17 @@ export class TerminalRateLimitGuard implements CanActivate {
const limit = LIMITS[kind];
if (bucket.count <= limit) return true;
+ if (kind === 'scan' && bucket.count === limit + 1 && request.user?.id) {
+ await this.audit.failure(
+ {
+ event: 'AdmissionRejected',
+ result: 'RATE_LIMITED',
+ method: 'QR',
+ employeeId: request.user.id,
+ },
+ false,
+ );
+ }
const retryAfterSeconds = Math.max(
1,
Math.ceil((bucket.windowStartedAt + WINDOW_MS - now) / 1000),
diff --git a/apps/api/src/app/terminals/terminal-security-locks.ts b/apps/api/src/app/terminals/terminal-security-locks.ts
new file mode 100644
index 0000000..14e48c2
--- /dev/null
+++ b/apps/api/src/app/terminals/terminal-security-locks.ts
@@ -0,0 +1,34 @@
+import { ForbiddenException } from '@nestjs/common';
+import type { Prisma } from '@prisma/client';
+
+/** All terminal mutations share the installation lock with mode/employee writes.
+ * Lock order: installation -> employee -> audit. Hashing never holds these locks. */
+export async function lockTerminalSecurity(
+ tx: Prisma.TransactionClient,
+ employeeId?: string,
+) {
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ if (employeeId)
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`;
+ const settings = await tx.installationSettings.findUnique({
+ where: { id: 1 },
+ select: { mode: true },
+ });
+ if (settings?.mode === 'Solo')
+ throw new ForbiddenException({
+ code: 'TERMINAL_TEAM_ONLY',
+ message: 'Terminals require Team mode',
+ });
+}
+
+export async function assertTerminalAdmin(
+ tx: Prisma.TransactionClient,
+ actorId: string,
+) {
+ const actor = await tx.employee.findUnique({
+ where: { id: actorId },
+ select: { isActive: true, role: true },
+ });
+ if (!actor?.isActive || actor.role !== 'HRAdmin')
+ throw new ForbiddenException('Current HR administrator required');
+}
diff --git a/apps/api/src/app/terminals/terminals.controller.ts b/apps/api/src/app/terminals/terminals.controller.ts
index 0ea8366..e2a506c 100644
--- a/apps/api/src/app/terminals/terminals.controller.ts
+++ b/apps/api/src/app/terminals/terminals.controller.ts
@@ -125,8 +125,11 @@ export class TerminalsController {
@Header('Cache-Control', 'no-store, no-cache, must-revalidate')
@Header('Pragma', 'no-cache')
@ApiCreatedResponse({ type: TerminalDto })
- create(@Body() dto: CreateTerminalDto): Promise {
- return this.terminals.create(dto);
+ create(
+ @Body() dto: CreateTerminalDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.terminals.create(dto, user.id);
}
@Get(':id')
@@ -146,8 +149,9 @@ export class TerminalsController {
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateTerminalDto,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.terminals.update(id, dto);
+ return this.terminals.update(id, dto, user.id);
}
@Delete(':id')
@@ -157,8 +161,9 @@ export class TerminalsController {
@ApiOkResponse({ type: TerminalDto })
deactivate(
@Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.terminals.deactivate(id);
+ return this.terminals.deactivate(id, user.id);
}
@Delete(':id/permanent')
@@ -172,8 +177,9 @@ export class TerminalsController {
})
async deletePermanently(
@Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
): Promise {
- await this.terminals.deletePermanently(id);
+ await this.terminals.deletePermanently(id, user.id);
}
@Post(':id/pairing')
@@ -185,8 +191,9 @@ export class TerminalsController {
@ApiCreatedResponse({ type: PairingCodeDto })
createPairing(
@Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.terminals.createPairing(id);
+ return this.terminals.createPairing(id, user.id);
}
@Delete(':id/devices/:deviceId')
@@ -197,7 +204,8 @@ export class TerminalsController {
revokeDevice(
@Param('id', new ParseUUIDPipe()) id: string,
@Param('deviceId', new ParseUUIDPipe()) deviceId: string,
+ @CurrentUser() user: JwtUser,
): Promise {
- return this.terminals.revokeDevice(id, deviceId);
+ return this.terminals.revokeDevice(id, deviceId, user.id);
}
}
diff --git a/apps/api/src/app/terminals/terminals.dto.ts b/apps/api/src/app/terminals/terminals.dto.ts
index 2b1efa1..0484809 100644
--- a/apps/api/src/app/terminals/terminals.dto.ts
+++ b/apps/api/src/app/terminals/terminals.dto.ts
@@ -1,6 +1,9 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
IsBoolean,
+ IsArray,
+ ArrayUnique,
+ ArrayMaxSize,
IsDateString,
IsIn,
IsInt,
@@ -19,6 +22,14 @@ import { TimeEntryDto } from '../time-entries/time-entries.dto';
const MAX_LOGO_DATA_URL_LENGTH = 85_000;
export class CreateTerminalDto {
+ @ApiPropertyOptional({ enum: ['QR', 'PIN'], isArray: true, default: ['QR'] })
+ @IsOptional()
+ @IsArray()
+ @ArrayUnique()
+ @ArrayMaxSize(2)
+ @IsIn(['QR', 'PIN'], { each: true })
+ allowedAuthMethods?: Array<'QR' | 'PIN'>;
+
@ApiProperty({ example: 'Haupteingang' })
@IsString()
@MinLength(1)
@@ -147,6 +158,10 @@ export class TerminalDeviceDto {
}
export class TerminalDto {
+ @ApiProperty({ enum: ['QR', 'PIN'], isArray: true })
+ allowedAuthMethods!: string[];
+ @ApiProperty() authConfigVersion!: number;
+
@ApiProperty({ format: 'uuid' })
id!: string;
@@ -231,6 +246,10 @@ export class PairTerminalDto {
}
export class KioskTerminalDto {
+ @ApiProperty({ enum: ['QR', 'PIN'], isArray: true })
+ allowedAuthMethods!: string[];
+ @ApiProperty() authConfigVersion!: number;
+
@ApiProperty({ format: 'uuid' })
id!: string;
@@ -277,8 +296,12 @@ export class KioskStateDto {
@ApiProperty({ type: KioskTerminalDto })
terminal!: KioskTerminalDto;
- @ApiProperty({ type: TerminalChallengeDto })
- challenge!: TerminalChallengeDto;
+ @ApiProperty({ type: TerminalChallengeDto, nullable: true })
+ challenge!: TerminalChallengeDto | null;
+
+ @ApiProperty() configRefreshAfterSeconds!: number;
+ @ApiProperty() requiresLocation!: boolean;
+ @ApiProperty() pinAvailable!: boolean;
@ApiProperty({ format: 'date-time' })
serverTime!: string;
@@ -351,6 +374,8 @@ export function toTerminalDto(terminal: TerminalWithDevices): TerminalDto {
}, null);
return {
id: terminal.id,
+ allowedAuthMethods: terminal.allowedAuthMethods,
+ authConfigVersion: terminal.authConfigVersion,
name: terminal.name,
displayText: terminal.displayText,
locationLabel: terminal.locationLabel,
diff --git a/apps/api/src/app/terminals/terminals.module.ts b/apps/api/src/app/terminals/terminals.module.ts
index 391887a..8ee6be4 100644
--- a/apps/api/src/app/terminals/terminals.module.ts
+++ b/apps/api/src/app/terminals/terminals.module.ts
@@ -1,13 +1,34 @@
-import { Module } from '@nestjs/common';
+import { Global, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TerminalDeviceGuard } from './terminal-device.guard';
import { TerminalRateLimitGuard } from './terminal-rate-limit.guard';
import { TerminalsController } from './terminals.controller';
import { TerminalsService } from './terminals.service';
+import { TerminalAuditService } from './terminal-audit.service';
+import { TerminalPinService } from './terminal-pin.service';
+import { TerminalCredentialsService } from './terminal-credentials.service';
+import { TerminalCredentialsController } from './terminal-credentials.controller';
+import { TerminalAuthService } from './terminal-auth.service';
+import { TerminalAuthController } from './terminal-auth.controller';
+
+@Global()
@Module({
imports: [AuthModule],
- controllers: [TerminalsController],
- providers: [TerminalsService, TerminalDeviceGuard, TerminalRateLimitGuard],
+ controllers: [
+ TerminalAuthController,
+ TerminalCredentialsController,
+ TerminalsController,
+ ],
+ providers: [
+ TerminalsService,
+ TerminalDeviceGuard,
+ TerminalRateLimitGuard,
+ TerminalAuditService,
+ TerminalPinService,
+ TerminalCredentialsService,
+ TerminalAuthService,
+ ],
+ exports: [TerminalAuditService],
})
export class TerminalsModule {}
diff --git a/apps/api/src/app/terminals/terminals.service.ts b/apps/api/src/app/terminals/terminals.service.ts
index 818eeeb..b5d4c11 100644
--- a/apps/api/src/app/terminals/terminals.service.ts
+++ b/apps/api/src/app/terminals/terminals.service.ts
@@ -11,7 +11,13 @@ import {
import { ConfigService } from '@nestjs/config';
import { Prisma, type Terminal, type TimeEntry } from '@prisma/client';
import { createHash, createHmac, randomBytes } from 'node:crypto';
-import { requiresSpecialApproval } from 'shared';
+import { bookTerminalTime } from './terminal-booking.service';
+import { TerminalAuditService } from './terminal-audit.service';
+import { TerminalPinService } from './terminal-pin.service';
+import {
+ lockTerminalSecurity,
+ assertTerminalAdmin,
+} from './terminal-security-locks';
import { EventsGateway } from '../events/events.gateway';
import { PrismaService } from '../prisma/prisma.service';
import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
@@ -36,10 +42,6 @@ const MAX_LOGO_BYTES = 60 * 1024;
const TERMINAL_INCLUDE = {
devices: { orderBy: { createdAt: 'desc' as const } },
} as const;
-const ENTRY_INCLUDE = {
- project: { select: { code: true, name: true } },
- serviceOrder: { select: { orderNo: true, title: true } },
-} as const;
type BookingAction = 'clock-in' | 'clock-out';
type AcceptedTerminalPosition = {
@@ -61,6 +63,8 @@ export class TerminalsService {
private readonly config: ConfigService,
private readonly schedules: WorkSchedulesService,
private readonly events: EventsGateway,
+ private readonly audit: TerminalAuditService,
+ private readonly pins: TerminalPinService,
) {
const terminalSecret = this.config
.get('TERMINAL_QR_SECRET')
@@ -111,10 +115,12 @@ export class TerminalsService {
return toTerminalDto(await this.findTerminal(id));
}
- async create(dto: CreateTerminalDto): Promise {
+ async create(dto: CreateTerminalDto, actorId?: string): Promise {
const data = this.createData(dto);
const now = new Date();
const terminal = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ if (actorId) await assertTerminalAdmin(tx, actorId);
const created = await tx.terminal.create({
data: {
...data,
@@ -124,14 +130,32 @@ export class TerminalsService {
include: TERMINAL_INCLUDE,
});
if (created.isActive) await this.recordFirstActivation(tx, now);
+ await this.audit.append(
+ tx,
+ {
+ event: 'TerminalCreated',
+ result: 'SUCCESS',
+ actorId,
+ terminalId: created.id,
+ terminalName: created.name,
+ locationLabel: created.locationLabel,
+ },
+ false,
+ );
return created;
});
return toTerminalDto(terminal);
}
- async update(id: string, dto: UpdateTerminalDto): Promise {
+ async update(
+ id: string,
+ dto: UpdateTerminalDto,
+ actorId?: string,
+ ): Promise {
const now = new Date();
const terminal = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ if (actorId) await assertTerminalAdmin(tx, actorId);
const current = await tx.terminal.findUnique({ where: { id } });
if (!current) throw new NotFoundException(`Terminal ${id} not found`);
const data = this.updateData(dto, current);
@@ -151,20 +175,34 @@ export class TerminalsService {
include: TERMINAL_INCLUDE,
});
if (activates) await this.recordFirstActivation(tx, now);
+ await this.audit.append(
+ tx,
+ {
+ event: 'TerminalUpdated',
+ result: 'SUCCESS',
+ actorId,
+ terminalId: updated.id,
+ terminalName: updated.name,
+ locationLabel: updated.locationLabel,
+ },
+ false,
+ );
return updated;
});
return toTerminalDto(terminal);
}
- deactivate(id: string): Promise {
- return this.update(id, { isActive: false });
+ deactivate(id: string, actorId?: string): Promise {
+ return this.update(id, { isActive: false }, actorId);
}
- async deletePermanently(id: string): Promise {
+ async deletePermanently(id: string, actorId?: string): Promise {
await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ if (actorId) await assertTerminalAdmin(tx, actorId);
const terminal = await tx.terminal.findUnique({
where: { id },
- select: { id: true },
+ select: { id: true, name: true, locationLabel: true },
});
if (!terminal) throw new NotFoundException(`Terminal ${id} not found`);
@@ -187,11 +225,24 @@ export class TerminalsService {
data: { clockOutChallengeId: null },
});
+ await this.audit.append(
+ tx,
+ {
+ event: 'TerminalDeleted',
+ result: 'SUCCESS',
+ actorId,
+ terminalId: id,
+ terminalName: terminal.name,
+ locationLabel: terminal.locationLabel,
+ },
+ false,
+ );
+ await tx.terminalBookingGrant.deleteMany({ where: { terminalId: id } });
await tx.terminal.delete({ where: { id } });
});
}
- async createPairing(id: string): Promise {
+ async createPairing(id: string, actorId?: string): Promise {
const terminal = await this.findTerminal(id);
if (!terminal.isActive) {
throw new ConflictException(
@@ -203,12 +254,30 @@ export class TerminalsService {
const rawCode = this.randomPairingCode();
const pairingCode = `${rawCode.slice(0, 5)}-${rawCode.slice(5)}`;
try {
- const updated = await this.prisma.terminal.updateMany({
- where: { id, isActive: true },
- data: {
- pairingCodeHash: this.hashPairingCode(rawCode),
- pairingExpiresAt: expiresAt,
- },
+ const updated = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ if (actorId) await assertTerminalAdmin(tx, actorId);
+ const updated = await tx.terminal.updateMany({
+ where: { id, isActive: true },
+ data: {
+ pairingCodeHash: this.hashPairingCode(rawCode),
+ pairingExpiresAt: expiresAt,
+ },
+ });
+ if (updated.count === 1)
+ await this.audit.append(
+ tx,
+ {
+ event: 'PairingIssued',
+ result: 'SUCCESS',
+ actorId,
+ terminalId: id,
+ terminalName: terminal.name,
+ locationLabel: terminal.locationLabel,
+ },
+ false,
+ );
+ return updated;
});
if (updated.count !== 1) {
throw new ConflictException(
@@ -240,6 +309,7 @@ export class TerminalsService {
const deviceToken = `octd_${randomBytes(32).toString('base64url')}`;
const tokenHash = this.hashOpaqueToken(deviceToken);
const terminal = await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
const claimed = await tx.terminal.updateMany({
where: {
id: candidate.id,
@@ -256,7 +326,7 @@ export class TerminalsService {
where: { terminalId: candidate.id, revokedAt: null },
data: { revokedAt: now },
});
- await tx.terminalDevice.create({
+ const pairedDevice = await tx.terminalDevice.create({
data: {
terminalId: candidate.id,
name: dto.deviceName?.trim() || null,
@@ -269,12 +339,26 @@ export class TerminalsService {
include: TERMINAL_INCLUDE,
});
if (!row) throw new NotFoundException('Terminal no longer exists');
+ await this.audit.append(
+ tx,
+ {
+ event: 'DevicePaired',
+ result: 'SUCCESS',
+ terminalId: row.id,
+ deviceId: pairedDevice.id,
+ terminalName: row.name,
+ locationLabel: row.locationLabel,
+ },
+ false,
+ );
return row;
});
return {
deviceToken,
terminal: {
id: terminal.id,
+ allowedAuthMethods: terminal.allowedAuthMethods,
+ authConfigVersion: terminal.authConfigVersion,
name: terminal.name,
displayText: terminal.displayText,
locationLabel: terminal.locationLabel,
@@ -287,15 +371,35 @@ export class TerminalsService {
async revokeDevice(
terminalId: string,
deviceId: string,
+ actorId?: string,
): Promise {
- await this.findTerminal(terminalId);
- const result = await this.prisma.terminalDevice.updateMany({
- where: { id: deviceId, terminalId, revokedAt: null },
- data: { revokedAt: new Date() },
+ await this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ if (actorId) await assertTerminalAdmin(tx, actorId);
+ const terminal = await tx.terminal.findUnique({
+ where: { id: terminalId },
+ });
+ if (!terminal) throw new NotFoundException('Terminal not found');
+ const result = await tx.terminalDevice.updateMany({
+ where: { id: deviceId, terminalId, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ if (result.count !== 1)
+ throw new NotFoundException('Active terminal device not found');
+ await this.audit.append(
+ tx,
+ {
+ event: 'DeviceRevoked',
+ result: 'SUCCESS',
+ actorId,
+ terminalId,
+ deviceId,
+ terminalName: terminal.name,
+ locationLabel: terminal.locationLabel,
+ },
+ false,
+ );
});
- if (result.count !== 1) {
- throw new NotFoundException('Active terminal device not found');
- }
return this.get(terminalId);
}
@@ -335,92 +439,167 @@ export class TerminalsService {
}
async kioskState(device: TerminalDevicePrincipal): Promise {
- const terminal = await this.prisma.terminal.findFirst({
- where: { id: device.terminalId, isActive: true },
- });
- if (!terminal) throw new UnauthorizedException('Terminal is inactive');
-
- const now = new Date();
- const recentChallenges = await this.prisma.terminalChallenge.count({
- where: {
- deviceId: device.id,
- createdAt: { gt: new Date(now.getTime() - 60_000) },
- },
- });
- if (recentChallenges >= 12) {
- throw this.rateLimitException('TERMINAL_KIOSK_RATE_LIMITED');
- }
- const cleanupBefore = new Date(now.getTime() - 24 * 60 * 60 * 1000);
- await this.prisma.terminalChallenge.deleteMany({
- where: {
- terminalId: device.terminalId,
- expiresAt: { lt: cleanupBefore },
- redemptions: { none: {} },
- clockInTimeEntries: { none: {} },
- clockOutTimeEntries: { none: {} },
- },
- });
+ return this.prisma.$transaction(async (tx) => {
+ await lockTerminalSecurity(tx);
+ const freshDevice = await tx.terminalDevice.findUnique({
+ where: { id: device.id },
+ });
+ if (
+ !freshDevice ||
+ freshDevice.revokedAt ||
+ freshDevice.terminalId !== device.terminalId
+ )
+ throw new UnauthorizedException({
+ code: 'TERMINAL_DEVICE_REVOKED',
+ message: 'Terminal device revoked',
+ });
+ const terminal = await tx.terminal.findFirst({
+ where: { id: device.terminalId, isActive: true },
+ });
+ if (!terminal) throw new UnauthorizedException('Terminal is inactive');
- const rootDateKey = this.dateKey(now, terminal.timeZone);
- const dailyRoot = createHmac('sha256', this.qrMasterSecret)
- .update(`openclockwork-terminal-root:v1:${terminal.id}:${rootDateKey}`)
- .digest();
- const opaquePayload = createHmac('sha256', dailyRoot)
- .update(randomBytes(32))
- .digest('base64url');
- // The marker lets the PWA avoid requesting GPS for non-geofenced
- // terminals. It is only a UX hint: the complete token is hashed below and
- // scan() always enforces the terminal's current server-side setting.
- const payload = `ocw1.${terminal.enforceGeofence ? 'g' : 'n'}.${opaquePayload}`;
- const configuredExpiry = new Date(
- now.getTime() + this.challengeTtlSeconds * 1000,
- );
- const expiresAt = new Date(
- Math.min(
- configuredExpiry.getTime(),
- this.nextLocalMidnight(now, terminal.timeZone).getTime(),
- ),
- );
- const challengeLifetimeSeconds = Math.max(
- 1,
- Math.floor((expiresAt.getTime() - now.getTime()) / 1000),
- );
- await this.prisma.terminalChallenge.create({
- data: {
- terminalId: terminal.id,
- deviceId: device.id,
- tokenHash: this.hashOpaqueToken(payload),
- rootDate: new Date(`${rootDateKey}T00:00:00.000Z`),
- expiresAt,
- },
- });
- return {
- terminal: {
+ const publicTerminal = {
id: terminal.id,
name: terminal.name,
displayText: terminal.displayText,
locationLabel: terminal.locationLabel,
logoUrl: terminal.logoUrl,
timeZone: terminal.timeZone,
- },
- challenge: {
- payload,
- expiresAt: expiresAt.toISOString(),
- refreshAfterSeconds: Math.max(
- 1,
- Math.min(
- Math.max(10, this.challengeTtlSeconds - 15),
- challengeLifetimeSeconds - 5,
- ),
+ allowedAuthMethods: terminal.allowedAuthMethods,
+ authConfigVersion: terminal.authConfigVersion,
+ };
+ if (!terminal.allowedAuthMethods.includes('QR'))
+ return {
+ terminal: publicTerminal,
+ challenge: null,
+ serverTime: new Date().toISOString(),
+ configRefreshAfterSeconds: 30,
+ requiresLocation: terminal.enforceGeofence,
+ pinAvailable: this.pins.available,
+ };
+ const now = new Date();
+ const recentChallenges = await tx.terminalChallenge.count({
+ where: {
+ deviceId: device.id,
+ purpose: 'DisplayQr',
+ createdAt: { gt: new Date(now.getTime() - 60_000) },
+ },
+ });
+ if (recentChallenges >= 12) {
+ throw this.rateLimitException('TERMINAL_KIOSK_RATE_LIMITED');
+ }
+ const cleanupBefore = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ await tx.terminalChallenge.deleteMany({
+ where: {
+ terminalId: device.terminalId,
+ expiresAt: { lt: cleanupBefore },
+ redemptions: { none: {} },
+ clockInTimeEntries: { none: {} },
+ clockOutTimeEntries: { none: {} },
+ },
+ });
+
+ const rootDateKey = this.dateKey(now, terminal.timeZone);
+ const dailyRoot = createHmac('sha256', this.qrMasterSecret)
+ .update(`openclockwork-terminal-root:v1:${terminal.id}:${rootDateKey}`)
+ .digest();
+ const opaquePayload = createHmac('sha256', dailyRoot)
+ .update(randomBytes(32))
+ .digest('base64url');
+ // The marker lets the PWA avoid requesting GPS for non-geofenced
+ // terminals. It is only a UX hint: the complete token is hashed below and
+ // scan() always enforces the terminal's current server-side setting.
+ const payload = `ocw1.${terminal.enforceGeofence ? 'g' : 'n'}.${opaquePayload}`;
+ const configuredExpiry = new Date(
+ now.getTime() + this.challengeTtlSeconds * 1000,
+ );
+ const expiresAt = new Date(
+ Math.min(
+ configuredExpiry.getTime(),
+ this.nextLocalMidnight(now, terminal.timeZone).getTime(),
),
- },
- serverTime: now.toISOString(),
- };
+ );
+ const challengeLifetimeSeconds = Math.max(
+ 1,
+ Math.floor((expiresAt.getTime() - now.getTime()) / 1000),
+ );
+ await tx.terminalChallenge.create({
+ data: {
+ terminalId: terminal.id,
+ deviceId: device.id,
+ tokenHash: this.hashOpaqueToken(payload),
+ purpose: 'DisplayQr',
+ authConfigVersion: terminal.authConfigVersion,
+ rootDate: new Date(`${rootDateKey}T00:00:00.000Z`),
+ expiresAt,
+ },
+ });
+ return {
+ terminal: {
+ id: terminal.id,
+ allowedAuthMethods: terminal.allowedAuthMethods,
+ authConfigVersion: terminal.authConfigVersion,
+ name: terminal.name,
+ displayText: terminal.displayText,
+ locationLabel: terminal.locationLabel,
+ logoUrl: terminal.logoUrl,
+ timeZone: terminal.timeZone,
+ },
+ challenge: {
+ payload,
+ expiresAt: expiresAt.toISOString(),
+ refreshAfterSeconds: Math.max(
+ 1,
+ Math.min(
+ Math.max(10, this.challengeTtlSeconds - 15),
+ challengeLifetimeSeconds - 5,
+ ),
+ ),
+ },
+ serverTime: now.toISOString(),
+ configRefreshAfterSeconds: 30,
+ requiresLocation: terminal.enforceGeofence,
+ pinAvailable: this.pins.available,
+ };
+ });
}
async scan(
dto: ScanTerminalDto,
employeeId: string,
+ ): Promise {
+ try {
+ return await this.scanValidated(dto, employeeId);
+ } catch (error) {
+ // The existing authenticated scan guard bounds this path to twelve
+ // requests/minute per employee per process. Never record the QR token.
+ if (this.audit.configured) {
+ const challenge = await this.prisma.terminalChallenge.findUnique({
+ where: { tokenHash: this.hashOpaqueToken(dto.qrPayload) },
+ include: { terminal: true },
+ });
+ await this.audit.failure(
+ {
+ event: 'BookingRejected',
+ result: 'REQUEST_REJECTED',
+ method: 'QR',
+ action: dto.action,
+ employeeId,
+ terminalId: challenge?.terminalId,
+ deviceId: challenge?.deviceId,
+ terminalName: challenge?.terminal.name,
+ locationLabel: challenge?.terminal.locationLabel,
+ },
+ false,
+ );
+ }
+ throw error;
+ }
+ }
+
+ private async scanValidated(
+ dto: ScanTerminalDto,
+ employeeId: string,
): Promise {
const now = new Date();
const recentRedemptions =
@@ -438,7 +617,7 @@ export class TerminalsService {
where: { tokenHash },
include: { terminal: true, device: true },
});
- if (!challenge) {
+ if (!challenge || challenge.purpose !== 'DisplayQr') {
throw new BadRequestException({
code: 'TERMINAL_QR_INVALID',
message: 'QR challenge is invalid',
@@ -454,6 +633,11 @@ export class TerminalsService {
message: 'QR challenge has expired',
});
}
+ if (!challenge.terminal.allowedAuthMethods.includes('QR'))
+ throw new ForbiddenException({
+ code: 'TERMINAL_METHOD_DISABLED',
+ message: 'QR is disabled',
+ });
if (!challenge.terminal.isActive || challenge.device.revokedAt !== null) {
throw new ForbiddenException({
code: 'TERMINAL_INACTIVE',
@@ -498,6 +682,8 @@ export class TerminalsService {
});
if (
!fresh ||
+ fresh.purpose !== 'DisplayQr' ||
+ !fresh.terminal.allowedAuthMethods.includes('QR') ||
fresh.expiresAt <= bookingNow ||
!fresh.terminal.isActive ||
fresh.device.revokedAt !== null ||
@@ -531,118 +717,33 @@ export class TerminalsService {
});
}
- const open = await tx.timeEntry.findFirst({
- where: { employeeId, clockOut: null },
- orderBy: { clockIn: 'desc' },
- });
- action = open ? 'clock-out' : 'clock-in';
- if (dto.action && dto.action !== action) {
- throw new ConflictException({
- code: 'TERMINAL_ACTION_MISMATCH',
- message: `Current employee state requires ${action}`,
+ const booked = await bookTerminalTime(
+ tx,
+ employeeId,
+ fresh,
+ bookingNow,
+ schedule,
+ freshPosition,
+ dto.action,
+ );
+ action = booked.action;
+ await this.audit.append(
+ tx,
+ {
+ event: 'BookingAccepted',
+ result: 'SUCCESS',
+ method: 'QR',
action,
- });
- }
-
- let booked: TimeEntry & {
- project: { code: string; name: string } | null;
- serviceOrder: { orderNo: string; title: string } | null;
- };
- if (action === 'clock-in') {
- const today = localTodayAsUtcDate(bookingNow);
- const dailyBlock = await tx.timeEntry.findUnique({
- where: {
- employeeId_bookingDate: { employeeId, bookingDate: today },
- },
- select: { id: true },
- });
- if (dailyBlock) {
- throw new ConflictException(
- 'Today already has a daily-block booking',
- );
- }
- booked = await tx.timeEntry.create({
- data: {
- employeeId,
- clockIn: bookingNow,
- source: 'Terminal',
- breakRules: schedule.breakRules,
- status: 'Open',
- requiresApproval: requiresSpecialApproval(
- bookingNow,
- null,
- schedule.frame,
- ),
- latitude: freshPosition?.latitude ?? null,
- longitude: freshPosition?.longitude ?? null,
- accuracyMeters: freshPosition?.accuracyMeters ?? null,
- terminalDistanceMeters: freshPosition?.distanceMeters ?? null,
- terminalRadiusMeters: freshPosition
- ? fresh.terminal.radiusMeters
- : null,
- terminalMaxAccuracyMeters: freshPosition
- ? fresh.terminal.maxAccuracyMeters
- : null,
- positionTimestamp: freshPosition?.positionTimestamp ?? null,
- terminalLocationLabel: fresh.terminal.locationLabel,
- terminalId: fresh.terminalId,
- clockInChallengeId: fresh.id,
- },
- include: ENTRY_INCLUDE,
- });
- } else {
- if (!open) throw new ConflictException('No open entry to close');
- if (bookingNow <= open.clockIn) {
- throw new BadRequestException('Clock-out must be after clock-in');
- }
- const requires = requiresSpecialApproval(
- open.clockIn,
- bookingNow,
- schedule.frame,
- );
- const updated = await tx.timeEntry.updateMany({
- where: { id: open.id, clockOut: null },
- data: {
- clockOut: bookingNow,
- status: requires ? 'Pending' : 'Approved',
- requiresApproval: requires,
- clockOutLatitude: freshPosition?.latitude ?? null,
- clockOutLongitude: freshPosition?.longitude ?? null,
- clockOutAccuracyMeters: freshPosition?.accuracyMeters ?? null,
- clockOutTerminalDistanceMeters:
- freshPosition?.distanceMeters ?? null,
- clockOutTerminalRadiusMeters: freshPosition
- ? fresh.terminal.radiusMeters
- : null,
- clockOutTerminalMaxAccuracyMeters: freshPosition
- ? fresh.terminal.maxAccuracyMeters
- : null,
- clockOutPositionTimestamp:
- freshPosition?.positionTimestamp ?? null,
- clockOutTerminalLocationLabel: fresh.terminal.locationLabel,
- clockOutTerminalId: fresh.terminalId,
- clockOutChallengeId: fresh.id,
- },
- });
- if (updated.count !== 1) {
- throw new ConflictException('Time entry was already closed');
- }
- const row = await tx.timeEntry.findUnique({
- where: { id: open.id },
- include: ENTRY_INCLUDE,
- });
- if (!row) throw new NotFoundException('Time entry not found');
- booked = row;
- }
- await tx.terminalChallengeRedemption.create({
- data: {
- challengeId: fresh.id,
employeeId,
- timeEntryId: booked.id,
- action,
+ terminalId: fresh.terminalId,
+ deviceId: fresh.deviceId,
+ terminalName: fresh.terminal.name,
+ locationLabel: fresh.terminal.locationLabel,
+ timeEntryId: booked.entry.id,
},
- });
- return booked;
+ false,
+ );
+ return booked.entry;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted },
);
@@ -689,6 +790,18 @@ export class TerminalsService {
return terminal;
}
+ private validateMethods(methods: string[], active: boolean): string[] {
+ if (
+ !Array.isArray(methods) ||
+ methods.some((m) => m !== 'QR' && m !== 'PIN') ||
+ new Set(methods).size !== methods.length ||
+ (active && methods.length === 0)
+ )
+ throw new BadRequestException('Select valid unique terminal methods');
+ if (methods.includes('PIN')) this.pins.assertAvailable();
+ return methods;
+ }
+
private createData(dto: CreateTerminalDto) {
const timeZone = (dto.timeZone ?? 'UTC').trim();
this.assertTimeZone(timeZone);
@@ -699,6 +812,10 @@ export class TerminalsService {
displayText: this.requiredTrimmed(dto.displayText, 'displayText'),
locationLabel: this.requiredTrimmed(dto.locationLabel, 'locationLabel'),
logoUrl: this.normalizeLogoUrl(dto.logoUrl),
+ allowedAuthMethods: this.validateMethods(
+ dto.allowedAuthMethods ?? ['QR'],
+ dto.isActive ?? false,
+ ),
enforceGeofence,
...geofence,
timeZone,
@@ -710,6 +827,25 @@ export class TerminalsService {
current: Terminal,
): Prisma.TerminalUpdateInput {
const data: Prisma.TerminalUpdateInput = {};
+ if (dto.allowedAuthMethods !== undefined || dto.isActive === true) {
+ const methods = this.validateMethods(
+ dto.allowedAuthMethods ?? current.allowedAuthMethods,
+ dto.isActive ?? current.isActive,
+ );
+ if (dto.allowedAuthMethods !== undefined)
+ data.allowedAuthMethods = methods;
+ }
+ if (
+ dto.allowedAuthMethods !== undefined ||
+ dto.enforceGeofence !== undefined ||
+ dto.latitude !== undefined ||
+ dto.longitude !== undefined ||
+ dto.radiusMeters !== undefined ||
+ dto.maxAccuracyMeters !== undefined ||
+ dto.timeZone !== undefined ||
+ dto.isActive !== undefined
+ )
+ data.authConfigVersion = { increment: 1 };
if (dto.name !== undefined)
data.name = this.requiredTrimmed(dto.name, 'name');
if (dto.displayText !== undefined) {
@@ -850,7 +986,7 @@ export class TerminalsService {
}
}
- private dateKey(date: Date, timeZone: string): string {
+ dateKey(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
@@ -867,7 +1003,7 @@ export class TerminalsService {
* calendar day. Comparing formatted local date keys keeps this correct for
* 23/25-hour DST days without maintaining a separate time-zone offset table.
*/
- private nextLocalMidnight(date: Date, timeZone: string): Date {
+ nextLocalMidnight(date: Date, timeZone: string): Date {
const currentDateKey = this.dateKey(date, timeZone);
let beforeBoundary = date.getTime();
let afterBoundary = beforeBoundary + 30 * 60 * 60 * 1000;
@@ -925,11 +1061,14 @@ export class TerminalsService {
tx: Prisma.TransactionClient,
employeeId: string,
): Promise {
- await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`;
+ await lockTerminalSecurity(tx, employeeId);
}
- private evaluateTerminalPosition(
- dto: ScanTerminalDto,
+ evaluateTerminalPosition(
+ dto: Pick<
+ ScanTerminalDto,
+ 'latitude' | 'longitude' | 'accuracyMeters' | 'positionTimestamp'
+ >,
terminal: Pick<
Terminal,
| 'enforceGeofence'
@@ -1052,7 +1191,3 @@ export class TerminalsService {
);
}
}
-
-function localTodayAsUtcDate(now = new Date()): Date {
- return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
-}
diff --git a/apps/api/src/generate-openapi.ts b/apps/api/src/generate-openapi.ts
index 1410b80..c8091e1 100644
--- a/apps/api/src/generate-openapi.ts
+++ b/apps/api/src/generate-openapi.ts
@@ -33,7 +33,7 @@ async function main() {
.setDescription(
'Self-hostable working-time tracker — REST + WebSocket surface.',
)
- .setVersion('2.0.1')
+ .setVersion('2.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 059f3ba..1a576d3 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -38,7 +38,7 @@ async function bootstrap() {
.setDescription(
'Self-hostable working-time tracker — REST + WebSocket surface.',
)
- .setVersion('2.0.1')
+ .setVersion('2.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts
index d5b97a2..d876d5d 100644
--- a/apps/web-e2e/playwright.config.ts
+++ b/apps/web-e2e/playwright.config.ts
@@ -20,15 +20,21 @@ export default defineConfig({
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: 'on-first-retry',
+ // Terminal tests handle synthetic PINs and device credentials. Never retain
+ // request bodies in traces, screenshots or videos by default.
+ trace: 'off',
+ screenshot: 'off',
+ video: 'off',
},
/* Run your local dev server before starting the tests */
- webServer: {
- command: 'pnpm exec nx run web:preview',
- url: 'http://localhost:4200',
- reuseExistingServer: true,
- cwd: workspaceRoot,
- },
+ webServer: process.env['BASE_URL']
+ ? undefined
+ : {
+ command: 'pnpm exec nx run web:preview',
+ url: 'http://localhost:4200',
+ reuseExistingServer: true,
+ cwd: workspaceRoot,
+ },
projects: [
{
name: 'chromium',
diff --git a/apps/web-e2e/src/smoke.spec.ts b/apps/web-e2e/src/smoke.spec.ts
index ba8263c..536989f 100644
--- a/apps/web-e2e/src/smoke.spec.ts
+++ b/apps/web-e2e/src/smoke.spec.ts
@@ -56,7 +56,9 @@ test('the login form refuses bogus credentials with a visible error', async ({
await page.getByLabel('E-Mail').fill('nope@nope.nope');
await page.getByLabel('Passwort').fill('definitely-wrong');
await page.getByRole('button', { name: 'Anmelden' }).click();
- await expect(page.getByText(/Invalid credentials/i)).toBeVisible();
+ await expect(page.getByRole('alert')).toHaveText(
+ 'E-Mail-Adresse oder Passwort ist falsch.',
+ );
});
test('the unpaired kiosk is public and uses its dedicated home-screen manifest', async ({
diff --git a/apps/web-e2e/src/terminal-pin.spec.ts b/apps/web-e2e/src/terminal-pin.spec.ts
new file mode 100644
index 0000000..ec70686
--- /dev/null
+++ b/apps/web-e2e/src/terminal-pin.spec.ts
@@ -0,0 +1,257 @@
+import { randomUUID } from 'node:crypto';
+import {
+ test as base,
+ expect,
+ type APIRequestContext,
+ type Page,
+} from '@playwright/test';
+
+type TerminalFixture = {
+ employeeId: string;
+ personalNo: string;
+ pin: string;
+ pairingCode: string;
+};
+
+function assertLocalTestTarget(baseURL: string | undefined) {
+ const target = new URL(baseURL ?? 'http://invalid.test');
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(
+ target.hostname,
+ );
+ expect(
+ Boolean(process.env.BASE_URL) &&
+ loopback &&
+ ['http:', 'https:'].includes(target.protocol) &&
+ Number(target.port) >= 18_000 &&
+ !target.username &&
+ !target.password,
+ 'Terminal PIN tests require an explicit loopback BASE_URL with a test port >= 18000.',
+ ).toBe(true);
+}
+
+/** Real API + isolated synthetic records. Never target a production installation. */
+const test = base.extend<{ terminal: TerminalFixture }>({
+ terminal: async ({ request, baseURL }, use) => {
+ // Check before the first request: these tests create and revoke HR records.
+ assertLocalTestTarget(baseURL);
+ const login = await request.post('/api/auth/login', {
+ data: {
+ email: process.env.SMOKE_EMAIL ?? 'hannah.roth@openclockwork.test',
+ password: process.env.SMOKE_PASSWORD ?? 'openclockwork',
+ },
+ });
+ expect(login.ok()).toBe(true);
+ const { accessToken } = await login.json();
+ const headers = { Authorization: `Bearer ${accessToken}` };
+ const suffix = randomUUID().slice(0, 12);
+ const personalNo = `00-pin-${suffix}`;
+ let employeeId: string | undefined;
+ let terminalId: string | undefined;
+ try {
+ const employee = await request.post('/api/employees', {
+ headers,
+ data: {
+ personalNo,
+ firstName: 'Synthetic',
+ lastName: 'Terminal',
+ email: `pin-${suffix}@example.test`,
+ password: 'synthetic-test-password',
+ role: 'Employee',
+ timeModel: 'Gleitzeit',
+ weeklyHours: 40,
+ annualLeaveDays: 20,
+ startDate: '2020-01-01',
+ holidayCalendar: 'NONE',
+ managerId: null,
+ workScheduleId: null,
+ },
+ });
+ expect(employee.ok()).toBe(true);
+ const employeeRecord = (await employee.json()) as { id: string };
+ employeeId = employeeRecord.id;
+ const created = await request.post('/api/terminals', {
+ headers,
+ data: {
+ name: `PIN E2E ${suffix}`,
+ displayText: 'Synthetic PIN terminal',
+ locationLabel: 'Test location',
+ logoUrl: null,
+ timeZone: 'Europe/Berlin',
+ enforceGeofence: false,
+ latitude: null,
+ longitude: null,
+ radiusMeters: null,
+ maxAccuracyMeters: null,
+ isActive: true,
+ allowedAuthMethods: ['PIN'],
+ },
+ });
+ expect(created.ok()).toBe(true);
+ terminalId = (await created.json()).id;
+ const reset = await request.post(
+ `/api/employees/${employeeId}/terminal-credentials/pin/reset`,
+ { headers, data: {} },
+ );
+ expect(reset.ok()).toBe(true);
+ const { pin } = await reset.json();
+ const pairing = await request.post(
+ `/api/terminals/${terminalId}/pairing`,
+ { headers },
+ );
+ expect(pairing.ok()).toBe(true);
+ const { pairingCode } = await pairing.json();
+ await use({
+ employeeId: employeeRecord.id,
+ personalNo,
+ pin,
+ pairingCode,
+ });
+ } finally {
+ await cleanup(request, headers, terminalId, employeeId);
+ }
+ },
+});
+
+async function cleanup(
+ request: APIRequestContext,
+ headers: Record,
+ terminalId?: string,
+ employeeId?: string,
+) {
+ if (terminalId)
+ await request.delete(`/api/terminals/${terminalId}/permanent`, { headers });
+ if (employeeId)
+ await request.delete(`/api/employees/${employeeId}`, { headers });
+}
+
+test.use({ locale: 'de-DE', trace: 'off', screenshot: 'off', video: 'off' });
+
+async function pair(page: Page, terminal: TerminalFixture) {
+ await page.goto(`/kiosk?pairing=${encodeURIComponent(terminal.pairingCode)}`);
+ await expect(
+ page.getByRole('button', { name: 'Mit PIN anmelden' }),
+ ).toBeVisible();
+ await expect(page).toHaveURL(/\/kiosk$/);
+}
+
+async function identify(
+ page: Page,
+ terminal: TerminalFixture,
+ pin = terminal.pin,
+) {
+ await page.getByRole('button', { name: 'Mit PIN anmelden' }).click();
+ await page
+ .getByLabel('Personalnummer', { exact: true })
+ .fill(terminal.personalNo);
+ await page.getByLabel('Terminal-PIN', { exact: true }).fill(pin);
+ await page.getByRole('button', { name: 'Anmeldung prüfen' }).click();
+}
+
+test('Terminal PIN: paired PIN-only terminal explicitly clocks in and out without an employee session', async ({
+ page,
+ terminal,
+}) => {
+ await pair(page, terminal);
+ await expect(
+ page.getByAltText('Kurzzeitig gültiger QR-Code für dieses Terminal'),
+ ).toHaveCount(0);
+ await identify(page, terminal);
+ await expect(
+ page.getByRole('heading', { name: /Buchung für Synthetic T/ }),
+ ).toBeVisible();
+ await expect(
+ page.getByText('Einstempeln bestätigt', { exact: true }),
+ ).toHaveCount(0);
+ await page.getByRole('button', { name: 'Jetzt einstempeln' }).click();
+ await expect(
+ page.getByText('Einstempeln bestätigt', { exact: true }),
+ ).toBeVisible();
+ await page.getByRole('button', { name: 'Vorgang beenden' }).click();
+ await identify(page, terminal);
+ await page.getByRole('button', { name: 'Jetzt ausstempeln' }).click();
+ await expect(
+ page.getByText('Ausstempeln bestätigt', { exact: true }),
+ ).toBeVisible();
+ const hasEmployeeSession = await page.evaluate(() => {
+ const storage = (
+ globalThis as unknown as {
+ localStorage: { getItem(key: string): string | null };
+ }
+ ).localStorage;
+ return Boolean(
+ storage.getItem('openclockwork.accessToken') ||
+ storage.getItem('openclockwork.refreshToken'),
+ );
+ });
+ expect(hasEmployeeSession).toBe(false);
+ await expect(page.getByLabel('Terminal-PIN', { exact: true })).toHaveCount(0);
+});
+
+test('Terminal PIN: incorrect PIN preserves pairing and cancellation does not clock in', async ({
+ page,
+ terminal,
+}) => {
+ await pair(page, terminal);
+ await identify(
+ page,
+ terminal,
+ terminal.pin === '00000000' ? '11111111' : '00000000',
+ );
+ await expect(page.getByText(/Anmeldung nicht möglich/)).toBeVisible();
+ const paired = await page.evaluate(() => {
+ const storage = (
+ globalThis as unknown as {
+ localStorage: { getItem(key: string): string | null };
+ }
+ ).localStorage;
+ return Boolean(storage.getItem('openclockwork.terminalDeviceToken'));
+ });
+ expect(paired).toBe(true);
+ await identify(page, terminal);
+ await expect(
+ page.getByRole('button', { name: 'Jetzt einstempeln' }),
+ ).toBeVisible();
+ await page.getByRole('button', { name: 'Vorgang beenden' }).click();
+ await expect(page.getByRole('heading', { name: /Buchung für/ })).toHaveCount(
+ 0,
+ );
+ await identify(page, terminal);
+ await expect(
+ page.getByRole('button', { name: 'Jetzt einstempeln' }),
+ ).toBeVisible();
+});
+
+test('Terminal PIN: HR can inspect PIN status and the real audit log without redisplaying a PIN', async ({
+ page,
+ terminal,
+}) => {
+ await page.goto('/');
+ await page
+ .getByLabel('E-Mail')
+ .fill(process.env.SMOKE_EMAIL ?? 'hannah.roth@openclockwork.test');
+ await page
+ .getByLabel('Passwort')
+ .fill(process.env.SMOKE_PASSWORD ?? 'openclockwork');
+ await page.getByRole('button', { name: 'Anmelden' }).click();
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
+ await page.goto('/admin/employees');
+ const row = page.getByRole('row').filter({ hasText: terminal.personalNo });
+ await row.getByRole('button', { name: 'Terminal-PIN verwalten' }).click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByText('PIN aktiv', { exact: true })).toBeVisible();
+ await expect(dialog.locator('output')).toHaveCount(0);
+ await dialog.getByRole('button', { name: 'Schließen', exact: true }).click();
+
+ await page.goto('/admin/settings/terminals');
+ await page.getByRole('button', { name: 'Protokoll laden' }).click();
+ await page.getByLabel('Methode', { exact: true }).selectOption('PIN');
+ await expect(
+ page
+ .getByRole('row')
+ .filter({ hasText: terminal.employeeId })
+ .filter({
+ has: page.locator('[title="PinReset"]'),
+ }),
+ ).toBeVisible();
+ await expect(page.locator('output')).toHaveCount(0);
+});
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 7fc0692..d75410f 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -571,6 +571,8 @@ export interface TerminalDto {
locationLabel: string;
logoUrl: string | null;
timeZone: string;
+ allowedAuthMethods?: TerminalAuthMethod[];
+ authConfigVersion?: number;
enforceGeofence: boolean;
latitude: number | null;
longitude: number | null;
@@ -598,6 +600,7 @@ export interface UpsertTerminalPayload {
locationLabel: string;
logoUrl: string | null;
timeZone: string;
+ allowedAuthMethods?: TerminalAuthMethod[];
enforceGeofence: boolean;
latitude: number | null;
longitude: number | null;
@@ -617,6 +620,37 @@ export interface TerminalSupportPromptDto {
}
export type TerminalBookingAction = 'clock-in' | 'clock-out';
+export type TerminalAuthMethod = 'QR' | 'PIN';
+
+export interface TerminalPinStatus {
+ method: 'PIN';
+ isConfigured: boolean;
+ enabled: boolean;
+ lockedUntil: string | null;
+ updatedAt: string | null;
+}
+
+export interface TerminalAuditEvent {
+ id: string;
+ sequence: string;
+ occurredAt: string;
+ event: string;
+ result: string;
+ method: TerminalAuthMethod | null;
+ action: TerminalBookingAction | null;
+ employeeId: string | null;
+ actorId: string | null;
+ terminalId: string | null;
+ deviceId: string | null;
+ terminalName: string | null;
+ locationLabel: string | null;
+ timeEntryId: string | null;
+}
+
+export interface TerminalAuditPage {
+ items: TerminalAuditEvent[];
+ nextCursor: string | null;
+}
export interface TerminalBookingPayload {
qrPayload: string;
@@ -1180,6 +1214,42 @@ export const api = {
),
terminals: () => request('/api/terminals'),
+ terminalCredentials: (employeeId: string, signal?: AbortSignal) =>
+ request(
+ `/api/employees/${employeeId}/terminal-credentials`,
+ { cache: 'no-store', signal },
+ ),
+ resetTerminalPin: (employeeId: string, signal?: AbortSignal) =>
+ request<{ pin: string; credential: TerminalPinStatus }>(
+ `/api/employees/${employeeId}/terminal-credentials/pin/reset`,
+ { method: 'POST', cache: 'no-store', signal, body: '{}' },
+ ),
+ disableTerminalPin: (employeeId: string, signal?: AbortSignal) =>
+ request(
+ `/api/employees/${employeeId}/terminal-credentials/pin`,
+ { method: 'DELETE', cache: 'no-store', signal },
+ ),
+ unlockTerminalPin: (employeeId: string, signal?: AbortSignal) =>
+ request(
+ `/api/employees/${employeeId}/terminal-credentials/pin/unlock`,
+ { method: 'POST', cache: 'no-store', signal, body: '{}' },
+ ),
+ terminalAuditEvents: (
+ filters: {
+ terminalId?: string;
+ method?: TerminalAuthMethod;
+ cursor?: string;
+ },
+ signal?: AbortSignal,
+ ) => {
+ const params = new URLSearchParams({ limit: '25' });
+ for (const [key, value] of Object.entries(filters))
+ if (value) params.set(key, value);
+ return request(`/api/terminals/audit-events?${params}`, {
+ cache: 'no-store',
+ signal,
+ });
+ },
createTerminal: (payload: UpsertTerminalPayload) =>
request('/api/terminals', {
method: 'POST',
diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts
index b56a80e..ff54feb 100644
--- a/apps/web/src/api/generated.ts
+++ b/apps/web/src/api/generated.ts
@@ -852,6 +852,134 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/terminals/kiosk/challenge': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TerminalAuthController_challenge'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/terminals/kiosk/identify': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TerminalAuthController_identify'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/terminals/kiosk/book': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TerminalAuthController_book'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/terminals/audit-events': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['TerminalAuthController_events'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/employees/{id}/terminal-credentials': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['TerminalCredentialsController_get'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/employees/{id}/terminal-credentials/pin/reset': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TerminalCredentialsController_reset'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/employees/{id}/terminal-credentials/pin': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations['TerminalCredentialsController_revoke'];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/employees/{id}/terminal-credentials/pin/unlock': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TerminalCredentialsController_unlock'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/terminals/support-prompt': {
parameters: {
query?: never;
@@ -1710,7 +1838,7 @@ export interface components {
status: string;
/** @example openclockwork-api */
service: string;
- /** @example 2.0.1 */
+ /** @example 2.1.0 */
version: string;
/** Format: date-time */
utcTimestamp: string;
@@ -2392,6 +2520,82 @@ export interface components {
serviceOrderId?: string | null;
activity?: string | null;
};
+ IdentifyChallengeDto: {
+ payload: string;
+ /** Format: date-time */
+ expiresAt: string;
+ };
+ IdentifyTerminalDto: {
+ /** @enum {string} */
+ method: 'PIN';
+ challengePayload: string;
+ /** @description Exact personal number, preserving leading zeros. */
+ personalNo: string;
+ pin: string;
+ };
+ IdentifyTerminalResultDto: {
+ grant: string;
+ /** Format: date-time */
+ expiresAt: string;
+ displayName: string;
+ /** @enum {string} */
+ action: 'clock-in' | 'clock-out';
+ requiresLocation: boolean;
+ };
+ BookTerminalDto: {
+ grant: string;
+ /** @enum {string} */
+ action: 'clock-in' | 'clock-out';
+ latitude?: number;
+ longitude?: number;
+ accuracyMeters?: number;
+ /** Format: date-time */
+ positionTimestamp?: string;
+ };
+ BookTerminalResultDto: {
+ /** @enum {string} */
+ code: 'TERMINAL_BOOKED';
+ /** @enum {string} */
+ action: 'clock-in' | 'clock-out';
+ /** Format: date-time */
+ bookedAt: string;
+ /** Format: uuid */
+ receiptId: string;
+ };
+ TerminalAuditEventDto: {
+ id: string;
+ sequence: string;
+ /** Format: date-time */
+ occurredAt: string;
+ event: string;
+ result: string;
+ method: string | null;
+ action: string | null;
+ employeeId: string | null;
+ actorId: string | null;
+ terminalId: string | null;
+ deviceId: string | null;
+ terminalName: string | null;
+ locationLabel: string | null;
+ timeEntryId: string | null;
+ };
+ TerminalAuditPageDto: {
+ items: components['schemas']['TerminalAuditEventDto'][];
+ nextCursor: string | null;
+ };
+ TerminalCredentialStatusDto: {
+ /** @enum {string} */
+ method: 'PIN';
+ isConfigured: boolean;
+ enabled: boolean;
+ lockedUntil: string | null;
+ updatedAt: string | null;
+ };
+ ResetTerminalPinResultDto: {
+ /** @description One-time secret. Never cache or log this response. */
+ pin: string;
+ credential: components['schemas']['TerminalCredentialStatusDto'];
+ };
SupportPromptDto: {
/** Format: date-time */
shownAt?: string | null;
@@ -2403,6 +2607,8 @@ export interface components {
deviceName?: string;
};
KioskTerminalDto: {
+ allowedAuthMethods: ('QR' | 'PIN')[];
+ authConfigVersion: number;
/** Format: uuid */
id: string;
name: string;
@@ -2425,7 +2631,10 @@ export interface components {
};
KioskStateDto: {
terminal: components['schemas']['KioskTerminalDto'];
- challenge: components['schemas']['TerminalChallengeDto'];
+ challenge: components['schemas']['TerminalChallengeDto'] | null;
+ configRefreshAfterSeconds: number;
+ requiresLocation: boolean;
+ pinAvailable: boolean;
/** Format: date-time */
serverTime: string;
};
@@ -2461,6 +2670,8 @@ export interface components {
revokedAt?: string | null;
};
TerminalDto: {
+ allowedAuthMethods: ('QR' | 'PIN')[];
+ authConfigVersion: number;
/** Format: uuid */
id: string;
name: string;
@@ -2487,6 +2698,12 @@ export interface components {
updatedAt: string;
};
CreateTerminalDto: {
+ /**
+ * @default [
+ * "QR"
+ * ]
+ */
+ allowedAuthMethods: ('QR' | 'PIN')[];
/** @example Haupteingang */
name: string;
/** @example Zum Ein- oder Ausstempeln QR-Code scannen */
@@ -2520,6 +2737,12 @@ export interface components {
isActive: boolean;
};
UpdateTerminalDto: {
+ /**
+ * @default [
+ * "QR"
+ * ]
+ */
+ allowedAuthMethods: ('QR' | 'PIN')[];
/** @example Haupteingang */
name?: string;
/** @example Zum Ein- oder Ausstempeln QR-Code scannen */
@@ -4204,6 +4427,181 @@ export interface operations {
};
};
};
+ TerminalAuthController_challenge: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['IdentifyChallengeDto'];
+ };
+ };
+ };
+ };
+ TerminalAuthController_identify: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['IdentifyTerminalDto'];
+ };
+ };
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['IdentifyTerminalResultDto'];
+ };
+ };
+ };
+ };
+ TerminalAuthController_book: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['BookTerminalDto'];
+ };
+ };
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['BookTerminalResultDto'];
+ };
+ };
+ };
+ };
+ TerminalAuthController_events: {
+ parameters: {
+ query?: {
+ limit?: number;
+ cursor?: string;
+ terminalId?: string;
+ employeeId?: string;
+ method?: 'QR' | 'PIN';
+ result?: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TerminalAuditPageDto'];
+ };
+ };
+ };
+ };
+ TerminalCredentialsController_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TerminalCredentialStatusDto'];
+ };
+ };
+ };
+ };
+ TerminalCredentialsController_reset: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['ResetTerminalPinResultDto'];
+ };
+ };
+ };
+ };
+ TerminalCredentialsController_revoke: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TerminalCredentialStatusDto'];
+ };
+ };
+ };
+ };
+ TerminalCredentialsController_unlock: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TerminalCredentialStatusDto'];
+ };
+ };
+ };
+ };
TerminalsController_supportPrompt: {
parameters: {
query?: never;
diff --git a/apps/web/src/api/terminal-device.spec.ts b/apps/web/src/api/terminal-device.spec.ts
new file mode 100644
index 0000000..8915383
--- /dev/null
+++ b/apps/web/src/api/terminal-device.spec.ts
@@ -0,0 +1,69 @@
+import { vi } from 'vitest';
+import {
+ bookAtKiosk,
+ identifyAtKiosk,
+ readTerminalDeviceToken,
+ storeTerminalDeviceToken,
+} from './terminal-device';
+
+const fetchMock = vi.fn();
+beforeEach(() => {
+ vi.stubGlobal('fetch', fetchMock);
+ fetchMock.mockReset();
+});
+afterEach(() => vi.unstubAllGlobals());
+
+it('sends kiosk credentials only with the device bearer and never refreshes an employee login', async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({ code: 'TERMINAL_AUTH_FAILED', message: 'Denied' }),
+ { status: 401 },
+ ),
+ );
+ await expect(
+ identifyAtKiosk('device', {
+ method: 'PIN',
+ challengePayload: 'challenge',
+ personalNo: '0012',
+ pin: '00123456',
+ }),
+ ).rejects.toMatchObject({ code: 'TERMINAL_AUTH_FAILED' });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/terminals/kiosk/identify',
+ expect.objectContaining({
+ credentials: 'omit',
+ cache: 'no-store',
+ headers: expect.objectContaining({ Authorization: 'Bearer device' }),
+ }),
+ );
+});
+
+it('preserves pairing for incorrect PINs but clears a confirmed device revocation', async () => {
+ const values = new Map();
+ vi.spyOn(window, 'localStorage', 'get').mockReturnValue({
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ removeItem: (key: string) => values.delete(key),
+ } as unknown as Storage);
+ storeTerminalDeviceToken('device');
+ fetchMock.mockResolvedValueOnce(
+ new Response(JSON.stringify({ code: 'TERMINAL_AUTH_FAILED' }), {
+ status: 401,
+ }),
+ );
+ await expect(
+ bookAtKiosk('device', { grant: 'grant', action: 'clock-in' }),
+ ).rejects.toBeDefined();
+ expect(readTerminalDeviceToken()).toBe('device');
+ fetchMock.mockResolvedValueOnce(
+ new Response(JSON.stringify({ code: 'TERMINAL_DEVICE_REVOKED' }), {
+ status: 401,
+ }),
+ );
+ await expect(
+ bookAtKiosk('device', { grant: 'grant', action: 'clock-in' }),
+ ).rejects.toBeDefined();
+ expect(readTerminalDeviceToken()).toBeNull();
+ vi.restoreAllMocks();
+});
diff --git a/apps/web/src/api/terminal-device.ts b/apps/web/src/api/terminal-device.ts
index 8a08551..c3baaa8 100644
--- a/apps/web/src/api/terminal-device.ts
+++ b/apps/web/src/api/terminal-device.ts
@@ -1,4 +1,8 @@
-import { ApiError, type TerminalDto } from './client';
+import {
+ ApiError,
+ type TerminalDto,
+ type TerminalBookingAction,
+} from './client';
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
@@ -7,7 +11,14 @@ export const TERMINAL_DEVICE_TOKEN_STORAGE_KEY =
export type TerminalPublicDto = Pick<
TerminalDto,
- 'id' | 'name' | 'displayText' | 'locationLabel' | 'logoUrl' | 'timeZone'
+ | 'id'
+ | 'name'
+ | 'displayText'
+ | 'locationLabel'
+ | 'logoUrl'
+ | 'timeZone'
+ | 'allowedAuthMethods'
+ | 'authConfigVersion'
>;
export interface TerminalChallengeDto {
@@ -19,8 +30,35 @@ export interface TerminalChallengeDto {
export interface KioskStateDto {
terminal: TerminalPublicDto;
- challenge: TerminalChallengeDto;
+ challenge: TerminalChallengeDto | null;
serverTime: string;
+ configRefreshAfterSeconds: number;
+ requiresLocation: boolean;
+ pinAvailable: boolean;
+}
+
+export interface TerminalIdentifyResult {
+ grant: string;
+ expiresAt: string;
+ displayName: string;
+ action: TerminalBookingAction;
+ requiresLocation: boolean;
+}
+
+export interface KioskBookingPayload {
+ grant: string;
+ action: TerminalBookingAction;
+ latitude?: number;
+ longitude?: number;
+ accuracyMeters?: number;
+ positionTimestamp?: string;
+}
+
+export interface KioskBookingResult {
+ code: 'TERMINAL_BOOKED';
+ action: TerminalBookingAction;
+ bookedAt: string;
+ receiptId: string;
}
export interface PairTerminalResultDto {
@@ -102,12 +140,64 @@ export async function loadKioskState(
try {
return await parseResponse(response);
} catch (error) {
- if (
- error instanceof ApiError &&
- (error.status === 401 || error.status === 403)
- ) {
+ if (error instanceof ApiError && error.code === 'TERMINAL_DEVICE_REVOKED') {
storeTerminalDeviceToken(null);
}
throw error;
}
}
+
+/** Device-only transport: never attach employee tokens or refresh a login. */
+async function kioskPost(
+ token: string,
+ path: string,
+ payload: unknown,
+ signal?: AbortSignal,
+): Promise {
+ try {
+ const response = await fetch(`${baseUrl}/api/terminals/kiosk/${path}`, {
+ method: 'POST',
+ credentials: 'omit',
+ cache: 'no-store',
+ signal,
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(payload),
+ });
+ return await parseResponse(response);
+ } catch (error) {
+ if (error instanceof ApiError && error.code === 'TERMINAL_DEVICE_REVOKED') {
+ storeTerminalDeviceToken(null);
+ }
+ throw error;
+ }
+}
+
+export const createKioskChallenge = (token: string, signal?: AbortSignal) =>
+ kioskPost<{ payload: string; expiresAt: string }>(
+ token,
+ 'challenge',
+ {},
+ signal,
+ );
+
+export const identifyAtKiosk = (
+ token: string,
+ payload: {
+ method: 'PIN';
+ challengePayload: string;
+ personalNo: string;
+ pin: string;
+ },
+ signal?: AbortSignal,
+) => kioskPost(token, 'identify', payload, signal);
+
+export const bookAtKiosk = (
+ token: string,
+ payload: KioskBookingPayload,
+ signal?: AbortSignal,
+) => kioskPost(token, 'book', payload, signal);
diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx
index ce2b113..1637c0e 100644
--- a/apps/web/src/app/i18n.tsx
+++ b/apps/web/src/app/i18n.tsx
@@ -7,6 +7,7 @@ import {
type ReactNode,
} from 'react';
import { soloDe, soloEn } from './solo-i18n';
+import { terminalPinDe, terminalPinEn } from './terminal-pin-i18n';
export type Locale = 'de' | 'en';
type Variables = Record;
@@ -16,6 +17,7 @@ const STORAGE_KEY = 'openclockwork.locale';
const de: Catalog = {
...soloDe,
+ ...terminalPinDe,
'projects.deleteFailed': 'Projekt konnte nicht gelöscht werden',
'projects.deleteHint':
'Löschen ist nur möglich, solange keine Zeiten gebucht sind',
@@ -679,6 +681,7 @@ const de: Catalog = {
const en: Catalog = {
...soloEn,
+ ...terminalPinEn,
'projects.deleteFailed': 'Could not delete project',
'projects.deleteHint':
'Deletion is available only while no time has been recorded',
diff --git a/apps/web/src/app/terminal-pin-i18n.ts b/apps/web/src/app/terminal-pin-i18n.ts
new file mode 100644
index 0000000..181fbe6
--- /dev/null
+++ b/apps/web/src/app/terminal-pin-i18n.ts
@@ -0,0 +1,191 @@
+export const terminalPinDe = {
+ 'terminalPin.methods': 'Anmeldearten',
+ 'terminalPin.method.QR': 'QR-Code mit Smartphone',
+ 'terminalPin.method.PIN': 'Personalnummer und Terminal-PIN',
+ 'terminalPin.chooseMethod': 'Mindestens eine Anmeldeart auswählen.',
+ 'terminalPin.manage': 'Terminal-PIN verwalten',
+ 'terminalPin.manageFor':
+ 'Terminal-Zugang für {name}. Der PIN ist vom Kontopasswort getrennt.',
+ 'terminalPin.reset': 'Neuen PIN erzeugen',
+ 'terminalPin.resetConfirm':
+ 'Einen neuen Terminal-PIN erzeugen? Der bisherige PIN und offene Anmeldungen werden sofort ungültig.',
+ 'terminalPin.disable': 'PIN deaktivieren',
+ 'terminalPin.disableConfirm': 'Den Terminal-PIN sofort deaktivieren?',
+ 'terminalPin.unlock': 'PIN-Sperre aufheben',
+ 'terminalPin.once':
+ 'Diesen PIN jetzt persönlich übergeben. Nach dem Schließen kann er nicht erneut angezeigt werden.',
+ 'terminalPin.newPin': 'Neuer Terminal-PIN',
+ 'terminalPin.active': 'PIN aktiv',
+ 'terminalPin.inactive': 'Kein aktiver Terminal-PIN',
+ 'terminalPin.locked': 'PIN vorübergehend gesperrt',
+ 'terminalPin.adminFailed':
+ 'Der Terminal-Zugang konnte nicht aktualisiert werden. Bitte den aktuellen Status neu laden.',
+ 'terminalPin.adminLoadFailed': 'Der PIN-Status konnte nicht geladen werden.',
+ 'terminalPin.updated': 'Der Terminal-Zugang wurde aktualisiert.',
+ 'terminalPin.start': 'Mit PIN anmelden',
+ 'terminalPin.title': 'Am Terminal anmelden',
+ 'terminalPin.description':
+ 'Personalnummer und Terminal-PIN eingeben. Danach Ein- oder Ausstempeln ausdrücklich bestätigen.',
+ 'terminalPin.personalNo': 'Personalnummer',
+ 'terminalPin.pin': 'Terminal-PIN',
+ 'terminalPin.identify': 'Anmeldung prüfen',
+ 'terminalPin.identifying': 'Anmeldung wird geprüft …',
+ 'terminalPin.confirm': 'Buchung für {name} bestätigen',
+ 'terminalPin.confirmHint': 'Eine Anmeldung allein bucht keine Zeit.',
+ 'terminalPin.clockIn': 'Jetzt einstempeln',
+ 'terminalPin.clockOut': 'Jetzt ausstempeln',
+ 'terminalPin.submitting': 'Buchung wird bestätigt …',
+ 'terminalPin.locating': 'Aktueller Standort wird ermittelt …',
+ 'terminalPin.successIn': 'Einstempeln bestätigt',
+ 'terminalPin.successOut': 'Ausstempeln bestätigt',
+ 'terminalPin.successTime': 'Serverzeit: {time}',
+ 'terminalPin.failed':
+ 'Anmeldung nicht möglich. Bitte die Angaben prüfen oder HR kontaktieren.',
+ 'terminalPin.rateLimited':
+ 'Zu viele Versuche. Bitte später erneut versuchen.',
+ 'terminalPin.expired': 'Die Anmeldung ist abgelaufen. Bitte erneut anmelden.',
+ 'terminalPin.unavailable': 'Diese Anmeldeart ist derzeit nicht verfügbar.',
+ 'terminalPin.revoked': 'Das Terminal wurde gesperrt. Bitte HR kontaktieren.',
+ 'terminalPin.conflict':
+ 'Der Zeitstatus hat sich geändert. Bitte erneut anmelden und die gewünschte Aktion prüfen.',
+ 'terminalPin.locationRequired':
+ 'Für diese Buchung ist jetzt ein aktueller Standort erforderlich. Bitte dieselbe Aktion erneut bestätigen.',
+ 'terminalPin.locationFailed':
+ 'Der Standort konnte nicht bestätigt werden. Bitte Standortfreigabe und Verbindung prüfen.',
+ 'terminalPin.offline':
+ 'Ohne Verbindung ist keine Anmeldung oder Buchung möglich.',
+ 'terminalPin.uncertain':
+ 'Ergebnis noch nicht bestätigt. Die Buchung kann bereits gespeichert sein. Nur dieselbe Buchung erneut prüfen.',
+ 'terminalPin.retry': 'Dieselbe Buchung erneut prüfen',
+ 'terminalPin.abandoned':
+ 'Eine möglicherweise gespeicherte Buchung wurde nicht zurückgenommen. Bitte den Zeitstatus im eigenen Konto oder bei HR prüfen.',
+ 'terminalPin.cancel': 'Vorgang beenden',
+ 'terminalPin.audit': 'Terminal-Protokoll',
+ 'terminalPin.auditDescription':
+ 'Anmeldungen, Buchungen und PIN-Verwaltung. Zugangsdaten werden nicht angezeigt.',
+ 'terminalPin.auditLoad': 'Protokoll laden',
+ 'terminalPin.auditEmpty': 'Keine passenden Ereignisse.',
+ 'terminalPin.auditFailed': 'Das Protokoll konnte nicht geladen werden.',
+ 'terminalPin.auditMethod': 'Methode',
+ 'terminalPin.auditAll': 'Alle Methoden',
+ 'terminalPin.auditEvent': 'Ereignis',
+ 'terminalPin.auditResult': 'Ergebnis',
+ 'terminalPin.auditTime': 'Zeitpunkt',
+ 'terminalPin.auditEmployee': 'Mitarbeiter-ID',
+ 'terminalPin.auditPrevious': 'Vorherige Seite',
+ 'terminalPin.auditNext': 'Nächste Seite',
+ 'terminalPin.event.PinReset': 'PIN erneuert',
+ 'terminalPin.event.PinUnlocked': 'PIN entsperrt',
+ 'terminalPin.event.PinRevoked': 'PIN deaktiviert',
+ 'terminalPin.event.PinLocked': 'PIN gesperrt',
+ 'terminalPin.event.IdentificationRejected': 'Anmeldung abgelehnt',
+ 'terminalPin.event.IdentificationAccepted': 'Anmeldung bestätigt',
+ 'terminalPin.event.BookingAccepted': 'Buchung bestätigt',
+ 'terminalPin.event.BookingRejected': 'Buchung abgelehnt',
+ 'terminalPin.event.AuditViewed': 'Protokoll eingesehen',
+ 'terminalPin.event.TerminalCreated': 'Terminal angelegt',
+ 'terminalPin.event.TerminalUpdated': 'Terminal geändert',
+ 'terminalPin.event.TerminalDeleted': 'Terminal gelöscht',
+ 'terminalPin.event.PairingIssued': 'Kopplung ausgegeben',
+ 'terminalPin.event.DevicePaired': 'Gerät gekoppelt',
+ 'terminalPin.event.DeviceRevoked': 'Gerät widerrufen',
+ 'terminalPin.result.SUCCESS': 'Erfolgreich',
+ 'terminalPin.result.AUTH_FAILED': 'Anmeldung fehlgeschlagen',
+ 'terminalPin.result.LOCKED': 'Gesperrt',
+ 'terminalPin.result.REQUEST_REJECTED': 'Anfrage abgelehnt',
+};
+
+export const terminalPinEn: Record = {
+ 'terminalPin.methods': 'Sign-in methods',
+ 'terminalPin.method.QR': 'QR code with smartphone',
+ 'terminalPin.method.PIN': 'Employee number and terminal PIN',
+ 'terminalPin.chooseMethod': 'Select at least one sign-in method.',
+ 'terminalPin.manage': 'Manage terminal PIN',
+ 'terminalPin.manageFor':
+ 'Terminal access for {name}. The PIN is separate from the account password.',
+ 'terminalPin.reset': 'Generate a new PIN',
+ 'terminalPin.resetConfirm':
+ 'Generate a new terminal PIN? The previous PIN and pending sign-ins will immediately become invalid.',
+ 'terminalPin.disable': 'Disable PIN',
+ 'terminalPin.disableConfirm': 'Disable the terminal PIN immediately?',
+ 'terminalPin.unlock': 'Unlock PIN',
+ 'terminalPin.once':
+ 'Hand over this PIN in person now. It cannot be displayed again after closing.',
+ 'terminalPin.newPin': 'New terminal PIN',
+ 'terminalPin.active': 'PIN active',
+ 'terminalPin.inactive': 'No active terminal PIN',
+ 'terminalPin.locked': 'PIN temporarily locked',
+ 'terminalPin.adminFailed':
+ 'Terminal access could not be updated. Please reload its current status.',
+ 'terminalPin.adminLoadFailed': 'The PIN status could not be loaded.',
+ 'terminalPin.updated': 'Terminal access was updated.',
+ 'terminalPin.start': 'Sign in with PIN',
+ 'terminalPin.title': 'Sign in at the terminal',
+ 'terminalPin.description':
+ 'Enter your employee number and terminal PIN. Then explicitly confirm clocking in or out.',
+ 'terminalPin.personalNo': 'Employee number',
+ 'terminalPin.pin': 'Terminal PIN',
+ 'terminalPin.identify': 'Verify sign-in',
+ 'terminalPin.identifying': 'Verifying sign-in …',
+ 'terminalPin.confirm': 'Confirm booking for {name}',
+ 'terminalPin.confirmHint': 'Signing in alone does not book any time.',
+ 'terminalPin.clockIn': 'Clock in now',
+ 'terminalPin.clockOut': 'Clock out now',
+ 'terminalPin.submitting': 'Confirming booking …',
+ 'terminalPin.locating': 'Getting current location …',
+ 'terminalPin.successIn': 'Clock-in confirmed',
+ 'terminalPin.successOut': 'Clock-out confirmed',
+ 'terminalPin.successTime': 'Server time: {time}',
+ 'terminalPin.failed':
+ 'Sign-in unavailable. Please check your details or contact HR.',
+ 'terminalPin.rateLimited': 'Too many attempts. Please try again later.',
+ 'terminalPin.expired': 'This sign-in has expired. Please sign in again.',
+ 'terminalPin.unavailable': 'This sign-in method is currently unavailable.',
+ 'terminalPin.revoked': 'This terminal was revoked. Please contact HR.',
+ 'terminalPin.conflict':
+ 'The time status has changed. Please sign in again and check the intended action.',
+ 'terminalPin.locationRequired':
+ 'This booking now requires a current location. Please confirm the same action again.',
+ 'terminalPin.locationFailed':
+ 'Your location could not be confirmed. Please check location access and connectivity.',
+ 'terminalPin.offline': 'A connection is required to sign in or book time.',
+ 'terminalPin.uncertain':
+ 'Result not confirmed yet. The booking may already be saved. Only retry the same booking.',
+ 'terminalPin.retry': 'Check the same booking again',
+ 'terminalPin.abandoned':
+ 'A booking that may have been saved was not reversed. Please check your time status in your account or with HR.',
+ 'terminalPin.cancel': 'End this session',
+ 'terminalPin.audit': 'Terminal audit log',
+ 'terminalPin.auditDescription':
+ 'Sign-ins, bookings and PIN management. Credentials are not displayed.',
+ 'terminalPin.auditLoad': 'Load audit log',
+ 'terminalPin.auditEmpty': 'No matching events.',
+ 'terminalPin.auditFailed': 'The audit log could not be loaded.',
+ 'terminalPin.auditMethod': 'Method',
+ 'terminalPin.auditAll': 'All methods',
+ 'terminalPin.auditEvent': 'Event',
+ 'terminalPin.auditResult': 'Result',
+ 'terminalPin.auditTime': 'Time',
+ 'terminalPin.auditEmployee': 'Employee ID',
+ 'terminalPin.auditPrevious': 'Previous page',
+ 'terminalPin.auditNext': 'Next page',
+ 'terminalPin.event.PinReset': 'PIN replaced',
+ 'terminalPin.event.PinUnlocked': 'PIN unlocked',
+ 'terminalPin.event.PinRevoked': 'PIN disabled',
+ 'terminalPin.event.PinLocked': 'PIN locked',
+ 'terminalPin.event.IdentificationRejected': 'Sign-in rejected',
+ 'terminalPin.event.IdentificationAccepted': 'Sign-in confirmed',
+ 'terminalPin.event.BookingAccepted': 'Booking confirmed',
+ 'terminalPin.event.BookingRejected': 'Booking rejected',
+ 'terminalPin.event.AuditViewed': 'Audit log viewed',
+ 'terminalPin.event.TerminalCreated': 'Terminal created',
+ 'terminalPin.event.TerminalUpdated': 'Terminal updated',
+ 'terminalPin.event.TerminalDeleted': 'Terminal deleted',
+ 'terminalPin.event.PairingIssued': 'Pairing issued',
+ 'terminalPin.event.DevicePaired': 'Device paired',
+ 'terminalPin.event.DeviceRevoked': 'Device revoked',
+ 'terminalPin.result.SUCCESS': 'Successful',
+ 'terminalPin.result.AUTH_FAILED': 'Sign-in failed',
+ 'terminalPin.result.LOCKED': 'Locked',
+ 'terminalPin.result.REQUEST_REJECTED': 'Request rejected',
+};
diff --git a/apps/web/src/routes/AdminEmployeesPage.tsx b/apps/web/src/routes/AdminEmployeesPage.tsx
index 9e66193..b5d29d2 100644
--- a/apps/web/src/routes/AdminEmployeesPage.tsx
+++ b/apps/web/src/routes/AdminEmployeesPage.tsx
@@ -29,6 +29,7 @@ import {
} from '../api/client';
import { useCurrentUser } from '../app/auth';
import { useI18n } from '../app/i18n';
+import { TerminalPinDialog } from './TerminalPinDialog';
const ROLES: EmployeeRole[] = ['Employee', 'Manager', 'HRAdmin'];
const TIME_MODELS: TimeModel[] = [
@@ -64,6 +65,9 @@ export function AdminEmployeesPage() {
const [includeInactive, setIncludeInactive] = useState(false);
const [editor, setEditor] = useState(null);
const [passwordFor, setPasswordFor] = useState(null);
+ const [terminalPinFor, setTerminalPinFor] = useState(
+ null,
+ );
const employees = useQuery({
queryKey: ['employees', { includeInactive }],
@@ -297,6 +301,13 @@ export function AdminEmployeesPage() {
>
+ setTerminalPinFor(e)}
+ >
+ {t('terminalPin.manage')}
+
{e.isActive ? (
setPasswordFor(null)}
/>
)}
+ {terminalPinFor && (
+ setTerminalPinFor(null)}
+ />
+ )}
);
}
diff --git a/apps/web/src/routes/AdminTerminalsPage.tsx b/apps/web/src/routes/AdminTerminalsPage.tsx
index ee970c6..cf6ee58 100644
--- a/apps/web/src/routes/AdminTerminalsPage.tsx
+++ b/apps/web/src/routes/AdminTerminalsPage.tsx
@@ -36,6 +36,7 @@ import { useCurrentUser } from '../app/auth';
import { useI18n } from '../app/i18n';
import { supportUrl } from '../app/runtime-config';
import { getBrowserTimeZone, getTimeZoneOptions } from '../app/time-zones';
+import { TerminalAuditPanel } from './TerminalAuditPanel';
type EditorState = { terminal: TerminalDto | null };
const MAX_LOGO_BYTES = 60 * 1024;
@@ -204,6 +205,14 @@ export function AdminTerminalsPage() {
{terminal.displayText}
+
+ {t('terminalPin.methods')}
+
+
+ {(terminal.allowedAuthMethods ?? ['QR'])
+ .map((method) => t(`terminalPin.method.${method}`))
+ .join(', ')}
+
{t('terminals.geofence')}
@@ -397,6 +406,7 @@ export function AdminTerminalsPage() {
)}
setSupportOpen(false)} />
+
);
}
@@ -417,6 +427,7 @@ function TerminalEditor({
locationLabel: terminal?.locationLabel ?? '',
logoUrl: terminal?.logoUrl ?? null,
timeZone: terminal?.timeZone ?? getBrowserTimeZone(),
+ allowedAuthMethods: terminal?.allowedAuthMethods ?? ['QR'],
enforceGeofence: terminal?.enforceGeofence ?? true,
latitude: terminal?.latitude ?? null,
longitude: terminal?.longitude ?? null,
@@ -431,6 +442,8 @@ function TerminalEditor({
const save = useMutation({
mutationFn: () => {
+ if (!draft.allowedAuthMethods?.length)
+ throw new Error(t('terminalPin.chooseMethod'));
const payload: UpsertTerminalPayload = draft.enforceGeofence
? draft
: {
@@ -579,6 +592,37 @@ function TerminalEditor({
)}
+
+
+ {t('terminalPin.methods')}
+
+ {(['QR', 'PIN'] as const).map((method) => (
+
+ {
+ setDraft((current) => ({
+ ...current,
+ allowedAuthMethods: event.target.checked
+ ? [...(current.allowedAuthMethods ?? []), method]
+ : (current.allowedAuthMethods ?? []).filter(
+ (value) => value !== method,
+ ),
+ }));
+ }}
+ />
+ {t(`terminalPin.method.${method}`)}
+
+ ))}
+ {!draft.allowedAuthMethods?.length && (
+
+ {t('terminalPin.chooseMethod')}
+
+ )}
+
{t('terminals.geofence')}
@@ -718,7 +762,7 @@ function TerminalEditor({
{save.isPending ? t('common.saving') : t('common.save')}
diff --git a/apps/web/src/routes/KioskPage.spec.tsx b/apps/web/src/routes/KioskPage.spec.tsx
index 8a6d378..911487e 100644
--- a/apps/web/src/routes/KioskPage.spec.tsx
+++ b/apps/web/src/routes/KioskPage.spec.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor } from '@testing-library/react';
+import { act, screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { renderWithProviders } from '../test-utils';
import { KioskPage } from './KioskPage';
@@ -63,6 +63,7 @@ describe('KioskPage', () => {
});
afterEach(() => {
+ vi.useRealTimers();
window.history.replaceState({}, '', '/');
});
@@ -148,4 +149,32 @@ describe('KioskPage', () => {
);
expect(screen.getByText('Keine Verbindung')).toBeDefined();
});
+
+ it('polls configuration and enables PIN without generating a QR challenge', async () => {
+ vi.useFakeTimers();
+ const state = kioskState();
+ loadKioskStateMock.mockResolvedValue({
+ ...state,
+ terminal: {
+ ...state.terminal,
+ allowedAuthMethods: ['PIN'],
+ authConfigVersion: 1,
+ },
+ challenge: null,
+ configRefreshAfterSeconds: 1,
+ requiresLocation: false,
+ pinAvailable: true,
+ });
+ await act(async () => {
+ renderWithProviders(
, { initialPath: '/kiosk' });
+ });
+ expect(
+ screen.getByRole('button', { name: 'Mit PIN anmelden' }),
+ ).toBeDefined();
+ expect(toDataUrlMock).not.toHaveBeenCalled();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_100);
+ });
+ expect(loadKioskStateMock.mock.calls.length).toBeGreaterThan(1);
+ });
});
diff --git a/apps/web/src/routes/KioskPage.tsx b/apps/web/src/routes/KioskPage.tsx
index 58e467f..7894ae8 100644
--- a/apps/web/src/routes/KioskPage.tsx
+++ b/apps/web/src/routes/KioskPage.tsx
@@ -24,6 +24,7 @@ import { LanguageToggle } from '../app/LanguageToggle';
import { useAuth } from '../app/auth';
import { useI18n } from '../app/i18n';
import { useOnline } from '../app/use-online';
+import { KioskPinPanel } from './KioskPinPanel';
import {
loadKioskState,
pairTerminal,
@@ -112,13 +113,15 @@ export function KioskPage() {
setError(null);
} catch (refreshError) {
setQrDataUrl(null);
- setState(null);
setError(
refreshError instanceof Error
? refreshError.message
: t('kiosk.refreshFailed'),
);
- if (!readTerminalDeviceToken()) setDeviceToken(null);
+ if (!readTerminalDeviceToken()) {
+ setDeviceToken(null);
+ setState(null);
+ }
} finally {
setLoading(false);
}
@@ -169,7 +172,12 @@ export function KioskPage() {
return () => window.clearInterval(timer);
}, []);
- const expiresAt = state ? new Date(state.challenge.expiresAt).getTime() : 0;
+ const methods = state?.terminal.allowedAuthMethods ?? ['QR'];
+ const qrEnabled = methods.includes('QR');
+ const pinEnabled = methods.includes('PIN') && state?.pinAvailable === true;
+ const expiresAt = state?.challenge
+ ? new Date(state.challenge.expiresAt).getTime()
+ : 0;
const serverNowMs = now.getTime() + serverOffsetMs;
const expired = !expiresAt || serverNowMs >= expiresAt;
@@ -178,12 +186,21 @@ export function KioskPage() {
setQrDataUrl(null);
return;
}
- const untilSafetyMargin =
- new Date(state.challenge.expiresAt).getTime() -
- (Date.now() + serverOffsetMs) -
- REFRESH_SAFETY_MARGIN_MS;
- const requestedRefresh = state.challenge.refreshAfterSeconds * 1_000;
- const delay = Math.max(250, Math.min(requestedRefresh, untilSafetyMargin));
+ const configDelay =
+ Math.max(1, state.configRefreshAfterSeconds || 30) * 1_000;
+ const challenge = state.challenge;
+ const untilSafetyMargin = challenge
+ ? new Date(challenge.expiresAt).getTime() -
+ (Date.now() + serverOffsetMs) -
+ REFRESH_SAFETY_MARGIN_MS
+ : configDelay;
+ const requestedRefresh = challenge
+ ? challenge.refreshAfterSeconds * 1_000
+ : configDelay;
+ const delay = Math.max(
+ 250,
+ Math.min(configDelay, requestedRefresh, untilSafetyMargin),
+ );
const timer = window.setTimeout(() => void refresh(), delay);
return () => window.clearTimeout(timer);
}, [online, refresh, serverOffsetMs, state]);
@@ -196,7 +213,7 @@ export function KioskPage() {
useEffect(() => {
let cancelled = false;
- if (!state || !online || expired) {
+ if (!state?.challenge || !qrEnabled || !online || expired) {
setQrDataUrl(null);
return;
}
@@ -218,7 +235,7 @@ export function KioskPage() {
return () => {
cancelled = true;
};
- }, [expired, online, state, t]);
+ }, [expired, online, qrEnabled, state, t]);
const clock = useMemo(
() =>
@@ -347,39 +364,70 @@ export function KioskPage() {
-
-
{t('kiosk.scanTitle')}
-
- {t('kiosk.scanDescription')}
-
-
-
- {!online ? (
- }
- title={t('kiosk.offlineTitle')}
- description={t('kiosk.offlineDescription')}
+ {pinEnabled && deviceToken && (
+ {
+ setDeviceToken(null);
+ setState(null);
+ }}
/>
- ) : expired || !qrDataUrl ? (
- }
- title={t('kiosk.refreshingTitle')}
- description={t('kiosk.refreshingDescription')}
- />
- ) : (
-
-
-
)}
+ {methods.includes('PIN') && !pinEnabled && (
+
+
+ {t('terminalPin.unavailable')}
+
+
+ )}
+ {qrEnabled && (
+ <>
+
+
+ {t('kiosk.scanTitle')}
+
+
+ {t('kiosk.scanDescription')}
+
+
-
-
- {t('kiosk.securityHint')}
-
+ {!online ? (
+ }
+ title={t('kiosk.offlineTitle')}
+ description={t('kiosk.offlineDescription')}
+ />
+ ) : expired || !qrDataUrl ? (
+ }
+ title={t('kiosk.refreshingTitle')}
+ description={t('kiosk.refreshingDescription')}
+ />
+ ) : (
+
+
+
+ )}
+
+
+
+ {t('kiosk.securityHint')}
+
+ >
+ )}
+ {!qrEnabled && !online && (
+
+ {t('terminalPin.offline')}
+
+ )}
{error && online && (
void refresh()}>
diff --git a/apps/web/src/routes/KioskPinPanel.spec.tsx b/apps/web/src/routes/KioskPinPanel.spec.tsx
new file mode 100644
index 0000000..9bebb71
--- /dev/null
+++ b/apps/web/src/routes/KioskPinPanel.spec.tsx
@@ -0,0 +1,278 @@
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
+import { ApiError } from '../api/client';
+import { renderWithProviders } from '../test-utils';
+import { KioskPinPanel } from './KioskPinPanel';
+
+const challenge = vi.fn();
+const identify = vi.fn();
+const book = vi.fn();
+vi.mock('../api/terminal-device', () => ({
+ createKioskChallenge: (...args: unknown[]) => challenge(...args),
+ identifyAtKiosk: (...args: unknown[]) => identify(...args),
+ bookAtKiosk: (...args: unknown[]) => book(...args),
+}));
+const onDeviceRevoked = vi.fn();
+const props = {
+ deviceToken: 'device-only',
+ online: true,
+ serverOffsetMs: 0,
+ onDeviceRevoked,
+};
+const receipt = {
+ code: 'TERMINAL_BOOKED',
+ action: 'clock-in',
+ bookedAt: '2026-09-30T08:00:00Z',
+ receiptId: 'receipt',
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ challenge.mockResolvedValue({
+ payload: 'fresh-challenge',
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
+ });
+ identify.mockResolvedValue({
+ grant: 'secret-grant',
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
+ displayName: 'Alex E.',
+ action: 'clock-in',
+ requiresLocation: false,
+ });
+ book.mockResolvedValue(receipt);
+});
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+function enterPin() {
+ fireEvent.click(screen.getByRole('button', { name: 'Mit PIN anmelden' }));
+ fireEvent.change(screen.getByLabelText('Personalnummer'), {
+ target: { value: '0012' },
+ });
+ fireEvent.change(screen.getByLabelText('Terminal-PIN'), {
+ target: { value: '00123456' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Anmeldung prüfen' }));
+}
+
+it('preserves lexical credentials and requires a separate explicit booking confirmation', async () => {
+ const rendered = renderWithProviders( );
+ enterPin();
+ const confirm = await screen.findByRole('button', {
+ name: 'Jetzt einstempeln',
+ });
+ expect(identify).toHaveBeenCalledWith(
+ 'device-only',
+ {
+ method: 'PIN',
+ challengePayload: 'fresh-challenge',
+ personalNo: '0012',
+ pin: '00123456',
+ },
+ expect.any(AbortSignal),
+ );
+ expect(book).not.toHaveBeenCalled();
+ expect(screen.queryByLabelText('Terminal-PIN')).toBeNull();
+ expect(rendered.queryClient.getMutationCache().getAll()).toHaveLength(0);
+ fireEvent.click(confirm);
+ fireEvent.click(confirm);
+ await screen.findByText('Einstempeln bestätigt');
+ expect(book).toHaveBeenCalledTimes(1);
+ expect(book).toHaveBeenCalledWith(
+ 'device-only',
+ { grant: 'secret-grant', action: 'clock-in' },
+ expect.any(AbortSignal),
+ );
+ expect(screen.queryByText(/Alex E\./)).toBeNull();
+});
+
+it('retries only the original booking after a lost response, without another identification', async () => {
+ book.mockRejectedValueOnce(new TypeError('network failed'));
+ renderWithProviders( );
+ enterPin();
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Jetzt einstempeln' }),
+ );
+ const retry = await screen.findByRole('button', {
+ name: 'Dieselbe Buchung erneut prüfen',
+ });
+ expect(screen.queryByText(/Alex E\./)).toBeNull();
+ fireEvent.click(retry);
+ await screen.findByText('Einstempeln bestätigt');
+ expect(identify).toHaveBeenCalledTimes(1);
+ expect(book).toHaveBeenCalledTimes(2);
+ expect(book.mock.calls[1][1]).toEqual(book.mock.calls[0][1]);
+});
+
+it('does not restore identity or a grant from an identification completed after cancellation', async () => {
+ let finish!: (value: unknown) => void;
+ identify.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ renderWithProviders( );
+ enterPin();
+ await waitFor(() => expect(identify).toHaveBeenCalledTimes(1));
+ fireEvent.click(screen.getByRole('button', { name: 'Vorgang beenden' }));
+ await act(async () => {
+ finish({
+ grant: 'late-secret',
+ displayName: 'Late Name',
+ action: 'clock-in',
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
+ requiresLocation: false,
+ });
+ });
+ expect(screen.queryByText(/Late Name/)).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Jetzt einstempeln' }),
+ ).toBeNull();
+ expect(book).not.toHaveBeenCalled();
+});
+
+it('uses a neutral credential error without revoking the paired device', async () => {
+ identify.mockRejectedValueOnce(
+ new ApiError(
+ 401,
+ 'employee-sensitive-server-message',
+ 'TERMINAL_AUTH_FAILED',
+ ),
+ );
+ renderWithProviders( );
+ enterPin();
+ await screen.findByText(/Anmeldung nicht möglich/);
+ expect(screen.queryByText('employee-sensitive-server-message')).toBeNull();
+ expect(onDeviceRevoked).not.toHaveBeenCalled();
+ expect(book).not.toHaveBeenCalled();
+});
+
+it('requires a new explicit confirmation if location becomes mandatory', async () => {
+ const position = vi.fn((resolve: PositionCallback) =>
+ resolve({
+ coords: { latitude: 49, longitude: 9, accuracy: 10 },
+ timestamp: Date.now(),
+ } as GeolocationPosition),
+ );
+ vi.stubGlobal('navigator', {
+ ...navigator,
+ geolocation: { getCurrentPosition: position },
+ });
+ book.mockRejectedValueOnce(
+ new ApiError(400, 'location required', 'TERMINAL_GPS_REQUIRED'),
+ );
+ renderWithProviders( );
+ enterPin();
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Jetzt einstempeln' }),
+ );
+ await screen.findByText(/Für diese Buchung ist jetzt/);
+ expect(book).toHaveBeenCalledTimes(1);
+ expect(position).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Jetzt einstempeln' }));
+ await screen.findByText('Einstempeln bestätigt');
+ expect(position).toHaveBeenCalledWith(
+ expect.any(Function),
+ expect.any(Function),
+ { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 },
+ );
+ expect(book.mock.calls[1][1]).toMatchObject({
+ grant: 'secret-grant',
+ action: 'clock-in',
+ latitude: 49,
+ longitude: 9,
+ });
+ vi.unstubAllGlobals();
+});
+
+it('removes a pending identity when the terminal configuration changes', async () => {
+ const rendered = renderWithProviders(
+ ,
+ );
+ enterPin();
+ await screen.findByRole('button', { name: 'Jetzt einstempeln' });
+ rendered.rerender( );
+ await waitFor(() => expect(screen.queryByText(/Alex E\./)).toBeNull());
+ expect(
+ screen.queryByRole('button', { name: 'Jetzt einstempeln' }),
+ ).toBeNull();
+ expect(book).not.toHaveBeenCalled();
+});
+
+it('forgets identity and blocks a late response after the kiosk moves to the background', async () => {
+ let finish!: (value: unknown) => void;
+ identify.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ renderWithProviders( );
+ enterPin();
+ await waitFor(() => expect(identify).toHaveBeenCalledTimes(1));
+ vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
+ fireEvent(document, new Event('visibilitychange'));
+ await act(async () => {
+ finish({
+ grant: 'background-secret',
+ displayName: 'Hidden Person',
+ action: 'clock-in',
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
+ requiresLocation: false,
+ });
+ });
+ expect(screen.queryByText(/Hidden Person/)).toBeNull();
+ expect(screen.queryByLabelText('Terminal-PIN')).toBeNull();
+ expect(book).not.toHaveBeenCalled();
+});
+
+it('treats going offline after dispatch as uncertain and safely retries after reconnecting', async () => {
+ book.mockImplementationOnce(() => new Promise(() => undefined));
+ const rendered = renderWithProviders( );
+ enterPin();
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Jetzt einstempeln' }),
+ );
+ await waitFor(() => expect(book).toHaveBeenCalledTimes(1));
+ rendered.rerender( );
+ expect(
+ screen
+ .getByRole('button', { name: 'Dieselbe Buchung erneut prüfen' })
+ .hasAttribute('disabled'),
+ ).toBe(true);
+ expect(screen.queryByText(/Alex E\./)).toBeNull();
+ rendered.rerender( );
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Dieselbe Buchung erneut prüfen' }),
+ );
+ await screen.findByText('Einstempeln bestätigt');
+ expect(book.mock.calls[1][1]).toEqual(book.mock.calls[0][1]);
+ expect(identify).toHaveBeenCalledTimes(1);
+});
+
+it('forgets entered credentials after inactivity and never authenticates offline', async () => {
+ vi.useFakeTimers();
+ const rendered = renderWithProviders( );
+ fireEvent.click(screen.getByRole('button', { name: 'Mit PIN anmelden' }));
+ fireEvent.change(screen.getByLabelText('Personalnummer'), {
+ target: { value: '0012' },
+ });
+ fireEvent.change(screen.getByLabelText('Terminal-PIN'), {
+ target: { value: '00123456' },
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30_500);
+ });
+ expect(screen.queryByLabelText('Terminal-PIN')).toBeNull();
+ rendered.rerender( );
+ expect(
+ screen
+ .getByRole('button', { name: 'Mit PIN anmelden' })
+ .hasAttribute('disabled'),
+ ).toBe(true);
+ expect(identify).not.toHaveBeenCalled();
+});
diff --git a/apps/web/src/routes/KioskPinPanel.tsx b/apps/web/src/routes/KioskPinPanel.tsx
new file mode 100644
index 0000000..c88d108
--- /dev/null
+++ b/apps/web/src/routes/KioskPinPanel.tsx
@@ -0,0 +1,503 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { ApiError, type TerminalBookingAction } from '../api/client';
+import {
+ bookAtKiosk,
+ createKioskChallenge,
+ identifyAtKiosk,
+ type KioskBookingPayload,
+ type KioskBookingResult,
+ type TerminalIdentifyResult,
+} from '../api/terminal-device';
+import { useI18n } from '../app/i18n';
+
+type Phase =
+ | 'idle'
+ | 'entering'
+ | 'identifying'
+ | 'confirming'
+ | 'locating'
+ | 'submitting'
+ | 'uncertain'
+ | 'success';
+const IDLE_MS = 30_000;
+const RETRY_MS = 120_000;
+
+function locate(): Promise {
+ return new Promise((resolve, reject) => {
+ if (!navigator.geolocation) return reject(new Error('location'));
+ navigator.geolocation.getCurrentPosition(resolve, reject, {
+ enableHighAccuracy: true,
+ maximumAge: 0,
+ timeout: 15_000,
+ });
+ });
+}
+
+function errorKey(error: unknown): string {
+ const code = error instanceof ApiError ? error.code : undefined;
+ switch (code) {
+ case 'TERMINAL_AUTH_RATE_LIMITED':
+ return 'terminalPin.rateLimited';
+ case 'TERMINAL_GRANT_EXPIRED':
+ case 'TERMINAL_GRANT_INVALID':
+ case 'TERMINAL_GRANT_REVOKED':
+ case 'TERMINAL_CHALLENGE_INVALID':
+ case 'TERMINAL_QR_EXPIRED':
+ return 'terminalPin.expired';
+ case 'TERMINAL_DEVICE_REVOKED':
+ case 'TERMINAL_DEVICE_INVALID':
+ return 'terminalPin.revoked';
+ case 'TERMINAL_METHOD_DISABLED':
+ case 'TERMINAL_PIN_UNAVAILABLE':
+ return 'terminalPin.unavailable';
+ case 'TERMINAL_ACTION_MISMATCH':
+ case 'TERMINAL_BOOKING_CONFLICT':
+ case 'TERMINAL_GRANT_CONSUMED':
+ case 'TERMINAL_CHALLENGE_REPLAYED':
+ return 'terminalPin.conflict';
+ case 'TERMINAL_OUTSIDE_GEOFENCE':
+ case 'TERMINAL_GPS_INACCURATE':
+ case 'TERMINAL_POSITION_STALE':
+ return 'terminalPin.locationFailed';
+ default:
+ return 'terminalPin.failed';
+ }
+}
+
+/** A grant lives only in this mounted session, never in a query/mutation cache. */
+export function KioskPinPanel({
+ deviceToken,
+ online,
+ serverOffsetMs,
+ authConfigVersion = 0,
+ onDeviceRevoked,
+}: {
+ deviceToken: string;
+ online: boolean;
+ serverOffsetMs: number;
+ authConfigVersion?: number;
+ onDeviceRevoked: () => void;
+}) {
+ const { t, formatDateTime } = useI18n();
+ const [phase, setPhaseState] = useState('idle');
+ const phaseRef = useRef('idle');
+ const [personalNo, setPersonalNo] = useState('');
+ const [pin, setPin] = useState('');
+ const [identity, setIdentity] = useState | null>(null);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+ const grant = useRef(null);
+ const submitted = useRef(null);
+ const epoch = useRef(0);
+ const controller = useRef(null);
+ const activityAt = useRef(Date.now());
+ const submittedAt = useRef(0);
+ const successAt = useRef(0);
+ const busy = useRef(false);
+ const version = useRef(authConfigVersion);
+ const restoreFocus = useRef(false);
+ const personalInput = useRef(null);
+ const heading = useRef(null);
+ const startButton = useRef(null);
+
+ const setPhase = useCallback((next: Phase) => {
+ phaseRef.current = next;
+ setPhaseState(next);
+ }, []);
+
+ const clear = useCallback(
+ (message: string | null = null) => {
+ epoch.current += 1;
+ controller.current?.abort();
+ controller.current = null;
+ grant.current = null;
+ submitted.current = null;
+ busy.current = false;
+ setPersonalNo('');
+ setPin('');
+ setIdentity(null);
+ setResult(null);
+ setError(message);
+ setPhase('idle');
+ },
+ [setPhase],
+ );
+
+ useEffect(() => {
+ const forget = () =>
+ clear(submitted.current ? 'terminalPin.abandoned' : null);
+ const visibility = () => {
+ if (document.hidden) forget();
+ };
+ const pageshow = (event: PageTransitionEvent) => {
+ if (event.persisted) forget();
+ };
+ document.addEventListener('visibilitychange', visibility);
+ window.addEventListener('pagehide', forget);
+ window.addEventListener('pageshow', pageshow);
+ return () => {
+ document.removeEventListener('visibilitychange', visibility);
+ window.removeEventListener('pagehide', forget);
+ window.removeEventListener('pageshow', pageshow);
+ epoch.current += 1;
+ controller.current?.abort();
+ grant.current = null;
+ submitted.current = null;
+ };
+ }, [clear]);
+
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ const current = phaseRef.current;
+ if (current === 'idle') return;
+ if (current === 'success') {
+ if (Date.now() - successAt.current >= 5_000) clear();
+ return;
+ }
+ const expired =
+ grant.current &&
+ !submitted.current &&
+ Date.now() + serverOffsetMs >= Date.parse(grant.current.expiresAt);
+ if (
+ expired ||
+ Date.now() - activityAt.current >= IDLE_MS ||
+ (submitted.current && Date.now() - submittedAt.current >= RETRY_MS)
+ ) {
+ clear(
+ submitted.current ? 'terminalPin.abandoned' : 'terminalPin.expired',
+ );
+ }
+ }, 500);
+ return () => window.clearInterval(timer);
+ }, [clear, serverOffsetMs]);
+
+ useEffect(() => {
+ if (online) return;
+ if (submitted.current) {
+ // Losing connectivity after dispatch does not establish a failed booking.
+ // Invalidate the old response and retain only this request for explicit retry.
+ epoch.current += 1;
+ controller.current?.abort();
+ busy.current = false;
+ grant.current = null;
+ setIdentity(null);
+ setPhase('uncertain');
+ } else {
+ clear('terminalPin.offline');
+ }
+ }, [online, clear, setPhase]);
+
+ useEffect(() => {
+ if (version.current !== authConfigVersion) {
+ version.current = authConfigVersion;
+ clear(
+ submitted.current ? 'terminalPin.abandoned' : 'terminalPin.expired',
+ );
+ }
+ }, [authConfigVersion, clear]);
+
+ useEffect(() => {
+ if (phase === 'entering') personalInput.current?.focus();
+ if (phase === 'confirming') heading.current?.focus();
+ if (phase === 'idle' && restoreFocus.current) {
+ restoreFocus.current = false;
+ startButton.current?.focus();
+ }
+ }, [phase]);
+
+ const revoked = (caught: unknown) => {
+ if (caught instanceof ApiError && caught.code === 'TERMINAL_DEVICE_REVOKED')
+ onDeviceRevoked();
+ };
+
+ const identify = async () => {
+ if (busy.current || !online || phaseRef.current !== 'entering') return;
+ if (!personalNo || !/^\d{8}$/.test(pin)) return;
+ busy.current = true;
+ const currentEpoch = ++epoch.current;
+ controller.current = new AbortController();
+ const signal = controller.current.signal;
+ const credentials = { personalNo, pin };
+ setPin('');
+ setPersonalNo('');
+ setError(null);
+ setPhase('identifying');
+ activityAt.current = Date.now();
+ try {
+ const challenge = await createKioskChallenge(deviceToken, signal);
+ if (epoch.current !== currentEpoch || signal.aborted) return;
+ if (Date.parse(challenge.expiresAt) <= Date.now() + serverOffsetMs) {
+ clear('terminalPin.expired');
+ return;
+ }
+ const next = await identifyAtKiosk(
+ deviceToken,
+ {
+ method: 'PIN',
+ challengePayload: challenge.payload,
+ ...credentials,
+ },
+ signal,
+ );
+ if (epoch.current !== currentEpoch || signal.aborted) return;
+ if (Date.parse(next.expiresAt) <= Date.now() + serverOffsetMs) {
+ clear('terminalPin.expired');
+ return;
+ }
+ grant.current = next;
+ setIdentity({ displayName: next.displayName, action: next.action });
+ activityAt.current = Date.now();
+ setPhase('confirming');
+ } catch (caught) {
+ if (epoch.current !== currentEpoch) return;
+ clear(errorKey(caught));
+ revoked(caught);
+ } finally {
+ credentials.pin = '';
+ credentials.personalNo = '';
+ if (epoch.current === currentEpoch) busy.current = false;
+ }
+ };
+
+ const book = async (action: TerminalBookingAction, retry = false) => {
+ if (busy.current || !online) return;
+ const auth = grant.current;
+ let payload: KioskBookingPayload;
+ if (retry) {
+ if (!submitted.current || phaseRef.current !== 'uncertain') return;
+ payload = { ...submitted.current };
+ } else {
+ if (!auth || phaseRef.current !== 'confirming') return;
+ if (Date.now() + serverOffsetMs >= Date.parse(auth.expiresAt)) {
+ clear('terminalPin.expired');
+ return;
+ }
+ payload = { grant: auth.grant, action };
+ }
+ busy.current = true;
+ const currentEpoch = ++epoch.current;
+ controller.current = new AbortController();
+ const signal = controller.current.signal;
+ activityAt.current = Date.now();
+ setError(null);
+ try {
+ if (!retry && auth?.requiresLocation) {
+ setPhase('locating');
+ let position: GeolocationPosition;
+ try {
+ position = await locate();
+ } catch {
+ if (epoch.current === currentEpoch) {
+ setError('terminalPin.locationFailed');
+ setPhase('confirming');
+ }
+ return;
+ }
+ if (epoch.current !== currentEpoch || signal.aborted) return;
+ payload = {
+ ...payload,
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ accuracyMeters: position.coords.accuracy,
+ positionTimestamp: new Date(position.timestamp).toISOString(),
+ };
+ }
+ if (epoch.current !== currentEpoch || signal.aborted) return;
+ if (!retry) submittedAt.current = Date.now();
+ submitted.current = payload;
+ setPhase('submitting');
+ const receipt = await bookAtKiosk(deviceToken, payload, signal);
+ if (epoch.current !== currentEpoch || signal.aborted) return;
+ grant.current = null;
+ submitted.current = null;
+ setIdentity(null);
+ setResult(receipt);
+ successAt.current = Date.now();
+ setPhase('success');
+ } catch (caught) {
+ if (epoch.current !== currentEpoch) return;
+ if (
+ caught instanceof ApiError &&
+ caught.code === 'TERMINAL_GPS_REQUIRED' &&
+ auth
+ ) {
+ submitted.current = null;
+ auth.requiresLocation = true;
+ setError('terminalPin.locationRequired');
+ setPhase('confirming');
+ } else if (!(caught instanceof ApiError) || caught.status >= 500) {
+ // A transport/proxy failure cannot prove whether the transaction committed.
+ // Keep the exact request, with no visible identity or reusable PIN.
+ grant.current = null;
+ setIdentity(null);
+ setPhase('uncertain');
+ } else {
+ clear(errorKey(caught));
+ revoked(caught);
+ }
+ } finally {
+ if (epoch.current === currentEpoch) busy.current = false;
+ }
+ };
+
+ const cancel = () => {
+ restoreFocus.current = true;
+ clear(submitted.current ? 'terminalPin.abandoned' : null);
+ };
+
+ return (
+ {
+ activityAt.current = Date.now();
+ }}
+ onKeyDown={() => {
+ activityAt.current = Date.now();
+ }}
+ >
+ {error && (
+
+ {t(error)}
+
+ )}
+ {phase === 'idle' ? (
+
{
+ clear();
+ activityAt.current = Date.now();
+ setPhase('entering');
+ }}
+ >
+ {t('terminalPin.start')}
+
+ ) : phase === 'entering' ? (
+
+ ) : phase === 'confirming' && identity ? (
+
+
+ {t('terminalPin.confirm', { name: identity.displayName })}
+
+
+ {t('terminalPin.confirmHint')}
+
+
void book(identity.action)}
+ >
+ {t(
+ identity.action === 'clock-in'
+ ? 'terminalPin.clockIn'
+ : 'terminalPin.clockOut',
+ )}
+
+
+ ) : phase === 'uncertain' ? (
+
+
{t('terminalPin.uncertain')}
+
{
+ const previous = submitted.current;
+ if (previous) void book(previous.action, true);
+ }}
+ >
+ {t('terminalPin.retry')}
+
+
+ ) : phase === 'success' && result ? (
+
+
+ {t(
+ result.action === 'clock-in'
+ ? 'terminalPin.successIn'
+ : 'terminalPin.successOut',
+ )}
+
+
+ {t('terminalPin.successTime', {
+ time: formatDateTime(result.bookedAt),
+ })}
+
+
+ ) : (
+
+ {t(
+ phase === 'identifying'
+ ? 'terminalPin.identifying'
+ : phase === 'locating'
+ ? 'terminalPin.locating'
+ : 'terminalPin.submitting',
+ )}
+
+ )}
+ {phase !== 'idle' && (
+
+ {t('terminalPin.cancel')}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/routes/TerminalAuditPanel.spec.tsx b/apps/web/src/routes/TerminalAuditPanel.spec.tsx
new file mode 100644
index 0000000..c4b462b
--- /dev/null
+++ b/apps/web/src/routes/TerminalAuditPanel.spec.tsx
@@ -0,0 +1,34 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
+import { api } from '../api/client';
+import { renderWithProviders } from '../test-utils';
+import { TerminalAuditPanel } from './TerminalAuditPanel';
+
+afterEach(() => vi.restoreAllMocks());
+
+it('loads only on request and resets cursor pagination when changing the method filter', async () => {
+ const load = vi
+ .spyOn(api, 'terminalAuditEvents')
+ .mockResolvedValueOnce({ items: [], nextCursor: '12' })
+ .mockResolvedValue({ items: [], nextCursor: null });
+ renderWithProviders( );
+ expect(load).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Protokoll laden' }));
+ await screen.findByText('Keine passenden Ereignisse.');
+ fireEvent.click(screen.getByRole('button', { name: 'Nächste Seite' }));
+ await waitFor(() =>
+ expect(load).toHaveBeenLastCalledWith(
+ { method: undefined, cursor: '12' },
+ expect.any(AbortSignal),
+ ),
+ );
+ fireEvent.change(screen.getByLabelText('Methode'), {
+ target: { value: 'PIN' },
+ });
+ await waitFor(() =>
+ expect(load).toHaveBeenLastCalledWith(
+ { method: 'PIN', cursor: undefined },
+ expect.any(AbortSignal),
+ ),
+ );
+});
diff --git a/apps/web/src/routes/TerminalAuditPanel.tsx b/apps/web/src/routes/TerminalAuditPanel.tsx
new file mode 100644
index 0000000..bab927f
--- /dev/null
+++ b/apps/web/src/routes/TerminalAuditPanel.tsx
@@ -0,0 +1,181 @@
+import { useEffect, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Label } from '@/components/ui/label';
+import {
+ api,
+ type TerminalAuditPage,
+ type TerminalAuthMethod,
+} from '../api/client';
+import { catalogs, useI18n } from '../app/i18n';
+
+export function TerminalAuditPanel() {
+ const { t, formatDateTime } = useI18n();
+ const [open, setOpen] = useState(false);
+ const [method, setMethod] = useState('');
+ const [cursors, setCursors] = useState([]);
+ const [page, setPage] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const [revision, setRevision] = useState(0);
+ const cursor = cursors.at(-1);
+ const eventLabel = (kind: 'event' | 'result', value: string) => {
+ const key = `terminalPin.${kind}.${value}`;
+ return key in catalogs.en ? t(key) : value;
+ };
+
+ useEffect(() => {
+ if (!open) return;
+ const controller = new AbortController();
+ let active = true;
+ setLoading(true);
+ setFailed(false);
+ setPage(null);
+ void api
+ .terminalAuditEvents(
+ { method: method || undefined, cursor },
+ controller.signal,
+ )
+ .then((result) => {
+ if (active) setPage(result);
+ })
+ .catch(() => {
+ if (active) setFailed(true);
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ controller.abort();
+ };
+ }, [open, method, cursor, revision]);
+
+ return (
+
+
+ {t('terminalPin.audit')}
+
+ {t('terminalPin.auditDescription')}
+
+
+
+ {!open ? (
+ setOpen(true)}>
+ {t('terminalPin.auditLoad')}
+
+ ) : (
+ <>
+
+
+
+ {t('terminalPin.auditMethod')}
+
+ {
+ setCursors([]);
+ setMethod(event.target.value as TerminalAuthMethod | '');
+ }}
+ >
+ {t('terminalPin.auditAll')}
+ {t('terminalPin.method.QR')}
+ {t('terminalPin.method.PIN')}
+
+
+
setRevision((value) => value + 1)}
+ disabled={loading}
+ >
+ {t('terminalPin.auditLoad')}
+
+
{
+ setOpen(false);
+ setPage(null);
+ }}
+ >
+ {t('common.close')}
+
+
+ {loading && {t('common.loading')}
}
+ {failed && (
+
+
+ {t('terminalPin.auditFailed')}
+
+
+ )}
+ {page && page.items.length === 0 && (
+ {t('terminalPin.auditEmpty')}
+ )}
+ {page && page.items.length > 0 && (
+
+
+
+
+ {t('terminalPin.auditTime')}
+ {t('terminals.name')}
+ {t('terminalPin.auditMethod')}
+ {t('terminalPin.auditEvent')}
+ {t('terminalPin.auditResult')}
+ {t('terminalPin.auditEmployee')}
+
+
+
+ {page.items.map((event) => (
+
+
+ {formatDateTime(event.occurredAt)}
+
+ {event.terminalName ?? '—'}
+
+ {event.method
+ ? t(`terminalPin.method.${event.method}`)
+ : '—'}
+
+
+ {eventLabel('event', event.event)}
+
+
+ {eventLabel('result', event.result)}
+
+
+ {event.employeeId ?? '—'}
+
+
+ ))}
+
+
+
+ )}
+
+ setCursors((value) => value.slice(0, -1))}
+ >
+ {t('terminalPin.auditPrevious')}
+
+ {
+ const next = page?.nextCursor;
+ if (next) setCursors((value) => [...value, next]);
+ }}
+ >
+ {t('terminalPin.auditNext')}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/routes/TerminalPinDialog.spec.tsx b/apps/web/src/routes/TerminalPinDialog.spec.tsx
new file mode 100644
index 0000000..846e87e
--- /dev/null
+++ b/apps/web/src/routes/TerminalPinDialog.spec.tsx
@@ -0,0 +1,84 @@
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
+import { api, type EmployeeDto } from '../api/client';
+import { renderWithProviders } from '../test-utils';
+import { TerminalPinDialog } from './TerminalPinDialog';
+
+const employee = {
+ id: 'employee',
+ firstName: 'Alex',
+ lastName: 'Example',
+ isActive: true,
+} as EmployeeDto;
+const status = {
+ method: 'PIN' as const,
+ isConfigured: true,
+ enabled: true,
+ lockedUntil: null,
+ updatedAt: null,
+};
+beforeEach(() => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ vi.spyOn(api, 'terminalCredentials').mockResolvedValue(status);
+});
+afterEach(() => vi.restoreAllMocks());
+
+it('displays a generated PIN once without retaining it in React Query caches', async () => {
+ vi.spyOn(api, 'resetTerminalPin').mockResolvedValue({
+ pin: '00123456',
+ credential: status,
+ });
+ const close = vi.fn();
+ const rendered = renderWithProviders(
+ ,
+ );
+ await screen.findByText('PIN aktiv');
+ fireEvent.click(screen.getByRole('button', { name: 'Neuen PIN erzeugen' }));
+ expect((await screen.findByLabelText('Neuer Terminal-PIN')).textContent).toBe(
+ '00123456',
+ );
+ expect(rendered.queryClient.getMutationCache().getAll()).toHaveLength(0);
+ expect(rendered.queryClient.getQueryCache().getAll()).toHaveLength(0);
+ fireEvent.click(screen.getByRole('button', { name: 'Schließen' }));
+ expect(screen.queryByText('00123456')).toBeNull();
+ expect(close).toHaveBeenCalledTimes(1);
+});
+
+it('does not redisplay a reset response that arrives after closing', async () => {
+ let finish!: (value: { pin: string; credential: typeof status }) => void;
+ vi.spyOn(api, 'resetTerminalPin').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const close = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await screen.findByText('PIN aktiv');
+ fireEvent.click(screen.getByRole('button', { name: 'Neuen PIN erzeugen' }));
+ await waitFor(() => expect(api.resetTerminalPin).toHaveBeenCalledTimes(1));
+ fireEvent.click(screen.getByRole('button', { name: 'Schließen' }));
+ await act(async () => {
+ finish({ pin: '00123456', credential: status });
+ });
+ expect(screen.queryByText('00123456')).toBeNull();
+ expect(close).toHaveBeenCalledTimes(1);
+});
+
+it('reloads status after a lost reset response without automatically issuing another PIN', async () => {
+ const reset = vi
+ .spyOn(api, 'resetTerminalPin')
+ .mockRejectedValue(new TypeError('connection lost'));
+ renderWithProviders(
+ ,
+ );
+ await screen.findByText('PIN aktiv');
+ fireEvent.click(screen.getByRole('button', { name: 'Neuen PIN erzeugen' }));
+ await screen.findByText(
+ /Der Terminal-Zugang konnte nicht aktualisiert werden/,
+ );
+ await waitFor(() => expect(api.terminalCredentials).toHaveBeenCalledTimes(2));
+ expect(reset).toHaveBeenCalledTimes(1);
+});
diff --git a/apps/web/src/routes/TerminalPinDialog.tsx b/apps/web/src/routes/TerminalPinDialog.tsx
new file mode 100644
index 0000000..94ef5ba
--- /dev/null
+++ b/apps/web/src/routes/TerminalPinDialog.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { api, type EmployeeDto, type TerminalPinStatus } from '../api/client';
+import { useI18n } from '../app/i18n';
+
+export function TerminalPinDialog({
+ employee,
+ onClose,
+}: {
+ employee: EmployeeDto;
+ onClose: () => void;
+}) {
+ const { t, formatDateTime } = useI18n();
+ const [status, setStatus] = useState(null);
+ const [pin, setPin] = useState(null);
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(null);
+ const [updated, setUpdated] = useState(false);
+ const epoch = useRef(0);
+ const busy = useRef(false);
+ const controller = useRef(null);
+ const closeCallback = useRef(onClose);
+ closeCallback.current = onClose;
+
+ useEffect(() => {
+ const abort = new AbortController();
+ controller.current = abort;
+ const current = ++epoch.current;
+ void api
+ .terminalCredentials(employee.id, abort.signal)
+ .then((next) => {
+ if (epoch.current === current) setStatus(next);
+ })
+ .catch(() => {
+ if (epoch.current === current) setError('terminalPin.adminLoadFailed');
+ });
+ return () => {
+ epoch.current += 1;
+ abort.abort();
+ controller.current?.abort();
+ };
+ }, [employee.id]);
+
+ useEffect(() => {
+ const close = () => {
+ epoch.current += 1;
+ controller.current?.abort();
+ setPin(null);
+ closeCallback.current();
+ };
+ const visibility = () => {
+ if (document.hidden) close();
+ };
+ const pageshow = (event: PageTransitionEvent) => {
+ if (event.persisted) close();
+ };
+ document.addEventListener('visibilitychange', visibility);
+ window.addEventListener('pagehide', close);
+ window.addEventListener('pageshow', pageshow);
+ return () => {
+ document.removeEventListener('visibilitychange', visibility);
+ window.removeEventListener('pagehide', close);
+ window.removeEventListener('pageshow', pageshow);
+ };
+ }, []);
+
+ const close = () => {
+ epoch.current += 1;
+ controller.current?.abort();
+ setPin(null);
+ onClose();
+ };
+
+ const change = async (operation: 'reset' | 'disable' | 'unlock') => {
+ if (busy.current) return;
+ if (
+ operation !== 'unlock' &&
+ !window.confirm(
+ t(
+ operation === 'reset'
+ ? 'terminalPin.resetConfirm'
+ : 'terminalPin.disableConfirm',
+ ),
+ )
+ )
+ return;
+ busy.current = true;
+ setPending(true);
+ setError(null);
+ setUpdated(false);
+ setPin(null);
+ controller.current?.abort();
+ const current = ++epoch.current;
+ controller.current = new AbortController();
+ const signal = controller.current.signal;
+ try {
+ // No useMutation: its cached result would retain the one-time PIN.
+ if (operation === 'reset') {
+ const response = await api.resetTerminalPin(employee.id, signal);
+ if (epoch.current !== current) return;
+ setPin(response.pin);
+ setStatus(response.credential);
+ } else {
+ const next = await (operation === 'disable'
+ ? api.disableTerminalPin(employee.id, signal)
+ : api.unlockTerminalPin(employee.id, signal));
+ if (epoch.current !== current) return;
+ setStatus(next);
+ }
+ setUpdated(true);
+ } catch {
+ if (epoch.current !== current) return;
+ setError('terminalPin.adminFailed');
+ // A lost response cannot recover a PIN; reload status without retrying reset.
+ try {
+ const next = await api.terminalCredentials(employee.id, signal);
+ if (epoch.current === current) setStatus(next);
+ } catch {
+ /* Keep the explicit failure message. */
+ }
+ } finally {
+ if (epoch.current === current) {
+ busy.current = false;
+ setPending(false);
+ }
+ }
+ };
+
+ const locked =
+ !!status?.lockedUntil && Date.parse(status.lockedUntil) > Date.now();
+ return (
+ {
+ if (!open) close();
+ }}
+ >
+
+
+ {t('terminalPin.manage')}
+
+ {t('terminalPin.manageFor', {
+ name: `${employee.firstName} ${employee.lastName}`,
+ })}
+
+
+ {status ? (
+
+ {t(
+ locked
+ ? 'terminalPin.locked'
+ : status.enabled
+ ? 'terminalPin.active'
+ : 'terminalPin.inactive',
+ )}
+ {locked && status.lockedUntil
+ ? ` (${formatDateTime(status.lockedUntil)})`
+ : ''}
+
+ ) : (
+ !error && {t('common.loading')}
+ )}
+ {pin && (
+
+
{t('terminalPin.newPin')}
+
+ {pin}
+
+
+ {t('terminalPin.once')}
+
+
+ )}
+ {error && (
+
+ {t(error)}
+
+ )}
+ {updated && !pin && {t('terminalPin.updated')}
}
+
+ void change('reset')}
+ >
+ {t('terminalPin.reset')}
+
+ void change('disable')}
+ >
+ {t('terminalPin.disable')}
+
+ void change('unlock')}
+ >
+ {t('terminalPin.unlock')}
+
+
+
+
+ {t('common.close')}
+
+
+
+
+ );
+}
diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts
index 7107b8d..f30aad3 100644
--- a/apps/web/vite.config.mts
+++ b/apps/web/vite.config.mts
@@ -113,7 +113,8 @@ export default defineConfig(() => ({
handler: 'NetworkOnly',
},
{
- urlPattern: /\/api\/terminals\/(?:pair|scan)(?:\?|$)/,
+ urlPattern:
+ /\/api\/terminals\/(?:pair|scan|kiosk\/(?:challenge|identify|book))(?:\?|$)/,
handler: 'NetworkOnly',
method: 'POST',
},
diff --git a/docker-compose.pin-test.yml b/docker-compose.pin-test.yml
new file mode 100644
index 0000000..195cbdd
--- /dev/null
+++ b/docker-compose.pin-test.yml
@@ -0,0 +1,76 @@
+# Isolated Increment A pilot. Synthetic local-only secrets; bind only loopback.
+services:
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: openclockwork
+ POSTGRES_PASSWORD: local-pin-test-migration-only
+ POSTGRES_DB: openclockwork_test_pin
+ volumes:
+ - pin-database:/var/lib/postgresql/data
+ healthcheck:
+ test:
+ ['CMD-SHELL', 'pg_isready -U openclockwork -d openclockwork_test_pin']
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ migrate:
+ image: openclockwork-api:2.1.0-pin-local
+ build:
+ context: .
+ dockerfile: Dockerfile.api
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ DATABASE_URL: postgresql://openclockwork:local-pin-test-migration-only@db:5432/openclockwork_test_pin?schema=public
+ TERMINAL_RUNTIME_DB_PASSWORD: local-pin-test-runtime-only-password
+ command:
+ [
+ 'sh',
+ '-c',
+ './node_modules/.bin/prisma migrate deploy && node /app/ops/configure-terminal-runtime.mjs',
+ ]
+ api:
+ image: openclockwork-api:2.1.0-pin-local
+ pull_policy: never
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ environment:
+ NODE_ENV: production
+ API_PORT: 3000
+ TZ: Europe/Berlin
+ DATABASE_URL: postgresql://openclockwork_runtime:local-pin-test-runtime-only-password@db:5432/openclockwork_test_pin?schema=public
+ JWT_SECRET: local-pin-test-jwt-only-synthetic-2026-openclockwork
+ TERMINAL_QR_SECRET: local-pin-test-qr-only-synthetic-2026-openclockwork
+ TERMINAL_PIN_PEPPER: local-pin-test-pepper-only-synthetic-2026-openclockwork
+ TERMINAL_PIN_PEPPER_VERSION: '1'
+ TERMINAL_AUDIT_KEY: local-pin-test-audit-only-synthetic-2026-openclockwork
+ TERMINAL_AUDIT_KEY_VERSION: '1'
+ ERP_API_KEY: local-pin-test-erp-only-synthetic-openclockwork
+ CRON_API_KEY: local-pin-test-cron-only-synthetic-openclockwork
+ API_CORS_ORIGINS: http://localhost:${PIN_TEST_WEB_PORT:-18082},http://127.0.0.1:${PIN_TEST_WEB_PORT:-18082}
+ STORAGE_BACKEND: local
+ ATTACHMENTS_DIR: /app/data/attachments
+ volumes:
+ - pin-attachments:/app/data/attachments
+ - pin-audit-export:/app/data/audit
+ web:
+ image: openclockwork-web:2.1.0-pin-local
+ build:
+ context: .
+ dockerfile: Dockerfile.web
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ API_UPSTREAM: http://api:3000
+ DEMO_MODE: 'false'
+ SUPPORT_URL: ''
+ ports:
+ - '127.0.0.1:${PIN_TEST_WEB_PORT:-18082}:8080'
+volumes:
+ pin-database:
+ pin-attachments:
+ pin-audit-export:
diff --git a/docker-compose.solo-test.yml b/docker-compose.solo-test.yml
index 5e750dc..3d7a5b5 100644
--- a/docker-compose.solo-test.yml
+++ b/docker-compose.solo-test.yml
@@ -18,7 +18,7 @@ services:
timeout: 5s
retries: 12
api:
- image: openclockwork-api:2.0.0-local
+ image: openclockwork-api:2.1.0-local
build:
context: .
dockerfile: Dockerfile.api
@@ -48,7 +48,7 @@ services:
'./node_modules/.bin/prisma migrate deploy && exec node dist/main.js',
]
web:
- image: openclockwork-web:2.0.0-local
+ image: openclockwork-web:2.1.0-local
build:
context: .
dockerfile: Dockerfile.web
diff --git a/docker-compose.terminal-pin.yml b/docker-compose.terminal-pin.yml
new file mode 100644
index 0000000..210d271
--- /dev/null
+++ b/docker-compose.terminal-pin.yml
@@ -0,0 +1,30 @@
+# Optional overlay for a production PIN pilot, after archive/retention acceptance.
+# DATABASE_URL must use runtime; MIGRATION_DATABASE_URL must use a separate owner.
+# Combine with docker-compose.prod.yml and OpenClockwork 2.1.0 or newer.
+services:
+ migrate:
+ image: ghcr.io/patrickschiller/openclockwork-api:${OPENCLOCKWORK_VERSION}
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ DATABASE_URL: ${MIGRATION_DATABASE_URL:?Set the separate migration owner URL}
+ TERMINAL_RUNTIME_DB_ROLE: ${TERMINAL_RUNTIME_DB_ROLE:-openclockwork_runtime}
+ TERMINAL_RUNTIME_DB_PASSWORD: ${TERMINAL_RUNTIME_DB_PASSWORD:?Set runtime role password}
+ command:
+ [
+ 'sh',
+ '-c',
+ './node_modules/.bin/prisma migrate deploy && node /app/ops/configure-terminal-runtime.mjs',
+ ]
+ api:
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ command: ['node', 'dist/main.js']
+ environment:
+ TERMINAL_PIN_PEPPER: ${TERMINAL_PIN_PEPPER:?Set independent PIN pepper}
+ TERMINAL_PIN_PEPPER_VERSION: ${TERMINAL_PIN_PEPPER_VERSION:-1}
+ TERMINAL_PIN_PREVIOUS_PEPPERS: ${TERMINAL_PIN_PREVIOUS_PEPPERS:-}
+ TERMINAL_AUDIT_KEY: ${TERMINAL_AUDIT_KEY:?Set independent audit key}
+ TERMINAL_AUDIT_KEY_VERSION: ${TERMINAL_AUDIT_KEY_VERSION:-1}
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 0000000..59d19ca
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,66 @@
+# Two isolated local acceptance stacks; see docs/LOCAL_DOCKER_TEST.de.md.
+# All credentials are synthetic and only suitable for local tests.
+# The Compose project name isolates networks, databases and attachments.
+# No automatic seed or bootstrap runs on startup.
+services:
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: openclockwork
+ POSTGRES_PASSWORD: local-test-database-only
+ POSTGRES_DB: openclockwork_test
+ volumes:
+ - test-database:/var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U openclockwork -d openclockwork_test']
+ interval: 5s
+ timeout: 5s
+ retries: 12
+
+ api:
+ image: openclockwork-api:2.1.0-local
+ build:
+ context: .
+ dockerfile: Dockerfile.api
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ NODE_ENV: production
+ API_PORT: 3000
+ TZ: Europe/Berlin
+ DATABASE_URL: postgresql://openclockwork:local-test-database-only@db:5432/openclockwork_test?schema=public
+ JWT_SECRET: local-test-jwt-only-synthetic-2026-openclockwork
+ TERMINAL_QR_SECRET: local-test-terminal-only-synthetic-2026-openclockwork
+ ERP_API_KEY: local-test-erp-only-synthetic-openclockwork
+ CRON_API_KEY: local-test-cron-only-synthetic-openclockwork
+ API_CORS_ORIGINS: http://localhost:${TEST_WEB_PORT:-18080},http://127.0.0.1:${TEST_WEB_PORT:-18080}
+ STORAGE_BACKEND: local
+ ATTACHMENTS_DIR: /app/data/attachments
+ volumes:
+ - test-attachments:/app/data/attachments
+ command:
+ [
+ 'sh',
+ '-c',
+ './node_modules/.bin/prisma migrate deploy && exec node dist/main.js',
+ ]
+
+ web:
+ image: openclockwork-web:2.1.0-local
+ build:
+ context: .
+ dockerfile: Dockerfile.web
+ depends_on:
+ api:
+ condition: service_healthy
+ environment:
+ API_UPSTREAM: http://api:3000
+ DEMO_MODE: 'false'
+ SUPPORT_URL: ''
+ ports:
+ - '127.0.0.1:${TEST_WEB_PORT:-18080}:8080'
+
+volumes:
+ test-database:
+ test-attachments:
diff --git a/docs/IPAD_TERMINAL_SETUP.de.md b/docs/IPAD_TERMINAL_SETUP.de.md
index 5d1c48e..3a06e01 100644
--- a/docs/IPAD_TERMINAL_SETUP.de.md
+++ b/docs/IPAD_TERMINAL_SETUP.de.md
@@ -2,10 +2,14 @@
Diese Anleitung beschreibt die Einrichtung eines OpenClockwork-Terminals im
**Team-Modus** mit einem fest montierten iPad. Im Solo-Modus sind Terminals
-deaktiviert. Das iPad zeigt ausschließlich den rotierenden
-QR-Code. Mitarbeitende scannen ihn mit der angemeldeten OpenClockwork-PWA auf
+deaktiviert. Das iPad zeigt je nach HR-Konfiguration einen rotierenden
+QR-Code und/oder einen PIN-Eingabedialog. Mitarbeitende scannen ihn mit der angemeldeten OpenClockwork-PWA auf
ihrem eigenen Smartphone. Die Kamera wird dort immer benötigt; ein aktueller
-GPS-Standort wird nur bei aktivierter Standortprüfung angefordert.
+GPS-Standort wird nur bei aktivierter Standortprüfung angefordert. Für PIN
+identifiziert sich der Mitarbeiter direkt am gekoppelten Kiosk und bestätigt
+Ein-/Aus anschließend ausdrücklich. [PIN-Betrieb und Audit](TERMINAL_AUTH_OPERATIONS.de.md)
+und die [isolierte Docker-PIN-Testversion](LOCAL_PIN_DOCKER_TEST.de.md) ergänzen
+diese bestehende QR-/HTTPS-Anleitung.
Das Terminal-Feature ist vollständig kostenlos. Der einmalige Hinweis auf eine
freiwillige Unterstützung von ungefähr 125 € schaltet keine Funktion frei
@@ -18,9 +22,9 @@ und verändert die Aktivierung nicht.
- Ein neuer Kiosk wird über einen einmalig verwendbaren Pairing-Code bzw.
Pairing-Link gekoppelt. Danach entfernt die Anwendung das Geheimnis aus der
sichtbaren URL und speichert nur das widerrufbare Geräte-Credential lokal.
-- Das Kiosk-Credential kann keine Arbeitszeiten buchen und keine Admin- oder
- Personaldaten lesen. Es darf ausschließlich den aktuellen Terminalzustand
- und die kurzlebige QR-Challenge abrufen.
+- Das Kiosk-Credential kann keine normalen Zeit-/Admin-/Personal-APIs aufrufen.
+ PIN-Buchungen benötigen zusätzlich einen geprüften Mitarbeiter-PIN, eine
+ gerätegebundene Challenge und eine kurzlebige einmalige Buchungsfreigabe.
- Die QR-Signatur wird täglich ausgetauscht; die sichtbare Challenge rotiert
zusätzlich in kurzen Abständen. Kopien und Screenshots laufen daher ab.
- Die Buchung wird ausschließlich vom Server akzeptiert, wenn Challenge,
diff --git a/docs/LOCAL_DOCKER_TEST.de.md b/docs/LOCAL_DOCKER_TEST.de.md
new file mode 100644
index 0000000..9194cd8
--- /dev/null
+++ b/docs/LOCAL_DOCKER_TEST.de.md
@@ -0,0 +1,143 @@
+# Solo und Team lokal in Docker testen
+
+Diese Anleitung startet zwei unabhängige Installationen aus dem aktuellen
+Quellcode. Voraussetzung sind Docker mit Compose und ein laufender Docker-Daemon;
+Node.js und pnpm werden auf dem Host nicht benötigt. Alle Befehle werden im
+Repository-Verzeichnis ausgeführt.
+
+| Modus | Compose-Projekt | Browser-Adresse |
+| --- | --- | --- |
+| Solo | `openclockwork-test-solo` | |
+| Team | `openclockwork-test-team` | |
+
+Die Projekte verwenden dieselben lokal gebauten Images, besitzen aber getrennte
+PostgreSQL-Datenbanken, Netzwerke und Anhang-Volumes. Nur die Weboberfläche wird
+an `127.0.0.1` veröffentlicht; die API ist über `/api` an derselben Adresse
+erreichbar. Datenbank und API haben keine veröffentlichten Host-Ports.
+
+Die Konfiguration enthält ausschließlich synthetische lokale Testschlüssel und
+ist für Tests auf diesem Rechner vorgesehen. Verwende nur erfundene Personen und
+Testdaten. Die Server-Zeitzone ist `Europe/Berlin`. Der API-Prozess läuft mit
+`NODE_ENV=production`, die Weboberfläche mit `DEMO_MODE=false`.
+
+## Einmal bauen und beide Installationen starten
+
+`--env-file /dev/null` verhindert, dass Compose eine vorhandene `.env` als
+Konfigurationsquelle lädt. Datenbank und Testschlüssel sind in der Testdatei fest
+vorgegeben. `TEST_WEB_PORT` wird für jeden Befehl explizit gesetzt.
+
+```bash
+TEST_WEB_PORT=18080 docker compose --env-file /dev/null \
+ -p openclockwork-test-solo -f docker-compose.test.yml build
+
+TEST_WEB_PORT=18080 docker compose --env-file /dev/null \
+ -p openclockwork-test-solo -f docker-compose.test.yml up -d --wait
+
+TEST_WEB_PORT=18081 docker compose --env-file /dev/null \
+ -p openclockwork-test-team -f docker-compose.test.yml up -d --wait
+```
+
+Der erste Build kann einige Minuten dauern. Beim API-Start werden ausstehende
+Prisma-Migrationen angewendet. Compose wartet auf die Healthchecks von Datenbank,
+API und Web. Es werden keine Konten oder Beispieldaten automatisch angelegt.
+
+## Solo einmalig einrichten
+
+Dieser Bootstrap ist nur für eine leere Mitarbeiter-Tabelle vorgesehen:
+
+```bash
+TEST_WEB_PORT=18080 docker compose --env-file /dev/null \
+ -p openclockwork-test-solo -f docker-compose.test.yml exec -T api \
+ node --import tsx prisma/create-admin.ts \
+ --mode Solo --first-name Solo --last-name Test --email solo@example.com
+```
+
+Der Befehl aktiviert Solo und gibt ein zufällig erzeugtes **Initial password**
+einmalig im Terminal aus. Melde dich auf mit
+`solo@example.com` und diesem Passwort an. Du kannst es anschließend unter
+**Einstellungen → Passwort** ändern. Das erzeugte Passwort gehört nicht in
+Repository-Dateien oder öffentliche Logs.
+
+Teste beispielsweise Timer, manuelle Einträge, Kunden, Projekte, Aufträge und
+Timesheets. Die optionale persönliche Arbeitszeitregelung kann separat
+konfiguriert werden. Details stehen im [Solo-Leitfaden](SOLO_MODE.md).
+
+## Team einmalig mit synthetischen Beispieldaten einrichten
+
+Führe den Seed nur bei der ersten Einrichtung der leeren Team-Testinstallation
+aus. Er legt HR, Führungskräfte, Beschäftigte, Arbeitszeitpläne, Projekte und
+Beispielbuchungen an. Wiederholtes Seeding kann geänderte Testkonfigurationen
+überschreiben; es gehört weder zum normalen Neustart noch in die Solo-Installation.
+
+```bash
+TEST_WEB_PORT=18081 docker compose --env-file /dev/null \
+ -p openclockwork-test-team -f docker-compose.test.yml exec -T \
+ -e NODE_ENV=development \
+ -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test \
+ api node --import tsx prisma/seed.ts
+```
+
+Die beiden Umgebungsvariablen gelten nur für diesen Seed-Prozess. Der laufende
+API-Prozess bleibt im Produktionsmodus. Der Seed prüft Datenbankname und Schema;
+die Bestätigung bezeichnet ausschließlich die isolierte Testdatenbank dieses
+Compose-Projekts. Die frisch migrierte Installation verwendet standardmäßig Team.
+
+Alle folgenden Konten aus [prisma/seed.ts](../prisma/seed.ts) haben anfangs das
+Testpasswort **`openclockwork`**:
+
+| Rolle | Name | E-Mail |
+| --- | --- | --- |
+| HR-Administration | Hannah Roth | `hannah.roth@openclockwork.test` |
+| Führungskraft | Marc Becker | `marc.becker@openclockwork.test` |
+| Führungskraft | Stefanie Weiss | `stefanie.weiss@openclockwork.test` |
+| Beschäftigte | Anna Müller | `anna.mueller@openclockwork.test` |
+| Beschäftigter | Bernd Schulz | `bernd.schulz@openclockwork.test` |
+| Beschäftigter | Cengiz Yilmaz | `cengiz.yilmaz@openclockwork.test` |
+| Beschäftigte | Diana Fischer | `diana.fischer@openclockwork.test` |
+| Beschäftigter | Erik Lindgren | `erik.lindgren@openclockwork.test` |
+| Beschäftigte | Fatma Demir | `fatma.demir@openclockwork.test` |
+
+Öffne . Für einen Genehmigungsablauf kannst du einen
+Antrag als Anna Müller erstellen und ihn anschließend als Marc Becker oder
+Hannah Roth bearbeiten. Separate Browserprofile oder private Fenster erleichtern
+den Vergleich verschiedener Rollen. Details stehen im
+[Team-Leitfaden](TEAM_MODE.md). Für einen Test auf einem Tablet mit Kamera und
+Standortzugriff verwende den gesonderten
+[HTTPS-iPad-Leitfaden](IPAD_TERMINAL_SETUP.de.md).
+
+## Stoppen und fortsetzen
+
+Diese Befehle stoppen die Container und erhalten Datenbanken und Anhänge:
+
+```bash
+TEST_WEB_PORT=18080 docker compose --env-file /dev/null \
+ -p openclockwork-test-solo -f docker-compose.test.yml stop
+TEST_WEB_PORT=18081 docker compose --env-file /dev/null \
+ -p openclockwork-test-team -f docker-compose.test.yml stop
+```
+
+Zum Fortsetzen führe die beiden `up -d --wait`-Befehle vom Anfang erneut aus.
+Bootstrap und Seed werden dabei **nicht** wiederholt. Nach Quellcodeänderungen
+baue die Images erneut mit dem obigen Build-Befehl und führe anschließend beide
+`up -d --wait`-Befehle aus.
+
+Verwende keine Option `down -v`, wenn du deine Testdaten behalten möchtest: Sie
+löscht die Volumes des betreffenden Projekts. Andere Compose-Projekte und ihre
+Volumes werden von diesen Testbefehlen nicht angesprochen.
+
+## Status und Logs
+
+```bash
+TEST_WEB_PORT=18080 docker compose --env-file /dev/null \
+ -p openclockwork-test-solo -f docker-compose.test.yml ps
+TEST_WEB_PORT=18081 docker compose --env-file /dev/null \
+ -p openclockwork-test-team -f docker-compose.test.yml ps
+
+TEST_WEB_PORT=18081 docker compose --env-file /dev/null \
+ -p openclockwork-test-team -f docker-compose.test.yml logs --tail=100 api web
+```
+
+Gesundheitsstatus im Browser: beziehungsweise
+. Falls ein Port bereits belegt ist, verwende
+für alle Befehle dieses Projekts einen anderen `TEST_WEB_PORT`; dadurch werden
+Portfreigabe und erlaubte Browser-Origin gemeinsam angepasst.
diff --git a/docs/LOCAL_PIN_DOCKER_TEST.de.md b/docs/LOCAL_PIN_DOCKER_TEST.de.md
new file mode 100644
index 0000000..5ba7b64
--- /dev/null
+++ b/docs/LOCAL_PIN_DOCKER_TEST.de.md
@@ -0,0 +1,74 @@
+# OpenClockwork 2.1.0 mit PIN lokal im Docker testen
+
+Version 2.1.0 enthält die QR/PIN-Implementierung von Inkrement A. API und Web
+verwenden die zusammen gebauten Images `openclockwork-api:2.1.0-pin-local` und
+`openclockwork-web:2.1.0-pin-local`. Nach dem Neubau muss die API unter
+`/api/health` Version `2.1.0` melden.
+
+Die lokale PIN-Abnahme und die Release-Freigabe wurden am 11.10.2026 vom Nutzer
+bestätigt. Damit ist keine zusätzliche Zielgeräte-/OS-
+oder externe Archivabnahme behauptet; diese bleibt installationsbezogen.
+
+Die separate Testversion bindet auf `http://localhost:18082`. Projektname,
+Volumes und Images sind von den bestehenden Solo-/Team-Teststacks getrennt.
+Alle enthaltenen Zugangsdaten sind synthetisch und nur für lokale Tests gedacht.
+
+```sh
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml up -d --build
+```
+
+Der separate Migrationscontainer führt Migrationen aus und richtet die
+eingeschränkte Runtime-Datenbankrolle ein. Erst danach startet die API.
+Die vorhandene Testdatenbank und ihre Volumes beim Update erhalten und zuvor
+sichern. Sie benötigen weder Seed noch Reset. Eine bereits angewandte
+PIN-Migration bleibt unverändert; Prisma überspringt sie beim erneuten Start.
+Nur eine neue leere Testdatenbank einmal synthetisch befüllen:
+
+```sh
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml run --rm \
+ -e NODE_ENV=development \
+ -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test_pin \
+ migrate node --import tsx prisma/seed.ts
+```
+
+HR: `hannah.roth@openclockwork.test`, Passwort `openclockwork`.
+Die Mitarbeiterliste enthält weitere synthetische Konten und Personalnummern.
+HR konfiguriert QR/PIN und vergibt einen PIN; er wird bei jeder Vergabe zufällig
+erzeugt und einmal angezeigt. Unter `/kiosk/pair` mit dem HR-Pairing-Code koppeln.
+Zum Desktoptest den Geofence bewusst ausschalten; für GPS/Tablet-Abnahme einen
+vertrauenswürdigen HTTPS-Ursprung mit passender CORS-Konfiguration verwenden.
+
+Prüfen: PIN identifiziert ohne Buchung, ausdrückliches IN/OUT bucht genau einmal,
+falsche PIN bleibt neutral, Reset/Deaktivieren/Widerruf sperrt alte Freigaben,
+QR bleibt nutzbar, wenn es für das Terminal aktiviert ist. Nach Deaktivierung
+eines ganzen Terminals oder Gerätewiderruf ist eine neue Kopplung erforderlich.
+In HR ist der Audit-Verlauf sichtbar. Die Zugriffsbeschränkungen aus 2.0.1
+bleiben aktiv: Mitarbeiter sehen eigene Personaldaten, Manager ihr direktes
+Team und HR alle Mitarbeiter; PIN-Verwaltung bleibt HR vorbehalten.
+Details zu Grenzen und Betrieb: [QR/PIN-Betriebsanleitung](TERMINAL_AUTH_OPERATIONS.de.md).
+
+Audit-Testadapter im separaten Exportvolume. Bei jeder Wiederholung frische
+Dateinamen für Export und Anker wählen: Die Werkzeuge überschreiben keine
+Dateien, und das Volume bleibt nach `down` erhalten.
+
+```sh
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml exec -T api \
+ node ops/terminal-audit.mjs export /app/data/audit/pilot.json
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml exec -T api \
+ node ops/terminal-audit.mjs anchor /app/data/audit/pilot.json /app/data/audit/pilot.anchor.json
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml exec -T api \
+ node ops/terminal-audit.mjs verify /app/data/audit/pilot.json /app/data/audit/pilot.anchor.json
+```
+
+Diese beiden lokalen Dateien sind eine Funktionsprüfung. Produktionsanker müssen
+in ein unabhängig geschütztes Archiv übertragen und dort nachgeprüft werden.
+
+```sh
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml ps
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml logs --tail=100 api
+docker compose -p openclockwork-pin-test -f docker-compose.pin-test.yml down
+```
+
+`down` erhält Testdaten. `down -v` würde sie löschen; vor erneutem Seeden vorhandene
+Daten prüfen. Port ändern: `PIN_TEST_WEB_PORT=18083` bei allen Compose-Befehlen.
+RFID ist kein Bestandteil dieser Testversion.
diff --git a/docs/OPERATING_MODES.md b/docs/OPERATING_MODES.md
index 631f85e..bfcd685 100644
--- a/docs/OPERATING_MODES.md
+++ b/docs/OPERATING_MODES.md
@@ -1,6 +1,6 @@
# Operating modes: Solo and Team
-OpenClockwork 2.0.0 offers two workflows on the same self-hosted installation.
+OpenClockwork 2.1.0 offers two workflows on the same self-hosted installation.
The mode is installation-wide, not a per-browser preference, billing plan, or
separate database. Pick the workflow that matches who uses the application and
who needs to make decisions about the recorded work.
diff --git a/docs/SOLO_MODE.md b/docs/SOLO_MODE.md
index cee58d7..5d3bbc8 100644
--- a/docs/SOLO_MODE.md
+++ b/docs/SOLO_MODE.md
@@ -1,6 +1,6 @@
# Solo mode
-Solo mode in OpenClockwork 2.0.1 is for one person managing their own work:
+Solo mode in OpenClockwork 2.1.0 is for one person managing their own work:
freelancers, independent consultants, and owner-operated businesses. It provides
time capture, customers, projects, service orders, personal calendar entries, and
timesheets without manager approvals or a fictional HR department.
@@ -31,11 +31,11 @@ are **not implemented** in this release. See the [roadmap](../ROADMAP.md).
Use the [production installation guide](../README.md#production-installation-step-by-step)
for host requirements, independent secrets, TLS, persistent volumes, and backups.
-For 2.0.1, check out the `v2.0.1` tag and set the following in your private
+For 2.1.0, check out the `v2.1.0` tag and set the following in your private
`.env.prod`:
```dotenv
-OPENCLOCKWORK_VERSION=2.0.1
+OPENCLOCKWORK_VERSION=2.1.0
TZ=UTC
```
diff --git a/docs/TEAM_MODE.md b/docs/TEAM_MODE.md
index 7ec538a..2203484 100644
--- a/docs/TEAM_MODE.md
+++ b/docs/TEAM_MODE.md
@@ -13,8 +13,8 @@ employee, manager, kiosk, or approval setup has to be replaced by Solo. Choose
## Install and bootstrap
Follow the [production installation guide](../README.md#production-installation-step-by-step).
-For this release, use the `v2.0.1` source tag and set
-`OPENCLOCKWORK_VERSION=2.0.1` in the private `.env.prod` file. Configure all
+For this release, use the `v2.1.0` source tag and set
+`OPENCLOCKWORK_VERSION=2.1.0` in the private `.env.prod` file. Configure all
required independent secrets, browser origins, the installation's IANA working
timezone (`TZ`), stable volume names, and TLS before using real personal data.
@@ -62,7 +62,7 @@ generated by the command, not supplied as a command-line option.
| Employee | Own bookings, calendar, time accounts, requests, and substitute tasks |
| Manager | Team request decisions, correction feedback, and authorised project reporting |
| HR administrator | Employee records, schedules, allowances, final workflow steps, terminals, and working-time reports |
-| Paired kiosk | Display a rotating QR challenge; never act as an employee or HR user |
+| Paired kiosk | Display QR or identify by PIN for one explicit booking; never create an employee or HR session |
Assign the actual manager relationships and roles in employee administration.
Being one person with an administrator account does not automatically turn a
@@ -82,7 +82,7 @@ switch succeeds.
3. **Create employees and reporting relationships.** Enter start dates, weekly
hours, roles, managers, holiday calendars, and actual leave entitlements.
Employee creation is administrator-managed; email invitation/onboarding is
- not part of 2.0.1.
+ not part of 2.1.0.
4. **Review accounts.** Configure opening time balances, annual leave,
carry-over, expiry, and reasoned adjustments as applicable. Check calculations
against representative contractual cases before operational use.
@@ -158,8 +158,10 @@ they do not acquire a new retrospective manager-approval requirement.
HR can configure an ordinary tablet as a branded clock display with an internal
name, visible location, custom message, optional logo, timezone, and optional
GPS geofence. Pair it once with a short-lived code. An employee scans the
-rotating QR challenge from their authenticated phone and explicitly chooses
-clock-in or clock-out.
+rotating QR challenge from their authenticated phone or enters their personal
+number and separate PIN on the paired kiosk, then explicitly confirms clock-in
+or clock-out. HR selects QR/PIN per terminal and manages PIN reset/disable/unlock.
+See [PIN and audit operations](TERMINAL_AUTH_OPERATIONS.de.md).
The kiosk has its own least-privilege credentials, not an employee login. Pairing
can be revoked or replaced. QR challenges expire and have replay protection.
diff --git a/docs/TERMINAL_AUTH_OPERATIONS.de.md b/docs/TERMINAL_AUTH_OPERATIONS.de.md
new file mode 100644
index 0000000..f885682
--- /dev/null
+++ b/docs/TERMINAL_AUTH_OPERATIONS.de.md
@@ -0,0 +1,134 @@
+# QR/PIN-Terminal betreiben
+
+OpenClockwork 2.1.0 enthält Inkrement A von Issue #33: Team-Terminals können
+Personalnummer und einen separaten achtstelligen PIN verwenden. RFID ist nicht
+Bestandteil dieses Releases. Bestehende QR-Terminals bleiben beim Upgrade
+QR-only. Solo erlaubt keine Terminalfunktionen. Die Leseberechtigungen für
+Personaldaten aus 2.0.1 bleiben erhalten.
+
+Das Upgrade von 2.0.1 ergänzt die Migration
+`20260930090000_terminal_pin_credentials`. Bestehende Daten und angewandte
+Migrationen bleiben erhalten; kein Seed, Reset oder Umschreiben früherer
+Migrationen ist erforderlich. Die PIN-Aktivierung erfolgt erst nach Einrichtung
+der getrennten Datenbankrollen und des unten beschriebenen Audit-Betriebs.
+Siehe [Upgrade-Anleitung](../UPGRADING.md).
+
+## Einrichtung und täglicher Ablauf
+
+1. Für alle API-Instanzen dieselben unabhängigen Secrets bereitstellen:
+ `TERMINAL_PIN_PEPPER` und `TERMINAL_AUDIT_KEY`, jeweils mindestens 32 Zeichen.
+ Sie dürfen weder untereinander noch mit JWT-/QR-Secrets identisch sein.
+ Für Produktion zuvor Rollen, Archiv und Aufbewahrung gemäß den folgenden
+ Abschnitten einrichten und prüfen.
+2. Unter Administration → Einstellungen → Terminals QR und/oder PIN aktivieren.
+ Ein aktives Terminal benötigt mindestens eine Methode. Das Gerät wie bisher
+ per kurzlebigem Pairing-Code unter `/kiosk/pair` koppeln.
+3. In der Mitarbeiterverwaltung einen Terminal-PIN erzeugen. HR sieht ihn
+ einmalig und übergibt ihn persönlich. Das Webpasswort bleibt separat.
+ Verlorene Ausgabe durch erneuten Reset ersetzen; der alte PIN wird ungültig.
+4. Am Kiosk Personalnummer exakt einschließlich führender Nullen und PIN
+ eingeben. Erst die anschließende ausdrückliche Ein-/Aus-Bestätigung bucht.
+ Bei aktiviertem Geofence muss der Kiosk eine frische gültige Position liefern.
+
+Nach fünf Fehlversuchen innerhalb 15 Minuten wird der PIN für 15 Minuten
+gesperrt. QR/Weblogin bleiben verfügbar. HR kann entsperren oder den PIN
+deaktivieren. Reset, Deaktivierung, Gerätewiderruf und Methodenänderung
+invalidieren noch nicht eingelöste Freigaben. Fehlermeldungen verraten keine
+Mitarbeiteridentität. Eine falsche PIN entfernt die Gerätekopplung nicht.
+
+Kiosk-Daten liegen nur kurz im Speicher. Nach Erfolg, Abbruch, Hintergrundwechsel
+oder Timeout werden PIN, Name und Freigabe gelöscht. Bei unklarer Netzwerkantwort
+zeigt der Kiosk einen unbestätigten Zustand: nur dieselbe Aktion mit derselben
+Freigabe wiederholen. Eine Wiederholung erzeugt keine zweite Buchung. Nach Abbruch
+zuerst den Buchungsstand prüfen; die fehlende Antwort beweist keine Nichtbuchung.
+Es gibt kein Offline-Stempeln.
+
+## Datenbankrechte und Audit
+
+Migrationen mit einer eigenen Owner-Rolle ausführen. Die API verwendet eine
+getrennte Runtime-Rolle ohne Objektbesitz, Rollenmitgliedschaften oder
+Superuser-Rechte. Das Rollenwerkzeug benötigt einen ausreichend privilegierten
+Administrationszugang für `CREATE/ALTER ROLE` einschließlich der gesetzten
+Rollenattribute; Tabellenbesitz oder `CREATEROLE` allein genügt dafür nicht in
+jeder PostgreSQL-Umgebung. Dieser Zugang gehört ausschließlich in den
+Migrations-/Administrationsjob, niemals in die API. Nach jeder Migration
+die Tabellenrechte erneut einrichten. Das Runtime-Passwort muss mindestens
+20 Zeichen enthalten. Die Werte als geschützte Umgebungsvariablen bereitstellen:
+
+```sh
+DATABASE_URL="${MIGRATION_DATABASE_URL:?Migrationszugang bereitstellen}" \
+TERMINAL_RUNTIME_DB_PASSWORD="${TERMINAL_RUNTIME_DB_PASSWORD:?Runtime-Passwort bereitstellen}" \
+node ops/configure-terminal-runtime.mjs
+```
+
+Das Werkzeug erzeugt standardmäßig `openclockwork_runtime`, gibt normale
+Anwendungs-CRUD-Rechte und beschränkt `TerminalAuditEvent` auf SELECT/INSERT.
+Die API-Verbindung muss danach den Runtime-Benutzer verwenden. Vor Produktions-
+einführung die tatsächlichen Rechte einschließlich PUBLIC-Grants prüfen.
+Die Produktions-API verweigert den Start mit aktiven PIN-Secrets, wenn die
+Audit-Rechte oder Rollentrennung unsicher sind. Das optionale
+`docker-compose.terminal-pin.yml` trennt Migrationsjob und API-Start; es setzt
+`MIGRATION_DATABASE_URL`, Runtime-Passwort und unabhängige PIN-/Audit-Secrets
+voraus. Mit der Produktions-Compose-Datei und passenden API-/Web-Images ab
+Version 2.1.0 kombinieren; `OPENCLOCKWORK_VERSION=2.1.0` setzen. Das Overlay
+ersetzt den Migrationsstart in der API durch den getrennten Job. `DATABASE_URL`
+verwendet danach den Runtime-Benutzer mit demselben Passwort, das
+`TERMINAL_RUNTIME_DB_PASSWORD` für die Rolleneinrichtung bereitstellt.
+Azure-Referenzdeployments bleiben ohne entsprechende Secrets und Rollentrennung
+QR-only; die neue Methode wird nicht automatisch produktiv aktiviert.
+UPDATE/DELETE werden zusätzlich durch einen Trigger verweigert. Owner, Backup-
+und Wartungszugriff müssen organisatorisch getrennt bleiben.
+
+HR kann den gefilterten Verlauf in der Terminalverwaltung lesen. Ereignisse
+enthalten Methode, Ergebnis, Serverzeit, bekannte Mitarbeiter-/Aktor-IDs und
+Terminal-/Gerätebezug. Sie enthalten keine PINs, Personalnummern, JWTs oder Grants.
+Historische Audit-Ereignisse bleiben beim Löschen eines Terminals erhalten.
+
+Die Ereignisse bilden eine fortlaufende HMAC-SHA-256-Kette mit eigenem Key und
+kanonischem Payload. Export und Prüfung benötigen alle historischen Audit-Keys:
+
+```sh
+node ops/terminal-audit.mjs export /secure-export/audit-2026-09-30.json
+node ops/terminal-audit.mjs anchor /secure-export/audit-2026-09-30.json /independent-archive/anchor-2026-09-30.json
+node ops/terminal-audit.mjs verify /secure-export/audit-2026-09-30.json /independent-archive/anchor-2026-09-30.json
+```
+
+`DATABASE_URL`, `TERMINAL_AUDIT_KEY`, `TERMINAL_AUDIT_KEY_VERSION` und optional
+`TERMINAL_AUDIT_PREVIOUS_KEYS` über geschützte Umgebungsvariablen bereitstellen.
+Exporte enthalten personenbezogene Verweise und benötigen Zugriffsschutz.
+Dateien werden nie überschrieben. Die Prüfung erkennt geänderte Felder,
+Kettenlücken, falsche Signaturen und Kürzungen gegenüber dem unabhängigen Anker.
+
+Ein externer Anker muss außerhalb von App/DB unveränderlich aufbewahrt werden,
+etwa in einem getrennt administrierten Retention-Archiv. Das lokale Dateiverfahren
+ist ein Prüfadapter; Dateirechte allein bieten keine WORM-Garantie. Betreiber
+legen Speicherziel, Key-Zugriff, regelmäßigen Export, Überwachung, Aufbewahrung
+und Wiederherstellungsprüfung vor dem produktiven PIN-Einsatz fest. Die lokale
+Nutzerabnahme und die Veröffentlichung von 2.1.0 ersetzen diese
+installationsbezogene Konfiguration und Prüfung nicht. Ereignisse seit dem
+letzten externen Anker sind ein verbleibendes Manipulationsfenster. Kein
+automatischer Audit-Purge ist konfiguriert. Eine Löschung erfordert separate
+Wartungsrechte und eine dokumentierte Archivgrenze.
+
+## Schlüsselwechsel, Backup und Rückfall
+
+PIN-Pepper versionieren über `TERMINAL_PIN_PEPPER_VERSION`. Bei Rotation den
+alten Pepper zeitweise in `TERMINAL_PIN_PREVIOUS_PEPPERS` als JSON-Map halten;
+alle API-Instanzen müssen dasselbe Keyset haben. PIN-Hashes lassen sich ohne
+Klartext nicht offline auf einen neuen Pepper umrechnen: HR muss PINs neu vergeben,
+bevor alte Keys entfernt werden. Ein verlorener Pepper erfordert Neuvergabe.
+Fehlender Audit-Key oder Pepper verhindert PIN-Aktivierung; Legacy-QR bleibt
+ohne neue Secrets nutzbar.
+
+Audit-Key versionieren über `TERMINAL_AUDIT_KEY_VERSION`; alte Audit-Keys für
+Verifikation in geschütztem Schlüsselarchiv behalten. `TERMINAL_AUDIT_PREVIOUS_KEYS`
+wird nur vom Export-/Prüfwerkzeug gelesen. PIN-/Audit-Keys getrennt von DB-Backups
+sichern und Wiederherstellung zusammen mit dem letzten unabhängigen Anker testen.
+TLS und ein gehärteter Kiosk bleiben notwendig; ein gestohlenes Gerätetoken kann
+ein Gerät imitieren. Browser-GPS ist kein fälschungssicherer Anwesenheitsnachweis.
+
+Rückfall: in HR PIN deaktivieren, QR ausdrücklich aktivieren und Kiosk prüfen.
+Schema und Audit bleiben erhalten. Keine destruktive Down-Migration und kein
+ungeprüfter Rücksprung auf alte Binaries. Tablet/Safari/PWA, Sleep/Resume,
+Netzverlust, GPS und schnelle Mitarbeiterwechsel müssen auf der Zielhardware
+abgenommen werden; lokale Chromium-Tests ersetzen diese Betriebsabnahme nicht.
diff --git a/ops/configure-terminal-runtime.mjs b/ops/configure-terminal-runtime.mjs
new file mode 100644
index 0000000..af67906
--- /dev/null
+++ b/ops/configure-terminal-runtime.mjs
@@ -0,0 +1,100 @@
+import { Client } from 'pg';
+
+// Run with the migration owner's connection; never give its credentials to API.
+async function main() {
+ const role = process.env.TERMINAL_RUNTIME_DB_ROLE ?? 'openclockwork_runtime';
+ const password = process.env.TERMINAL_RUNTIME_DB_PASSWORD;
+ if (
+ !/^[a-z][a-z0-9_]{0,62}$/.test(role) ||
+ !password ||
+ password.length < 20 ||
+ !process.env.DATABASE_URL
+ )
+ throw new Error('Invalid runtime role configuration');
+ const identifier = (value) => '"' + value.replaceAll('"', '""') + '"';
+ const literal = (value) => "'" + value.replaceAll("'", "''") + "'";
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ try {
+ await client.query('BEGIN');
+ await client.query('SET LOCAL standard_conforming_strings = on');
+ const {
+ rows: [{ database, owner }],
+ } = await client.query(
+ 'SELECT current_database() AS database, current_user AS owner',
+ );
+ if (owner === role) throw new Error('Use the separate migration owner');
+ const existing = await client.query(
+ 'SELECT 1 FROM pg_roles WHERE rolname = $1',
+ [role],
+ );
+ if (!existing.rowCount)
+ await client.query(
+ `CREATE ROLE ${identifier(role)} LOGIN PASSWORD ${literal(password)} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS`,
+ );
+ else
+ await client.query(
+ `ALTER ROLE ${identifier(role)} LOGIN PASSWORD ${literal(password)} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS`,
+ );
+ // Fail if the proposed runtime has ownership or inherited owner privileges.
+ // Shared dependencies include schema/database/function ownership as well as
+ // tables. A schema owner could otherwise DROP SCHEMA ... CASCADE the audit.
+ const ownership = await client.query(
+ "SELECT 1 FROM pg_shdepend d JOIN pg_roles r ON r.oid=d.refobjid WHERE d.refclassid='pg_authid'::regclass AND d.deptype='o' AND r.rolname=$1 LIMIT 1",
+ [role],
+ );
+ const memberships = await client.query(
+ 'SELECT 1 FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member WHERE r.rolname=$1 LIMIT 1',
+ [role],
+ );
+ if (ownership.rowCount || memberships.rowCount)
+ throw new Error('Runtime must not own objects or inherit roles');
+ await client.query(
+ `GRANT CONNECT ON DATABASE ${identifier(database)} TO ${identifier(role)}`,
+ );
+ await client.query(`GRANT USAGE ON SCHEMA public TO ${identifier(role)}`);
+ await client.query(
+ `REVOKE CREATE ON SCHEMA public FROM ${identifier(role)}`,
+ );
+ const schemaRights = await client.query(
+ "SELECT has_schema_privilege($1, 'public', 'CREATE') AS unsafe",
+ [role],
+ );
+ if (schemaRights.rows[0].unsafe)
+ throw new Error(
+ 'Runtime inherits public schema CREATE; remove that grant before setup',
+ );
+ await client.query(
+ `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${identifier(role)}`,
+ );
+ await client.query(
+ `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${identifier(role)}`,
+ );
+ await client.query('REVOKE ALL ON "TerminalAuditEvent" FROM PUBLIC');
+ await client.query(
+ `REVOKE ALL ON "TerminalAuditEvent" FROM ${identifier(role)}`,
+ );
+ await client.query(
+ `GRANT SELECT, INSERT ON "TerminalAuditEvent" TO ${identifier(role)}`,
+ );
+ await client.query(
+ `REVOKE ALL ON "_prisma_migrations" FROM ${identifier(role)}`,
+ );
+ // Repeat after every deployment so newly added tables receive explicit grants.
+ await client.query('COMMIT');
+ console.log(
+ 'Terminal runtime role configured with append-only audit permissions',
+ );
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ } finally {
+ await client.end();
+ }
+}
+main().catch(() => {
+ console.error(
+ 'Runtime role configuration failed; use a separate migration owner and check the role.',
+ );
+ process.exitCode = 1;
+});
diff --git a/ops/terminal-audit-chain.mjs b/ops/terminal-audit-chain.mjs
new file mode 100644
index 0000000..beeaee2
--- /dev/null
+++ b/ops/terminal-audit-chain.mjs
@@ -0,0 +1,99 @@
+import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
+
+export function auditKeys(env = process.env) {
+ let keys = JSON.parse(env.TERMINAL_AUDIT_PREVIOUS_KEYS ?? '{}');
+ if (!keys || Array.isArray(keys) || typeof keys !== 'object')
+ throw new Error('Invalid audit key map');
+ if (env.TERMINAL_AUDIT_KEY)
+ keys = {
+ ...keys,
+ [env.TERMINAL_AUDIT_KEY_VERSION ?? '1']: env.TERMINAL_AUDIT_KEY,
+ };
+ for (const [version, key] of Object.entries(keys)) {
+ if (
+ !/^[A-Za-z0-9_.-]{1,40}$/.test(version) ||
+ typeof key !== 'string' ||
+ key.length < 32
+ )
+ throw new Error('Invalid audit key');
+ }
+ return keys;
+}
+
+export function canonicalEvent(row) {
+ return JSON.stringify({
+ format: 1,
+ sequence: String(row.sequence),
+ keyVersion: row.keyVersion,
+ id: row.id,
+ occurredAt: new Date(row.occurredAt).toISOString(),
+ event: row.event,
+ result: row.result,
+ method: row.method ?? null,
+ action: row.action ?? null,
+ employeeId: row.employeeId ?? null,
+ actorId: row.actorId ?? null,
+ terminalId: row.terminalId ?? null,
+ deviceId: row.deviceId ?? null,
+ terminalName: row.terminalName ?? null,
+ locationLabel: row.locationLabel ?? null,
+ timeEntryId: row.timeEntryId ?? null,
+ });
+}
+
+export function verifyAudit(snapshot, keys, anchor) {
+ if (snapshot.format !== 1 || !Array.isArray(snapshot.events))
+ throw new Error('Invalid audit snapshot');
+ let previousHash = '0'.repeat(64);
+ let sequence = 0n;
+ for (const row of snapshot.events) {
+ if (
+ !/^[1-9][0-9]*$/.test(String(row.sequence)) ||
+ BigInt(row.sequence) !== sequence + 1n
+ )
+ throw new Error('Audit sequence gap');
+ if (
+ row.previousHash !== previousHash ||
+ row.canonicalPayload !== canonicalEvent(row)
+ )
+ throw new Error('Audit payload/link mismatch');
+ const key = Object.hasOwn(keys, row.keyVersion)
+ ? keys[row.keyVersion]
+ : undefined;
+ if (!key) throw new Error('Missing historical audit key');
+ const expected = createHmac('sha256', key)
+ .update(`${previousHash}\n${row.canonicalPayload}`)
+ .digest();
+ if (
+ !/^[a-f0-9]{64}$/.test(row.hash) ||
+ !timingSafeEqual(expected, Buffer.from(row.hash, 'hex'))
+ )
+ throw new Error('Audit HMAC mismatch');
+ sequence += 1n;
+ previousHash = row.hash;
+ }
+ const head = { sequence: sequence.toString(), hash: previousHash };
+ if (JSON.stringify(snapshot.head) !== JSON.stringify(head))
+ throw new Error('Audit head mismatch');
+ if (anchor) {
+ if (
+ anchor.format !== 1 ||
+ anchor.database !== snapshot.database ||
+ !/^[0-9]+$/.test(anchor.head?.sequence ?? '')
+ )
+ throw new Error('Invalid audit anchor');
+ const anchoredSequence = BigInt(anchor.head.sequence);
+ const anchoredEvent = snapshot.events.find(
+ (event) => String(event.sequence) === anchor.head.sequence,
+ );
+ const anchoredHash =
+ anchoredSequence === 0n ? '0'.repeat(64) : anchoredEvent?.hash;
+ if (anchoredSequence > sequence || anchoredHash !== anchor.head.hash)
+ throw new Error('Audit differs from external anchor');
+ }
+ return head;
+}
+
+export function snapshotDigest(content) {
+ return createHash('sha256').update(content).digest('hex');
+}
diff --git a/ops/terminal-audit-chain.test.mjs b/ops/terminal-audit-chain.test.mjs
new file mode 100644
index 0000000..7897d99
--- /dev/null
+++ b/ops/terminal-audit-chain.test.mjs
@@ -0,0 +1,137 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createHmac } from 'node:crypto';
+import {
+ auditKeys,
+ canonicalEvent,
+ verifyAudit,
+} from './terminal-audit-chain.mjs';
+
+const key = 'synthetic-test-audit-key-independent-000001';
+function snapshot() {
+ const row = {
+ sequence: '1',
+ keyVersion: '1',
+ id: 'synthetic-event',
+ occurredAt: '2026-09-30T10:00:00.000Z',
+ event: 'Booking',
+ result: 'SUCCESS',
+ method: 'PIN',
+ };
+ row.previousHash = '0'.repeat(64);
+ row.canonicalPayload = canonicalEvent(row);
+ row.hash = createHmac('sha256', key)
+ .update(`${row.previousHash}\n${row.canonicalPayload}`)
+ .digest('hex');
+ return {
+ format: 1,
+ database: 'openclockwork_test',
+ head: { sequence: '1', hash: row.hash },
+ events: [row],
+ };
+}
+test('valid chain and independently retained head match', () => {
+ const data = snapshot();
+ assert.deepEqual(
+ verifyAudit(
+ data,
+ { 1: key },
+ { format: 1, database: data.database, head: data.head },
+ ),
+ data.head,
+ );
+});
+test('changed DB fields, canonical content, HMAC and sequence gaps are detected', () => {
+ for (const mutate of [
+ (data) => (data.events[0].result = 'FAILED'),
+ (data) => (data.events[0].canonicalPayload += ' '),
+ (data) => (data.events[0].hash = 'f'.repeat(64)),
+ (data) => (data.events[0].previousHash = 'f'.repeat(64)),
+ (data) => (data.events[0].sequence = '2'),
+ ]) {
+ const data = snapshot();
+ mutate(data);
+ assert.throws(() => verifyAudit(data, { 1: key }));
+ }
+});
+test('truncated chain cannot match an external anchor even with recomputed local head', () => {
+ const original = snapshot();
+ const empty = {
+ ...original,
+ events: [],
+ head: { sequence: '0', hash: '0'.repeat(64) },
+ };
+ assert.throws(
+ () =>
+ verifyAudit(
+ empty,
+ { 1: key },
+ { format: 1, database: original.database, head: original.head },
+ ),
+ /external anchor/,
+ );
+});
+test('historical keys are needed after rotation and wrong keys fail', () => {
+ assert.throws(() => verifyAudit(snapshot(), {}), /historical/);
+ assert.throws(() => verifyAudit(snapshot(), { 1: key + 'x' }), /HMAC/);
+ assert.deepEqual(
+ auditKeys({
+ TERMINAL_AUDIT_KEY: key,
+ TERMINAL_AUDIT_KEY_VERSION: '2',
+ TERMINAL_AUDIT_PREVIOUS_KEYS: JSON.stringify({ 1: key }),
+ }),
+ { 1: key, 2: key },
+ );
+ assert.throws(() => auditKeys({ TERMINAL_AUDIT_KEY: 'short' }));
+});
+test('a later key version extends the chain while retaining the previous external anchor', () => {
+ const data = snapshot();
+ const anchor = { format: 1, database: data.database, head: { ...data.head } };
+ const rotatedKey = key + '-rotated';
+ const row = {
+ ...data.events[0],
+ sequence: '2',
+ keyVersion: '2',
+ id: 'second-event',
+ previousHash: data.head.hash,
+ };
+ row.canonicalPayload = canonicalEvent(row);
+ row.hash = createHmac('sha256', rotatedKey)
+ .update(`${row.previousHash}\n${row.canonicalPayload}`)
+ .digest('hex');
+ data.events.push(row);
+ data.head = { sequence: '2', hash: row.hash };
+ assert.deepEqual(
+ verifyAudit(data, { 1: key, 2: rotatedKey }, anchor),
+ data.head,
+ );
+ assert.throws(
+ () => verifyAudit(data, { 2: rotatedKey }, anchor),
+ /historical/,
+ );
+});
+test('rewriting a chain with a compromised key still conflicts with the external anchor', () => {
+ const data = snapshot();
+ const anchor = { format: 1, database: data.database, head: { ...data.head } };
+ const row = data.events[0];
+ row.result = 'REWRITTEN';
+ row.canonicalPayload = canonicalEvent(row);
+ row.hash = createHmac('sha256', key)
+ .update(`${row.previousHash}\n${row.canonicalPayload}`)
+ .digest('hex');
+ data.head.hash = row.hash;
+ assert.deepEqual(verifyAudit(data, { 1: key }), data.head);
+ assert.throws(() => verifyAudit(data, { 1: key }, anchor), /external anchor/);
+});
+test('inherited object properties are never accepted as historical key versions', () => {
+ const data = snapshot();
+ data.events[0].keyVersion = 'constructor';
+ data.events[0].canonicalPayload = canonicalEvent(data.events[0]);
+ assert.throws(() => verifyAudit(data, {}), /historical/);
+ const keys = auditKeys({
+ TERMINAL_AUDIT_KEY: key,
+ TERMINAL_AUDIT_KEY_VERSION: '__proto__',
+ });
+ assert.equal(Object.hasOwn(keys, '__proto__'), true);
+ assert.equal(keys.__proto__, key);
+});
diff --git a/ops/terminal-audit.mjs b/ops/terminal-audit.mjs
new file mode 100644
index 0000000..6c94d64
--- /dev/null
+++ b/ops/terminal-audit.mjs
@@ -0,0 +1,96 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { Client } from 'pg';
+import {
+ auditKeys,
+ snapshotDigest,
+ verifyAudit,
+} from './terminal-audit-chain.mjs';
+
+async function main() {
+ const [command, file, anchorFile] = process.argv.slice(2);
+ if (
+ !file ||
+ !['export', 'verify', 'anchor'].includes(command) ||
+ (command === 'anchor' && !anchorFile)
+ )
+ throw new Error(
+ 'Usage: terminal-audit.mjs export FILE | verify FILE [ANCHOR] | anchor FILE ANCHOR',
+ );
+ const keys = auditKeys();
+ if (command === 'export') {
+ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ try {
+ await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
+ const {
+ rows: [{ database }],
+ } = await client.query('SELECT current_database() AS database');
+ // Prisma stores UTC instants in TIMESTAMP WITHOUT TIME ZONE. Convert to
+ // timestamptz explicitly so pg does not interpret them in the CLI's TZ.
+ const { rows: events } =
+ await client.query(`SELECT "sequence", "id", "occurredAt" AT TIME ZONE 'UTC' AS "occurredAt",
+ "event", "result", "method", "action", "employeeId", "actorId", "terminalId", "deviceId",
+ "terminalName", "locationLabel", "timeEntryId", "canonicalPayload", "previousHash", "hash", "keyVersion"
+ FROM "TerminalAuditEvent" ORDER BY "sequence"`);
+ await client.query('COMMIT');
+ const last = events.at(-1);
+ const snapshot = {
+ format: 1,
+ database,
+ exportedAt: new Date().toISOString(),
+ head: {
+ sequence: last?.sequence ?? '0',
+ hash: last?.hash ?? '0'.repeat(64),
+ },
+ events,
+ };
+ verifyAudit(snapshot, keys);
+ await writeFile(file, JSON.stringify(snapshot, null, 2) + '\n', {
+ flag: 'wx',
+ mode: 0o600,
+ });
+ console.log(`Audit export verified: ${snapshot.head.sequence} events`);
+ } finally {
+ await client.end();
+ }
+ return;
+ }
+ const content = await readFile(file, 'utf8');
+ const snapshot = JSON.parse(content);
+ const anchor =
+ command === 'verify' && anchorFile
+ ? JSON.parse(await readFile(anchorFile, 'utf8'))
+ : undefined;
+ const head = verifyAudit(snapshot, keys, anchor);
+ if (command === 'anchor') {
+ // The destination must be independently retained by the operator. File mode
+ // alone is a local test adapter and does not provide WORM protection.
+ await writeFile(
+ anchorFile,
+ JSON.stringify(
+ {
+ format: 1,
+ database: snapshot.database,
+ anchoredAt: new Date().toISOString(),
+ head,
+ snapshotSha256: snapshotDigest(content),
+ },
+ null,
+ 2,
+ ) + '\n',
+ { flag: 'wx', mode: 0o444 },
+ );
+ console.log(`Audit anchor created: ${head.sequence} events`);
+ } else
+ console.log(
+ `Audit verified: ${head.sequence} events${anchor ? ', external anchor matched' : ''}`,
+ );
+}
+
+main().catch(() => {
+ console.error(
+ 'Terminal audit operation failed; check the target, key versions and audit integrity.',
+ );
+ process.exitCode = 1;
+});
diff --git a/ops/terminal-pin-runtime-smoke.mjs b/ops/terminal-pin-runtime-smoke.mjs
new file mode 100644
index 0000000..73b6d06
--- /dev/null
+++ b/ops/terminal-pin-runtime-smoke.mjs
@@ -0,0 +1,560 @@
+import { spawn } from 'node:child_process';
+import { randomBytes, randomUUID } from 'node:crypto';
+
+// Deliberately fixed: this test may restart one API and create synthetic records.
+// It never runs against the production Compose project or an arbitrary database.
+const PROJECT = 'openclockwork-pin-test';
+const DATABASE = 'openclockwork_test_pin';
+const CONTAINER_ID = /^[a-f0-9]{12,64}$/;
+const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
+let stage = 'target validation';
+
+function check(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+function docker(args, input = '') {
+ return new Promise((resolve, reject) => {
+ // No shell, response bodies, credentials in argv, inherited test-secret
+ // assignments, or file-backed IPC. Child output stays in private memory.
+ const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
+ let output = '';
+ let bytes = 0;
+ const timer = setTimeout(() => child.kill(), 45_000);
+ child.stdout.on('data', (chunk) => {
+ bytes += chunk.length;
+ if (bytes > 1_048_576) child.kill();
+ else output += chunk.toString();
+ });
+ // Never forward Docker/Node/HTTP errors, which may contain request data.
+ child.stderr.on('data', () => {});
+ child.on('error', () => {
+ clearTimeout(timer);
+ reject(new Error('Docker command unavailable'));
+ });
+ child.on('close', (code) => {
+ clearTimeout(timer);
+ if (code !== 0 || bytes > 1_048_576)
+ reject(new Error('Docker command failed'));
+ else resolve(output);
+ });
+ child.stdin.on('error', () => {});
+ child.stdin.end(input);
+ });
+}
+
+async function containers(service) {
+ const output = await docker([
+ 'ps',
+ '--filter',
+ `label=com.docker.compose.project=${PROJECT}`,
+ '--filter',
+ `label=com.docker.compose.service=${service}`,
+ '--filter',
+ 'status=running',
+ '--format',
+ '{{.ID}}',
+ ]);
+ const ids = output.trim().split(/\s+/).filter(Boolean);
+ check(
+ ids.every((id) => CONTAINER_ID.test(id)),
+ 'Invalid container identity',
+ );
+ check(new Set(ids).size === ids.length, 'Duplicate container identity');
+ return ids.sort();
+}
+
+async function inspect(id, format) {
+ check(CONTAINER_ID.test(id), 'Invalid container identity');
+ return JSON.parse(await docker(['inspect', '--format', format, id]));
+}
+
+// Each execution runs inside a specific API container, whose existing runtime
+// connection is checked without exposing it. Secrets in the payload reach Node
+// only via docker exec stdin; JSON stdout is a private pipe back to this process.
+async function inContainer(id, payload) {
+ check(CONTAINER_ID.test(id), 'Invalid container identity');
+ const program = `
+ const fs = require('node:fs');
+ const { Client } = require('pg');
+ const input = ${JSON.stringify(payload)};
+ async function main() {
+ const target = new URL(process.env.DATABASE_URL);
+ if (!['postgres:', 'postgresql:'].includes(target.protocol)
+ || target.hostname !== 'db' || target.pathname !== '/${DATABASE}'
+ || target.searchParams.get('schema') !== 'public'
+ || process.env.NODE_ENV !== 'production' || process.env.API_PORT !== '3000')
+ throw new Error('Refused target');
+ if (input.kind === 'http') {
+ if (!/^\\/api\\//.test(input.path) || input.path.includes('..')) throw new Error('Refused route');
+ const response = await fetch('http://127.0.0.1:3000' + input.path, {
+ method: input.method, redirect: 'error', signal: AbortSignal.timeout(15000),
+ headers: { 'Content-Type': 'application/json', ...(input.token ? { Authorization: 'Bearer ' + input.token } : {}) },
+ ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
+ });
+ const content = await response.text();
+ return { status: response.status, body: content ? JSON.parse(content) : null };
+ }
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+ try {
+ const { rows: [connected] } = await client.query('SELECT current_database() AS database, current_schema() AS schema');
+ if (connected.database !== '${DATABASE}' || connected.schema !== 'public') throw new Error('Refused database');
+ if (input.kind === 'validate') {
+ const count = fs.readdirSync('/proc').filter((pid) => /^\\d+$/.test(pid)).filter((pid) => {
+ try {
+ const argv = fs.readFileSync('/proc/' + pid + '/cmdline', 'utf8').split('\\0');
+ return /(?:^|\\/)node$/.test(argv[0] ?? '') && argv.includes('dist/main.js');
+ } catch { return false; }
+ }).length;
+ if (count !== 1) throw new Error('Expected one real API server process');
+ return { ...connected, serverProcesses: count };
+ }
+ if (input.kind !== 'entries' || !/^[a-f0-9-]{36}$/.test(input.employeeId)) throw new Error('Refused query');
+ const { rows: [counts] } = await client.query(
+ 'SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE "clockOut" IS NULL AND "voidedAt" IS NULL)::int AS open FROM "TimeEntry" WHERE "employeeId" = $1::uuid',
+ [input.employeeId]);
+ return counts;
+ } finally { await client.end(); }
+ }
+ main().then((result) => process.stdout.write(JSON.stringify(result))).catch(() => {
+ process.stderr.write('Synthetic runtime check failed'); process.exitCode = 1;
+ });
+ `;
+ return JSON.parse(await docker(['exec', '-i', id, 'node'], program));
+}
+
+function expectStatus(response, expected) {
+ check(response.status === expected, 'Unexpected HTTP status');
+ return response.body;
+}
+
+async function main() {
+ const started = performance.now();
+ const origin = new URL(
+ process.env.TERMINAL_RUNTIME_TEST_ORIGIN ?? 'http://localhost:18082',
+ );
+ check(
+ origin.protocol === 'http:' &&
+ ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname) &&
+ Number(origin.port) >= 18000 &&
+ Number(origin.port) <= 65535 &&
+ !origin.username &&
+ !origin.password &&
+ origin.pathname === '/' &&
+ !origin.search &&
+ !origin.hash,
+ 'Only an explicit high loopback HTTP port is allowed',
+ );
+ check(
+ !process.env.DOCKER_HOST || process.env.DOCKER_HOST.startsWith('unix://'),
+ 'Only local Docker sockets are allowed',
+ );
+ const endpoint = (
+ await docker([
+ 'context',
+ 'inspect',
+ '--format',
+ '{{.Endpoints.docker.Host}}',
+ ])
+ ).trim();
+ check(
+ endpoint.startsWith('unix://'),
+ 'Only a local Docker context is allowed',
+ );
+ const api = await containers('api');
+ const web = await containers('web');
+ const db = await containers('db');
+ check(
+ api.length === 3 && web.length === 1 && db.length === 1,
+ 'Expected exactly three APIs and one local web/database stack',
+ );
+ for (const [service, ids] of [
+ ['api', api],
+ ['web', web],
+ ['db', db],
+ ]) {
+ for (const id of ids) {
+ const labels = await inspect(id, '{{json .Config.Labels}}');
+ check(
+ labels['com.docker.compose.project'] === PROJECT &&
+ labels['com.docker.compose.service'] === service,
+ 'Compose identity differs from the synthetic target',
+ );
+ }
+ }
+ const bindings = await inspect(web[0], '{{json .HostConfig.PortBindings}}');
+ check(
+ bindings['8080/tcp']?.some(
+ (binding) =>
+ ['127.0.0.1', '::1'].includes(binding.HostIp) &&
+ binding.HostPort === origin.port,
+ ),
+ 'Loopback origin is not the selected Compose web port',
+ );
+ const validated = await Promise.all(
+ api.map((id) => inContainer(id, { kind: 'validate' })),
+ );
+ check(
+ validated.every(
+ (value) =>
+ value.database === DATABASE &&
+ value.schema === 'public' &&
+ value.serverProcesses === 1,
+ ),
+ 'All three real API processes must share the protected synthetic database',
+ );
+
+ async function http(method, path, body, token) {
+ const response = await fetch(new URL(path, origin), {
+ method,
+ redirect: 'error',
+ signal: AbortSignal.timeout(15_000),
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ const content = await response.text();
+ return {
+ status: response.status,
+ body: content ? JSON.parse(content) : null,
+ };
+ }
+ const direct = (id, method, path, body, token) =>
+ inContainer(id, { kind: 'http', method, path, body, token });
+ stage = 'synthetic fixture';
+ const loggedIn = expectStatus(
+ await http('POST', '/api/auth/login', {
+ email: 'hannah.roth@openclockwork.test',
+ password: 'openclockwork',
+ }),
+ 200,
+ );
+ check(
+ loggedIn.employee?.role === 'HRAdmin' &&
+ typeof loggedIn.accessToken === 'string',
+ 'Synthetic HR login required',
+ );
+ const hrToken = loggedIn.accessToken;
+ const installation = expectStatus(
+ await http('GET', '/api/installation', undefined, hrToken),
+ 200,
+ );
+ check(installation.mode === 'Team', 'Synthetic target must use Team mode');
+ const suffix = randomUUID();
+ const personalNo = `PIN-OS-${suffix.slice(0, 12)}`;
+ let employeeId;
+ let terminalId;
+ let deviceToken;
+ let pin;
+ let cleanupFailed = false;
+ let completed = false;
+ try {
+ const employee = expectStatus(
+ await http(
+ 'POST',
+ '/api/employees',
+ {
+ personalNo,
+ firstName: 'Synthetic',
+ lastName: 'Process Smoke',
+ email: `pin-process-${suffix}@openclockwork.test`,
+ password: randomBytes(24).toString('base64url'),
+ role: 'Employee',
+ timeModel: 'Vollzeit',
+ weeklyHours: 40,
+ annualLeaveDays: 30,
+ startDate: '2020-01-01',
+ holidayCalendar: 'NONE',
+ },
+ hrToken,
+ ),
+ 201,
+ );
+ check(UUID.test(employee.id), 'Invalid synthetic employee identity');
+ employeeId = employee.id;
+ const reset = expectStatus(
+ await http(
+ 'POST',
+ `/api/employees/${employeeId}/terminal-credentials/pin/reset`,
+ {},
+ hrToken,
+ ),
+ 201,
+ );
+ check(/^\d{8}$/.test(reset.pin), 'Expected synthetic eight-digit PIN');
+ pin = reset.pin;
+ const terminal = expectStatus(
+ await http(
+ 'POST',
+ '/api/terminals',
+ {
+ name: `Synthetic process smoke ${suffix.slice(0, 8)}`,
+ displayText: 'Synthetic process acceptance',
+ locationLabel: 'Synthetic process test',
+ timeZone: 'Europe/Berlin',
+ enforceGeofence: false,
+ isActive: true,
+ allowedAuthMethods: ['PIN'],
+ },
+ hrToken,
+ ),
+ 201,
+ );
+ check(UUID.test(terminal.id), 'Invalid synthetic terminal identity');
+ terminalId = terminal.id;
+ const pairing = expectStatus(
+ await http(
+ 'POST',
+ `/api/terminals/${terminalId}/pairing`,
+ undefined,
+ hrToken,
+ ),
+ 201,
+ );
+ const paired = expectStatus(
+ await http('POST', '/api/terminals/pair', {
+ pairingCode: pairing.pairingCode,
+ deviceName: 'Synthetic process smoke kiosk',
+ }),
+ 201,
+ );
+ deviceToken = paired.deviceToken;
+ check(
+ typeof deviceToken === 'string' && deviceToken.startsWith('octd_'),
+ 'Synthetic device token required',
+ );
+ const challenge = async () =>
+ expectStatus(
+ await direct(
+ api[0],
+ 'POST',
+ '/api/terminals/kiosk/challenge',
+ {},
+ deviceToken,
+ ),
+ 201,
+ ).payload;
+ const identify = (id, payload, candidate) =>
+ direct(
+ id,
+ 'POST',
+ '/api/terminals/kiosk/identify',
+ {
+ method: 'PIN',
+ challengePayload: payload,
+ personalNo,
+ pin: candidate,
+ },
+ deviceToken,
+ );
+ stage = 'cross-process PIN lockout';
+ let payload = await challenge();
+ const wrong = `${pin[0] === '0' ? '1' : '0'}${pin.slice(1)}`;
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ const denied = expectStatus(
+ await identify(api[attempt % 3], payload, wrong),
+ 401,
+ );
+ check(
+ denied.code === 'TERMINAL_AUTH_FAILED',
+ 'PIN errors must remain neutral',
+ );
+ }
+ for (const id of api) {
+ const denied = expectStatus(await identify(id, payload, pin), 401);
+ check(
+ denied.code === 'TERMINAL_AUTH_FAILED',
+ 'Shared PIN lockout must hold on every process',
+ );
+ }
+ stage = 'API restart and persisted PIN lockout';
+ await docker(['restart', api[1]]);
+ let healthy = false;
+ for (let attempt = 0; attempt < 30 && !healthy; attempt += 1) {
+ try {
+ healthy = (await direct(api[1], 'GET', '/api/health')).status === 200;
+ } catch {
+ healthy = false;
+ }
+ if (!healthy) await new Promise((resolve) => setTimeout(resolve, 1_000));
+ }
+ check(healthy, 'Restarted API must recover');
+ await inContainer(api[1], { kind: 'validate' });
+ payload = await challenge();
+ for (const id of api) {
+ const denied = expectStatus(await identify(id, payload, pin), 401);
+ check(
+ denied.code === 'TERMINAL_AUTH_FAILED',
+ 'PIN lockout must survive a real API restart',
+ );
+ }
+ stage = 'cross-process atomic booking';
+ expectStatus(
+ await http(
+ 'POST',
+ `/api/employees/${employeeId}/terminal-credentials/pin/unlock`,
+ {},
+ hrToken,
+ ),
+ 201,
+ );
+ payload = await challenge();
+ const identified = expectStatus(await identify(api[2], payload, pin), 201);
+ check(
+ identified.action === 'clock-in' && typeof identified.grant === 'string',
+ 'A fresh explicit clock-in grant is required',
+ );
+ const bookings = await Promise.all(
+ api.map((id) =>
+ direct(
+ id,
+ 'POST',
+ '/api/terminals/kiosk/book',
+ {
+ grant: identified.grant,
+ action: 'clock-in',
+ },
+ deviceToken,
+ ),
+ ),
+ );
+ const receipts = bookings.map((response) => expectStatus(response, 201));
+ check(
+ receipts.every(
+ (receipt) =>
+ receipt.code === 'TERMINAL_BOOKED' &&
+ receipt.action === 'clock-in' &&
+ JSON.stringify(receipt) === JSON.stringify(receipts[0]),
+ ),
+ 'Parallel processes must return an identical receipt',
+ );
+ const counts = await inContainer(api[0], { kind: 'entries', employeeId });
+ check(
+ counts.total === 1 && counts.open === 1,
+ 'Only one synthetic time entry may be created',
+ );
+ completed = true;
+ } finally {
+ const failedStage = stage;
+ stage = 'synthetic fixture cleanup';
+ if (employeeId && deviceToken && pin) {
+ try {
+ const counts = await inContainer(api[0], {
+ kind: 'entries',
+ employeeId,
+ });
+ if (counts.open) {
+ const challenge = expectStatus(
+ await direct(
+ api[0],
+ 'POST',
+ '/api/terminals/kiosk/challenge',
+ {},
+ deviceToken,
+ ),
+ 201,
+ );
+ const identified = expectStatus(
+ await direct(
+ api[0],
+ 'POST',
+ '/api/terminals/kiosk/identify',
+ {
+ method: 'PIN',
+ challengePayload: challenge.payload,
+ personalNo,
+ pin,
+ },
+ deviceToken,
+ ),
+ 201,
+ );
+ check(
+ identified.action === 'clock-out',
+ 'Cleanup may only close its synthetic running timer',
+ );
+ expectStatus(
+ await direct(
+ api[0],
+ 'POST',
+ '/api/terminals/kiosk/book',
+ {
+ grant: identified.grant,
+ action: 'clock-out',
+ },
+ deviceToken,
+ ),
+ 201,
+ );
+ }
+ } catch {
+ cleanupFailed = true;
+ }
+ }
+ if (terminalId) {
+ try {
+ expectStatus(
+ await http(
+ 'DELETE',
+ `/api/terminals/${terminalId}/permanent`,
+ undefined,
+ hrToken,
+ ),
+ 204,
+ );
+ } catch {
+ cleanupFailed = true;
+ }
+ }
+ if (employeeId) {
+ if (pin) {
+ try {
+ expectStatus(
+ await http(
+ 'DELETE',
+ `/api/employees/${employeeId}/terminal-credentials/pin`,
+ undefined,
+ hrToken,
+ ),
+ 200,
+ );
+ } catch {
+ cleanupFailed = true;
+ }
+ }
+ try {
+ expectStatus(
+ await http(
+ 'DELETE',
+ `/api/employees/${employeeId}`,
+ undefined,
+ hrToken,
+ ),
+ 200,
+ );
+ } catch {
+ cleanupFailed = true;
+ }
+ }
+ // Successful time entries and audit are historical synthetic evidence;
+ // account deactivation and terminal deletion preserve those records.
+ if (!completed) stage = failedStage;
+ }
+ check(
+ completed && !cleanupFailed,
+ 'Smoke and synthetic cleanup must complete',
+ );
+ console.log(
+ `Terminal PIN runtime smoke PASS: 3 OS processes, 5 rejected PINs, 1 real restart, 3 identical receipts, 1 time entry; synthetic cleanup complete (${Math.round(performance.now() - started)} ms).`,
+ );
+}
+
+main().catch(() => {
+ console.error(
+ `Terminal PIN runtime smoke FAILED during ${stage}; request data and credentials are withheld.`,
+ );
+ process.exitCode = 1;
+});
diff --git a/ops/upgrade-smoke.mjs b/ops/upgrade-smoke.mjs
index 40f965b..87abafb 100644
--- a/ops/upgrade-smoke.mjs
+++ b/ops/upgrade-smoke.mjs
@@ -260,6 +260,29 @@ try {
WHERE "finished_at" IS NULL AND "rolled_back_at" IS NULL`,
);
+ const pinSchema = await client.query(
+ `SELECT to_regclass('"TerminalCredential"') AS table_name`,
+ );
+ if (pinSchema.rows[0].table_name) {
+ const terminal = await client.query(
+ 'SELECT "allowedAuthMethods", "authConfigVersion" FROM "Terminal" WHERE "id"=$1',
+ [terminalId],
+ );
+ assert.deepEqual(
+ terminal.rows,
+ [{ allowedAuthMethods: ['QR'], authConfigVersion: 0 }],
+ 'PIN upgrade must preserve QR-only terminals',
+ );
+ const credentials = await client.query(
+ 'SELECT COUNT(*)::int AS count FROM "TerminalCredential"',
+ );
+ assert.equal(
+ credentials.rows[0].count,
+ 0,
+ 'Upgrade must not invent employee PINs',
+ );
+ }
+
if (failedMigrations.rows[0].count !== 0) {
throw new Error(
'The upgraded database contains an unfinished Prisma migration.',
diff --git a/package.json b/package.json
index f8cb2fd..a84e60c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openclockwork",
- "version": "2.0.1",
+ "version": "2.1.0",
"license": "Apache-2.0",
"private": true,
"description": "OpenClockwork — self-hosted time and attendance with configurable work rules for organisations worldwide",
@@ -124,6 +124,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.100.9",
+ "argon2": "0.44.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc30281..24b2de9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.100.9
version: 5.100.9(react@19.2.6)
+ argon2:
+ specifier: 0.44.0
+ version: 0.44.0
axios:
specifier: ^1.18.1
version: 1.18.1
@@ -390,6 +393,9 @@ importers:
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
+ argon2:
+ specifier: 0.44.0
+ version: 0.44.0
bcrypt:
specifier: '*'
version: 6.0.0
@@ -1295,6 +1301,9 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+ '@epic-web/invariant@1.0.0':
+ resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
+
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -2541,6 +2550,10 @@ packages:
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
engines: {node: '>=20.0.0'}
+ '@phc/format@1.0.0':
+ resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
+ engines: {node: '>=10'}
+
'@phenomnomnominal/tsquery@6.2.0':
resolution: {integrity: sha512-Vo9nkhfZxDB/sBiqIY3pjDC4mOSyure+AFlEW5hcy/tRE82MqCXjRN4InnVNMldinRt0dLYqg4HAU2XPq5e1LA==}
peerDependencies:
@@ -4404,6 +4417,10 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+ argon2@0.44.0:
+ resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==}
+ engines: {node: '>=16.17.0'}
+
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
@@ -5068,6 +5085,11 @@ packages:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
+ cross-env@10.1.0:
+ resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -11042,6 +11064,8 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@epic-web/invariant@1.0.0': {}
+
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -12748,6 +12772,8 @@ snapshots:
tslib: 2.8.1
tsyringe: 4.10.0
+ '@phc/format@1.0.0': {}
+
'@phenomnomnominal/tsquery@6.2.0(typescript@5.9.3)':
dependencies:
'@types/esquery': 1.5.4
@@ -14643,6 +14669,13 @@ snapshots:
arg@5.0.2: {}
+ argon2@0.44.0:
+ dependencies:
+ '@phc/format': 1.0.0
+ cross-env: 10.1.0
+ node-addon-api: 8.7.0
+ node-gyp-build: 4.8.4
+
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
@@ -15376,6 +15409,11 @@ snapshots:
dependencies:
luxon: 3.7.2
+ cross-env@10.1.0:
+ dependencies:
+ '@epic-web/invariant': 1.0.0
+ cross-spawn: 7.0.6
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
diff --git a/prisma/migrations/20260930090000_terminal_pin_credentials/migration.sql b/prisma/migrations/20260930090000_terminal_pin_credentials/migration.sql
new file mode 100644
index 0000000..47a41aa
--- /dev/null
+++ b/prisma/migrations/20260930090000_terminal_pin_credentials/migration.sql
@@ -0,0 +1,48 @@
+-- Increment A: QR/PIN only. Existing installations remain QR-only.
+ALTER TABLE "Terminal" ADD COLUMN "allowedAuthMethods" TEXT[] NOT NULL DEFAULT ARRAY['QR']::TEXT[],
+ ADD COLUMN "authConfigVersion" INTEGER NOT NULL DEFAULT 0,
+ ADD CONSTRAINT "Terminal_auth_methods_chk" CHECK ("allowedAuthMethods" <@ ARRAY['QR','PIN']::TEXT[] AND (NOT "isActive" OR cardinality("allowedAuthMethods") > 0)),
+ ADD CONSTRAINT "Terminal_auth_version_chk" CHECK ("authConfigVersion" >= 0);
+ALTER TABLE "TerminalChallenge" ADD COLUMN "purpose" VARCHAR(20) NOT NULL DEFAULT 'DisplayQr',
+ ADD COLUMN "authConfigVersion" INTEGER NOT NULL DEFAULT 0,
+ ADD CONSTRAINT "TerminalChallenge_purpose_chk" CHECK ("purpose" IN ('DisplayQr','KioskIdentify'));
+CREATE TABLE "TerminalCredential" (
+ "id" UUID PRIMARY KEY, "employeeId" UUID NOT NULL UNIQUE REFERENCES "Employee"("id") ON DELETE RESTRICT,
+ "method" VARCHAR(8) NOT NULL DEFAULT 'PIN' CHECK ("method" = 'PIN'),
+ "pinHash" TEXT NOT NULL, "pepperVersion" VARCHAR(40) NOT NULL,
+ "version" INTEGER NOT NULL DEFAULT 1 CHECK ("version" > 0), "revokedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL
+);
+CREATE TABLE "TerminalAuthAttemptState" (
+ "key" VARCHAR(160) PRIMARY KEY, "counter" INTEGER NOT NULL DEFAULT 0 CHECK ("counter" >= 0),
+ "windowStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "lockedUntil" TIMESTAMP(3), "expiresAt" TIMESTAMP(3) NOT NULL
+);
+CREATE INDEX "TerminalAuthAttemptState_expiresAt_idx" ON "TerminalAuthAttemptState"("expiresAt");
+CREATE TABLE "TerminalBookingGrant" (
+ "id" UUID PRIMARY KEY, "tokenHash" CHAR(64) NOT NULL UNIQUE,
+ "employeeId" UUID NOT NULL, "credentialId" UUID NOT NULL, "credentialVersion" INTEGER NOT NULL,
+ "terminalId" UUID NOT NULL, "deviceId" UUID NOT NULL, "challengeId" UUID NOT NULL,
+ "authConfigVersion" INTEGER NOT NULL, "expiresAt" TIMESTAMP(3) NOT NULL, "consumedAt" TIMESTAMP(3),
+ "action" VARCHAR(9), "timeEntryId" UUID, "bookedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "TerminalBookingGrant_consumption_chk" CHECK (
+ ("consumedAt" IS NULL AND "action" IS NULL AND "timeEntryId" IS NULL AND "bookedAt" IS NULL)
+ OR ("consumedAt" IS NOT NULL AND "action" IN ('clock-in','clock-out') AND "timeEntryId" IS NOT NULL AND "bookedAt" IS NOT NULL))
+);
+CREATE INDEX "TerminalBookingGrant_expiresAt_idx" ON "TerminalBookingGrant"("expiresAt");
+CREATE INDEX "TerminalBookingGrant_deviceId_createdAt_idx" ON "TerminalBookingGrant"("deviceId","createdAt");
+CREATE TABLE "TerminalAuditEvent" (
+ "sequence" BIGINT PRIMARY KEY CHECK ("sequence" > 0), "id" UUID NOT NULL UNIQUE, "occurredAt" TIMESTAMP(3) NOT NULL,
+ "event" VARCHAR(60) NOT NULL, "result" VARCHAR(60) NOT NULL, "method" VARCHAR(8), "action" VARCHAR(9),
+ "employeeId" UUID, "actorId" UUID, "terminalId" UUID, "deviceId" UUID, "terminalName" VARCHAR(120),
+ "locationLabel" VARCHAR(200), "timeEntryId" UUID, "canonicalPayload" TEXT NOT NULL,
+ "previousHash" CHAR(64) NOT NULL, "hash" CHAR(64) NOT NULL, "keyVersion" VARCHAR(40) NOT NULL
+);
+CREATE INDEX "TerminalAuditEvent_terminalId_occurredAt_idx" ON "TerminalAuditEvent"("terminalId","occurredAt");
+CREATE INDEX "TerminalAuditEvent_employeeId_occurredAt_idx" ON "TerminalAuditEvent"("employeeId","occurredAt");
+CREATE INDEX "TerminalAuditEvent_occurredAt_idx" ON "TerminalAuditEvent"("occurredAt");
+-- This protects against accidental mutations, not database owners. Operators must
+-- also use a separate runtime role with SELECT/INSERT only and external anchors.
+CREATE FUNCTION terminal_audit_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'Terminal audit events are append-only'; END $$;
+CREATE TRIGGER "TerminalAuditEvent_append_only" BEFORE UPDATE OR DELETE ON "TerminalAuditEvent"
+FOR EACH ROW EXECUTE FUNCTION terminal_audit_append_only();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d426362..dbf4114 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -147,6 +147,7 @@ model Employee {
absences Absence[]
projectAssignments ProjectAssignment[]
terminalChallengeRedemptions TerminalChallengeRedemption[]
+ terminalCredential TerminalCredential?
ownedInstallation InstallationSettings?
soloPolicies SoloPolicy[]
personalDays PersonalDay[]
@@ -274,6 +275,8 @@ model Terminal {
displayText String @db.VarChar(500)
locationLabel String @db.VarChar(200)
logoUrl String?
+ allowedAuthMethods String[] @default(["QR"])
+ authConfigVersion Int @default(0)
enforceGeofence Boolean @default(true)
latitude Decimal? @db.Decimal(9, 6)
longitude Decimal? @db.Decimal(9, 6)
@@ -320,6 +323,8 @@ model TerminalChallenge {
deviceId String @db.Uuid
device TerminalDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
tokenHash String @unique @db.Char(64)
+ purpose String @default("DisplayQr") @db.VarChar(20)
+ authConfigVersion Int @default(0)
rootDate DateTime @db.Date
expiresAt DateTime
redemptions TerminalChallengeRedemption[]
@@ -619,3 +624,75 @@ model InstallationEvent {
@@index([occurredAt])
}
+
+/// Terminal PIN credentials are independent from employee account passwords.
+model TerminalCredential {
+ id String @id @default(uuid()) @db.Uuid
+ employeeId String @unique @db.Uuid
+ employee Employee @relation(fields: [employeeId], references: [id], onDelete: Restrict)
+ method String @default("PIN") @db.VarChar(8)
+ pinHash String
+ pepperVersion String @db.VarChar(40)
+ version Int @default(1)
+ revokedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+/// Shared fixed windows and PIN lock state. Keys never contain personal numbers.
+model TerminalAuthAttemptState {
+ key String @id @db.VarChar(160)
+ counter Int @default(0)
+ windowStartedAt DateTime @default(now())
+ lockedUntil DateTime?
+ expiresAt DateTime
+ @@index([expiresAt])
+}
+
+/// References are immutable snapshots, deliberately independent of entity deletion.
+/// Every use revalidates the referenced employee/device/credential/challenge.
+model TerminalBookingGrant {
+ id String @id @default(uuid()) @db.Uuid
+ tokenHash String @unique @db.Char(64)
+ employeeId String @db.Uuid
+ credentialId String @db.Uuid
+ credentialVersion Int
+ terminalId String @db.Uuid
+ deviceId String @db.Uuid
+ challengeId String @db.Uuid
+ authConfigVersion Int
+ expiresAt DateTime
+ consumedAt DateTime?
+ action String? @db.VarChar(9)
+ timeEntryId String? @db.Uuid
+ bookedAt DateTime?
+ createdAt DateTime @default(now())
+ @@index([expiresAt])
+ @@index([deviceId, createdAt])
+}
+
+/// Append-only authenticated chain. All IDs and labels are historical snapshots.
+/// No cascading relations; runtime role receives no UPDATE/DELETE/TRUNCATE.
+model TerminalAuditEvent {
+ sequence BigInt @id
+ id String @unique @db.Uuid
+ occurredAt DateTime
+ event String @db.VarChar(60)
+ result String @db.VarChar(60)
+ method String? @db.VarChar(8)
+ action String? @db.VarChar(9)
+ employeeId String? @db.Uuid
+ actorId String? @db.Uuid
+ terminalId String? @db.Uuid
+ deviceId String? @db.Uuid
+ terminalName String? @db.VarChar(120)
+ locationLabel String? @db.VarChar(200)
+ timeEntryId String? @db.Uuid
+ canonicalPayload String
+ previousHash String @db.Char(64)
+ hash String @db.Char(64)
+ keyVersion String @db.VarChar(40)
+ @@index([terminalId, occurredAt])
+ @@index([employeeId, occurredAt])
+ @@index([occurredAt])
+}