diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
index d856e80..7793af3 100644
--- a/app/Http/Controllers/AuthController.php
+++ b/app/Http/Controllers/AuthController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Events\QuickCodeLogin;
+use App\Http\Requests\NfcLoginRequest;
use App\Http\Requests\QuickCodeRequest;
use App\Models\Kiosk;
use App\Models\QuickCode;
@@ -112,6 +113,45 @@ public function postQuickcode(QuickCodeRequest $request): JsonResponse|RedirectR
: redirect()->route('volunteer.index');
}
+ /**
+ * Logs the user in via an NFC badge tap, relayed from a workstation-local ConcatNFCValidator instance. Only usable
+ * from sessions already authorized as a Kiosk - trusting a client-submitted attendeeId for login is only
+ * reasonable in that physically-trusted context.
+ */
+ public function postNfc(NfcLoginRequest $request): JsonResponse|RedirectResponse {
+ if (!Kiosk::isSessionAuthorized(true)) {
+ $error = 'NFC login is only available on authorized kiosks.';
+ return $request->expectsJson()
+ ? response()->json(['error' => $error], 403)
+ : redirect()->back()->withErrors(['attendeeId' => $error]);
+ }
+
+ // Prevent too many rapid failed attempts
+ $rateLimitKey = "nfc:{$request->ip()}";
+ if (RateLimiter::tooManyAttempts($rateLimitKey, $perMinute = 5)) {
+ $error = 'Too many failed NFC login attempts have been made from this location. Try again in a minute.';
+ return $request->expectsJson()
+ ? response()->json(['error' => $error], 429)
+ : redirect()->back()->withErrors(['attendeeId' => $error]);
+ }
+
+ $user = User::whereBadgeId($request->attendeeId)->first();
+
+ if (!$user) {
+ RateLimiter::hit($rateLimitKey);
+ $error = 'No Tracker account is linked to this badge.';
+ return $request->expectsJson()
+ ? response()->json(['errors' => $error], 401)
+ : redirect()->back()->withErrors(['attendeeId' => $error]);
+ }
+
+ Auth::login($user);
+
+ return $request->expectsJson()
+ ? response()->json(null, 205)
+ : redirect()->route('volunteer.index');
+ }
+
/**
* Display the banned notice
*/
diff --git a/app/Http/Requests/NfcLoginRequest.php b/app/Http/Requests/NfcLoginRequest.php
new file mode 100644
index 0000000..43374c3
--- /dev/null
+++ b/app/Http/Requests/NfcLoginRequest.php
@@ -0,0 +1,25 @@
+
+ */
+ public function rules(): array {
+ return [
+ 'attendeeId' => 'required|integer',
+ ];
+ }
+}
diff --git a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
index 27f80c3..4f70f8c 100644
--- a/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
+++ b/resources/js/Components/AttendeeLog/AttendeeCreatePanel.vue
@@ -14,6 +14,7 @@
Return ⏎
key press after the badge number.
+ NFC badge taps on a workstation-local reader are submitted the same way.
+
+
+ Badge Tap-In
+
+
+
+
+
+ {{ nfcStatusText }}
+
+
diff --git a/resources/js/lib/nfcLogin.ts b/resources/js/lib/nfcLogin.ts
new file mode 100644
index 0000000..5d6fb4e
--- /dev/null
+++ b/resources/js/lib/nfcLogin.ts
@@ -0,0 +1,27 @@
+import { useInertiaRequest } from './request';
+import { useNfcTap, type NfcTap } from './nfcTap';
+
+/**
+ * Logs in the Tracker user matching a badge tapped on a workstation-local ConcatNFCValidator instance. Only meaningful on
+ * sessions authorized as a Kiosk (callers should gate on that separately - this composable has no opinion on it
+ * beyond that the backend login endpoint enforces it too).
+ */
+export function useNfcLogin() {
+ const inertiaRequest = useInertiaRequest();
+
+ return useNfcTap(
+ (tap: NfcTap) =>
+ new Promise((resolve, reject) => {
+ inertiaRequest.post(
+ 'auth.nfc.post',
+ { attendeeId: tap.attendeeId },
+ {
+ onSuccess: () => resolve(),
+ onError(errors) {
+ reject(new Error(errors.attendeeId ?? Object.values(errors)[0] ?? 'NFC login failed.'));
+ },
+ },
+ );
+ }),
+ );
+}
diff --git a/resources/js/lib/nfcTap.ts b/resources/js/lib/nfcTap.ts
new file mode 100644
index 0000000..1cf35ae
--- /dev/null
+++ b/resources/js/lib/nfcTap.ts
@@ -0,0 +1,146 @@
+import { ref } from 'vue';
+
+const VALIDATOR_BASE_URL = 'http://localhost:7071';
+
+const HEALTH_POLL_MS = 5000;
+const STATUS_POLL_MS = 1500;
+
+export type NfcTapStage = 'idle' | 'searching' | 'waiting' | 'processing' | 'error';
+
+export interface NfcTap {
+ uid: string;
+ attendeeId: number;
+ conventionId?: number;
+ scannedAt?: string;
+}
+
+interface HealthResponse {
+ alive: boolean;
+ readerReady: boolean;
+}
+
+interface StatusResponse {
+ valid: boolean;
+ uid?: string;
+ attendeeId?: number;
+ conventionId?: number;
+ scannedAt?: string;
+}
+
+/**
+ * Polls a workstation-local ConcatNFCValidator instance for a validated badge tap and invokes onTap for
+ * each one. onTap is awaited - the stage is 'processing' while it's pending, and if it throws, the stage becomes
+ * 'error' with the message exposed via `error` until the next tap comes in. The scan is acknowledged (so the
+ * validator clears it and accepts the next one) regardless of whether onTap succeeds.
+ */
+export function useNfcTap(onTap: (tap: NfcTap) => void | Promise) {
+ const stage = ref('idle');
+ const error = ref(null);
+
+ let generation = 0;
+
+ /**
+ * Begins polling the validator. Safe to call again to restart after stop().
+ */
+ function start() {
+ if (stage.value !== 'idle' && stage.value !== 'error') return;
+ error.value = null;
+ stage.value = 'searching';
+ const myGeneration = ++generation;
+ void pollHealth(myGeneration);
+ }
+
+ /**
+ * Stops polling. Any in-flight request's result will be ignored once it resolves.
+ */
+ function stop() {
+ generation++;
+ stage.value = 'idle';
+ }
+
+ /**
+ * Polls /health until the validator answers, then switches to polling /status
+ */
+ async function pollHealth(myGeneration: number) {
+ if (myGeneration !== generation) return;
+
+ try {
+ const res = await fetch(`${VALIDATOR_BASE_URL}/health`, { signal: AbortSignal.timeout(3000) });
+ if (!res.ok) throw new Error(`Unexpected status ${res.status}`);
+ await (res.json() as Promise);
+
+ if (myGeneration !== generation) return;
+ stage.value = 'waiting';
+ void pollStatus(myGeneration);
+ return;
+ } catch {
+ // Validator not reachable - fall through to marking it as searching and retrying below
+ }
+
+ if (myGeneration !== generation) return;
+ stage.value = 'searching';
+ setTimeout(() => void pollHealth(myGeneration), HEALTH_POLL_MS);
+ }
+
+ /**
+ * Polls /status for a validated badge tap. Falls back to pollHealth if the validator stops responding.
+ */
+ async function pollStatus(myGeneration: number) {
+ if (myGeneration !== generation) return;
+
+ let status: StatusResponse;
+ try {
+ const res = await fetch(`${VALIDATOR_BASE_URL}/status`, { signal: AbortSignal.timeout(3000) });
+ if (!res.ok) throw new Error(`Unexpected status ${res.status}`);
+ status = await res.json();
+ } catch {
+ if (myGeneration !== generation) return;
+ // A single missed poll doesn't necessarily mean the validator is down - recheck health right away
+ // instead of dropping to 'searching' (and hiding the tap-in UI) over one transient blip.
+ void pollHealth(myGeneration);
+ return;
+ }
+
+ if (myGeneration !== generation) return;
+
+ if (!status.valid || status.attendeeId == null || !status.uid) {
+ setTimeout(() => void pollStatus(myGeneration), STATUS_POLL_MS);
+ return;
+ }
+
+ const tap: NfcTap = {
+ uid: status.uid,
+ attendeeId: status.attendeeId,
+ conventionId: status.conventionId,
+ scannedAt: status.scannedAt,
+ };
+
+ error.value = null;
+ stage.value = 'processing';
+
+ // Acknowledge the scan so the validator can clear it and accept the next one, regardless of whether
+ // onTap below succeeds.
+ fetch(`${VALIDATOR_BASE_URL}/ack`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ uid: tap.uid }),
+ }).catch(() => {
+ // Non-fatal - the validator will expire the pending scan on its own after a while
+ });
+
+ try {
+ await onTap(tap);
+ if (myGeneration !== generation) return;
+ stage.value = 'waiting';
+ } catch (err) {
+ if (myGeneration !== generation) return;
+ error.value = err instanceof Error ? err.message : 'Failed to handle badge tap.';
+ stage.value = 'error';
+ }
+
+ if (myGeneration !== generation) return;
+ setTimeout(() => void pollStatus(myGeneration), STATUS_POLL_MS);
+ }
+
+ return { stage, error, start, stop };
+}
diff --git a/routes/web.php b/routes/web.php
index b41132c..03670f4 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -10,6 +10,7 @@
Route::get('/auth/redirect', 'getRedirect')->name('auth.redirect');
Route::get('/auth/callback', 'getCallback')->name('auth.callback');
Route::post('/auth/quickcode', 'postQuickcode')->name('auth.quickcode.post');
+ Route::post('/auth/nfc', 'postNfc')->name('auth.nfc.post');
Route::get('/disabled/account', 'getBanned')->name('auth.banned');
});