add auth and nicer styling
This commit is contained in:
40
package-lock.json
generated
40
package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
26
src/app/api/login/route.ts
Normal file
26
src/app/api/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
17
src/app/api/logout/route.ts
Normal file
17
src/app/api/logout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
13
src/app/api/session/route.ts
Normal file
13
src/app/api/session/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
77
src/components/LoginForm.tsx
Normal file
77
src/components/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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 på området over for å skru av og på 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
21
src/lib/auth.ts
Normal 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);
|
||||
}
|
Reference in New Issue
Block a user