From 0d95b642cd639309c4aa8cc3703293c47cabc9fc Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Mon, 11 Aug 2025 01:48:15 +0200 Subject: [PATCH] add auth and nicer styling --- package-lock.json | 40 +++++++ package.json | 1 + src/app/api/login/route.ts | 26 +++++ src/app/api/logout/route.ts | 17 +++ src/app/api/lookup/route.ts | 15 +++ src/app/api/session/route.ts | 13 +++ src/app/globals.css | 13 ++- src/app/page.tsx | 83 +++++++++++-- src/components/LoginForm.tsx | 77 ++++++++++++ src/components/Scanner.tsx | 221 ++++++++++++++++++----------------- src/lib/auth.ts | 21 ++++ 11 files changed, 412 insertions(+), 115 deletions(-) create mode 100644 src/app/api/login/route.ts create mode 100644 src/app/api/logout/route.ts create mode 100644 src/app/api/session/route.ts create mode 100644 src/components/LoginForm.tsx create mode 100644 src/lib/auth.ts diff --git a/package-lock.json b/package-lock.json index 5bc67f6..2c95816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.84.2", + "iron-session": "^8.0.4", "next": "15.4.6", "qr-scanner": "^1.4.2", "react": "^19.1.1", @@ -2426,6 +2427,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3713,6 +3723,30 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6024,6 +6058,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", diff --git a/package.json b/package.json index 08a24dd..26ab8d7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.84.2", + "iron-session": "^8.0.4", "next": "15.4.6", "qr-scanner": "^1.4.2", "react": "^19.1.1", diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts new file mode 100644 index 0000000..e308ba0 --- /dev/null +++ b/src/app/api/login/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; + +export async function POST(req: NextRequest) { + try { + const { password } = await req.json(); + + if (password !== process.env.PASSWORD) { + return NextResponse.json( + { error: "Invalid password" }, + { status: 401 } + ); + } + + const session = await getSession(); + session.authenticated = true; + await session.save(); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: "Authentication failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts new file mode 100644 index 0000000..7255af7 --- /dev/null +++ b/src/app/api/logout/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; + +export async function POST(req: NextRequest) { + try { + const session = await getSession(); + session.destroy(); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: "Logout failed" }, + { status: 500 } + ); + } +} +} diff --git a/src/app/api/lookup/route.ts b/src/app/api/lookup/route.ts index e930067..0d0467d 100644 --- a/src/app/api/lookup/route.ts +++ b/src/app/api/lookup/route.ts @@ -1,4 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; +import { getIronSession } from "iron-session"; +import { sessionOptions, SessionData } from "@/lib/auth"; type MerchantUserResponse = { firstName: string; @@ -59,6 +61,19 @@ async function fetchOidcToken(): Promise { export async function POST(req: NextRequest) { try { + // require valid session + const session = await getIronSession( + req, + NextResponse.next(), + sessionOptions + ); + if (!session.authenticated) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 401 } + ); + } + const token = await fetchOidcToken(); const apiBase = process.env.ID_CARD_API_BASE_URL; diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts new file mode 100644 index 0000000..c03924e --- /dev/null +++ b/src/app/api/session/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; + +export async function GET() { + try { + const session = await getSession(); + const isAuthenticated = session.authenticated || false; + + return NextResponse.json({ authenticated: isAuthenticated }); + } catch (error) { + return NextResponse.json({ authenticated: false }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..ebf628b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,23 +1,30 @@ @import "tailwindcss"; -:root { +/* :root { --background: #ffffff; --foreground: #171717; +} */ + +:root { + --background: #161516; + --foreground: #ffffff; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-golden-orange: #ff6915; + --color-neuf-black: #161516; --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { +/* @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; } -} +} */ body { background: var(--background); diff --git a/src/app/page.tsx b/src/app/page.tsx index 83b9604..c482115 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import Image from "next/image"; import Scanner from "@/components/Scanner"; +import LoginForm from "@/components/LoginForm"; import { useMutation, QueryClient, @@ -18,25 +19,40 @@ type SessionResponse = { phoneNumber: string; }; -function SessionInfo({ session }: { session: SessionResponse }) { +function SessionInfo({ + session, + onClear, +}: { + session: SessionResponse; + onClear: () => void; +}) { return ( -
+
+
+ +
Document {(session.firstName || session.lastName) && ( -
+
{[session.firstName, session.lastName].filter(Boolean).join(" ")}
)} {typeof session.age === "number" && ( -
+
{session.age} år
)} @@ -57,8 +73,17 @@ async function lookupSession(sessionId: string) { function HomeInner() { const [session, setSession] = useState(null); const [scannedCodes, setScannedCodes] = useState>(new Set()); + const [isAuthenticated, setIsAuthenticated] = useState(null); const pendingCodeRef = useRef(null); + // check for valid session on mount + useEffect(() => { + fetch("/api/session") + .then((res) => res.json()) + .then((data) => setIsAuthenticated(data.authenticated)) + .catch(() => setIsAuthenticated(false)); + }, []); + const mutation = useMutation({ mutationFn: lookupSession, onSuccess: (data, variables) => { @@ -91,10 +116,52 @@ function HomeInner() { mutation.mutate(decodedText); }; + const handleClearSession = () => { + setSession(null); + setScannedCodes(new Set()); + }; + + // const handleLogout = async () => { + // try { + // await fetch("/api/logout", { method: "POST" }); + // setIsAuthenticated(false); + // setSession(null); + // setScannedCodes(new Set()); + // } catch { + // alert("Noe gikk galt ved utlogging"); + // } + // }; + + if (isAuthenticated === null) { + return ( +
+ Laster... +
+ ); + } + + if (!isAuthenticated) { + return ( +
+ setIsAuthenticated(true)} /> +
+ ); + } + return ( -
+
+ {/*
+ +
*/} - {session && } + {session && ( + + )}
); } diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 0000000..9e56124 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; + +interface LoginFormProps { + onLogin: () => void; +} + +export default function LoginForm({ onLogin }: LoginFormProps) { + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + + try { + const res = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + + if (res.ok) { + onLogin(); + } else { + setError("Ugyldig passord"); + } + } catch { + setError("Noe gikk galt :("); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

+ Oldeneuf? +

+
+
+ + { + setPassword(e.target.value); + setError(""); + }} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + required + disabled={isLoading} + /> + {error && ( +

{error}

+ )} +
+ +
+
+ ); +} diff --git a/src/components/Scanner.tsx b/src/components/Scanner.tsx index 79d4b43..7d3a9a2 100644 --- a/src/components/Scanner.tsx +++ b/src/components/Scanner.tsx @@ -24,6 +24,8 @@ export default function Scanner({ onScan }: ScannerProps) { const [error, setError] = useState(""); const [highlightStyle, setHighlightStyle] = useState("default-style"); const [showScanRegion, setShowScanRegion] = useState(false); + const [hasStartedOnce, setHasStartedOnce] = useState(false); + const showControls = false; const handleScanResult = (result: QrScanner.ScanResult) => { onScan(result.data); @@ -53,6 +55,7 @@ export default function Scanner({ onScan }: ScannerProps) { await scannerRef.current.start(); setIsScanning(true); + setHasStartedOnce(true); await updateFlashAvailability(); // expects both light and dark QR codes @@ -70,6 +73,7 @@ export default function Scanner({ onScan }: ScannerProps) { try { await scannerRef.current.start(); setIsScanning(true); + setHasStartedOnce(true); } catch (err) { setError(`Failed to start scanner: ${err}`); } @@ -137,124 +141,133 @@ export default function Scanner({ onScan }: ScannerProps) {
-
+
- {/* Video Container */} + {!hasStartedOnce && ( +

+ Trykk på området over for å skru av og på kameraet. +

+ )} {/* Controls */} -
- {/* Start/Stop Toggle Button */} -
- -
- {/* Flash Toggle */} - {/* {hasFlash && ( - - )} */} -
- -
- {/* Settings */} -
- {/* Camera Selection */} - {cameras.length > 0 && ( -
- - + {isScanning ? "Stopp" : "Start"} +
- )} - - {/* Highlight Style */} -
- - + {/* Flash Toggle */} + {hasFlash && ( + + )}
- {/* Show Scan Region */} -
- setShowScanRegion(e.target.checked)} - className="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" - /> - -
-
+
+ {/* Settings */} +
+ {/* Camera Selection */} + {cameras.length > 0 && ( +
+ + +
+ )} - {/* Status Information */} -
-
- Device has camera:{" "} - {hasCamera !== null - ? hasCamera - ? "Yes" - : "No" - : "Checking..."} -
-
- Camera has flash:{" "} - {hasFlash ? "Yes" : "No"} -
- {error && ( -
- Error: {error} + {/* Highlight Style */} +
+ + +
+ + {/* Show Scan Region */} +
+ setShowScanRegion(e.target.checked)} + className="mr-2 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
- )} -
-
+ + {/* Status Information */} +
+
+ Device has camera:{" "} + {hasCamera !== null + ? hasCamera + ? "Yes" + : "No" + : "Checking..."} +
+
+ Camera has flash:{" "} + {hasFlash ? "Yes" : "No"} +
+ {error && ( +
+ Error: {error} +
+ )} +
+
+ + )}