add auth and nicer styling

This commit is contained in:
2025-08-11 01:48:15 +02:00
parent bb3ad1e4b9
commit 0d95b642cd
11 changed files with 412 additions and 115 deletions

40
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}
}

View File

@@ -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<string> {
export async function POST(req: NextRequest) {
try {
// require valid session
const session = await getIronSession<SessionData>(
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;

View File

@@ -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 });
}
}

View File

@@ -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);

View File

@@ -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 (
<div className="max-w-md mx-auto mt-6 p-6 rounded-xl shadow-lg border border-gray-200 bg-gradient-to-br from-gray-50 to-gray-200 space-y-4">
<div className="max-w-md mx-auto mt-2 p-2 bg-neuf-black space-y-4">
<div className="flex justify-center">
<button
onClick={onClear}
className="w-8 h-8 bg-red-600 text-white rounded-full hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center justify-center font-bold text-lg transition-colors duration-200"
aria-label="Clear session"
>
×
</button>
</div>
<div className="flex flex-col items-center">
<Image
src={`data:image/jpeg;base64,${session.documentPhoto}`}
alt="Document"
width={200}
height={261}
className="w-40 h-50 object-cover rounded-lg mb-4 border border-gray-300 shadow"
className="w-40 h-50 object-cover rounded-lg mb-4 border border-black-300 shadow"
unoptimized
/>
{(session.firstName || session.lastName) && (
<div className="text-xl font-bold text-gray-800">
<div className="text-xl font-bold text-white">
{[session.firstName, session.lastName].filter(Boolean).join(" ")}
</div>
)}
{typeof session.age === "number" && (
<div className="text-3xl font-extrabold text-blue-700 mt-2">
<div className="text-3xl font-extrabold text-white mt-2">
{session.age} år
</div>
)}
@@ -57,8 +73,17 @@ async function lookupSession(sessionId: string) {
function HomeInner() {
const [session, setSession] = useState<SessionResponse | null>(null);
const [scannedCodes, setScannedCodes] = useState<Set<string>>(new Set());
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const pendingCodeRef = useRef<string | null>(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 (
<div>
<div className="text-center mt-20 text-white bg-neuf-black min-h-screen">
Laster...
</div>
);
}
if (!isAuthenticated) {
return (
<div className="bg-neuf-black min-h-screen">
<LoginForm onLogin={() => setIsAuthenticated(true)} />
</div>
);
}
return (
<div className="bg-neuf-black min-h-screen">
{/* <div className="flex justify-end p-4">
<button
onClick={handleLogout}
className="bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Logg ut
</button>
</div> */}
<Scanner onScan={handleScan} />
{session && <SessionInfo session={session} />}
{session && (
<SessionInfo session={session} onClear={handleClearSession} />
)}
</div>
);
}

View File

@@ -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 (
<div className="max-w-md mx-auto mt-20 p-6 bg-neuf-black">
<h1 className="text-2xl font-bold text-center mb-6 text-white">
Oldeneuf?
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-300 mb-1"
>
Passord
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => {
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 && (
<p className="text-red-500 text-sm mt-1">{error}</p>
)}
</div>
<button
type="submit"
className="w-full bg-golden-orange text-neuf-black py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? "Logger inn..." : "Logg inn"}
</button>
</form>
</div>
);
}

View File

@@ -24,6 +24,8 @@ export default function Scanner({ onScan }: ScannerProps) {
const [error, setError] = useState<string>("");
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,45 +141,52 @@ export default function Scanner({ onScan }: ScannerProps) {
<div className={getVideoContainerClass()}>
<video
ref={videoRef}
className="w-full h-auto rounded-lg border-2 border-gray-300"
className="w-full h-auto rounded-lg border-2 border-gray-300 cursor-pointer"
playsInline
muted
onClick={isScanning ? stopScanner : startScanner}
/>
</div>
<div className="p-4 space-y-4">
<div className="p-2 space-y-2">
<div className="text-center">
{/* Video Container */}
{!hasStartedOnce && (
<p className="text-white-600 mb-4">
Trykk området over for å skru av og kameraet.
</p>
)}
{/* Controls */}
<div className="mt-4 space-y-4">
{showControls && (
<>
<div className="mt-0 space-y-2">
{/* Start/Stop Toggle Button */}
<div className="flex gap-2 justify-center">
<button
onClick={isScanning ? stopScanner : startScanner}
className={`px-4 py-2 text-white rounded-lg transition-colors ${
className={`px-2 py-2 text-white rounded-lg transition-colors ${
isScanning
? "bg-red-500 hover:bg-red-600"
: "bg-green-500 hover:bg-green-600"
}`}
>
{isScanning ? "Stop Scanner" : "Start Scanner"}
{isScanning ? "Stopp" : "Start"}
</button>
</div>
{/* Flash Toggle */}
{/* {hasFlash && (
{hasFlash && (
<button
onClick={toggleFlash}
className="px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors"
>
📸 Flash: {isFlashOn ? "on" : "off"}
</button>
)} */}
)}
</div>
<div style={{ display: "none" }}>
<div>
{/* Settings */}
<div className="mt-6 space-y-4 text-left">
<div className="mt-2 space-y-2 text-left">
{/* Camera Selection */}
{cameras.length > 0 && (
<div>
@@ -255,6 +266,8 @@ export default function Scanner({ onScan }: ScannerProps) {
)}
</div>
</div>
</>
)}
</div>
</div>
<style jsx>{`

21
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import { SessionOptions } from "iron-session";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
export interface SessionData {
authenticated?: boolean;
}
export const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET!,
cookieName: "oldeneuf-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession() {
return await getIronSession<SessionData>(await cookies(), sessionOptions);
}