diff --git a/app/api/verify-access/route.ts b/app/api/verify-access/route.ts new file mode 100644 index 00000000..ccb7d108 --- /dev/null +++ b/app/api/verify-access/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +/** + * GET /api/verify-access + * + * Returns whether password protection is enabled at runtime. + * This is called by the PasswordGate client component on mount + * to determine if it should show the password prompt. + */ +export async function GET() { + const accessKey = process.env.ACCESS_KEY; + const enabled = !!accessKey; + return NextResponse.json({ enabled }); +} + +/** + * POST /api/verify-access + * + * Validates a submitted key against the ACCESS_KEY environment variable. + * Returns { valid: true } if matched, { valid: false } otherwise. + * + * If ACCESS_KEY is not set, all keys are rejected (locked by default). + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const submitted = body?.key; + + if (typeof submitted !== "string" || !submitted) { + return NextResponse.json({ valid: false }, { status: 400 }); + } + + const accessKey = process.env.ACCESS_KEY; + + // If no ACCESS_KEY is configured, reject everything + if (!accessKey) { + return NextResponse.json({ valid: false }, { status: 403 }); + } + + const valid = submitted === accessKey; + return NextResponse.json({ valid }, { status: valid ? 200 : 401 }); + } catch { + return NextResponse.json({ valid: false }, { status: 400 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 3494f48a..5c8b865f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { PasswordGate } from "@/components/password-gate"; import "./globals.css"; const geistSans = Geist({ @@ -38,7 +39,9 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + + {children} + diff --git a/components/password-gate.tsx b/components/password-gate.tsx new file mode 100644 index 00000000..dc4e8835 --- /dev/null +++ b/components/password-gate.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState, useEffect, type ReactNode } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +/** + * PasswordGate — a simple client-side password protection layer. + * + * How it works: + * 1. On mount, asks the server (GET /api/verify-access) whether protection is enabled. + * 2. If not enabled, renders children immediately (no gate). + * 3. If enabled, checks sessionStorage for a stored access key. + * 4. If found, validates it against the server via POST /api/verify-access. + * 5. If not found or invalid, shows a password prompt. + * 6. On correct password, stores it in sessionStorage and renders children. + * + * Environment: + * - ACCESS_KEY env variable must be set on the server for protection to activate. + * - If ACCESS_KEY is not set, the gate is bypassed entirely. + * + * This is intentionally simple and modular — remove the wrapper + * from layout.tsx to disable it entirely. + */ + +const SESSION_KEY = "spsays_access"; +const API_ENDPOINT = "/api/verify-access"; + +export function PasswordGate({ children }: { children: ReactNode }) { + const [status, setStatus] = useState<"loading" | "locked" | "unlocked">( + "loading", + ); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [verifying, setVerifying] = useState(false); + + async function verifyKey(key: string): Promise { + try { + const res = await fetch(API_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + if (!res.ok) return false; + const data = await res.json(); + return data.valid === true; + } catch { + return false; + } + } + + useEffect(() => { + async function init() { + // Step 1: Ask server if protection is enabled + try { + const res = await fetch(API_ENDPOINT); + const data = await res.json(); + + if (!data.enabled) { + // No ACCESS_KEY set on server — bypass gate + setStatus("unlocked"); + return; + } + } catch { + // If we can't reach the server, unlock to avoid blocking the site + setStatus("unlocked"); + return; + } + + // Step 2: Protection is enabled — check sessionStorage + const stored = sessionStorage.getItem(SESSION_KEY); + if (stored) { + const ok = await verifyKey(stored); + if (ok) { + setStatus("unlocked"); + return; + } + // Stored key is invalid — clear it + sessionStorage.removeItem(SESSION_KEY); + } + + setStatus("locked"); + } + + init(); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!password.trim()) return; + + setVerifying(true); + setError(""); + + const ok = await verifyKey(password.trim()); + if (ok) { + sessionStorage.setItem(SESSION_KEY, password.trim()); + setStatus("unlocked"); + } else { + setError("Invalid access key. Please try again."); + } + setVerifying(false); + } + + // Loading state — brief flash while checking with server + if (status === "loading") { + return ( +
+
Loading…
+
+ ); + } + + // Unlocked — render the app + if (status === "unlocked") { + return <>{children}; + } + + // Locked — show password prompt + return ( +
+ + + Prabhupāda Says +

+ Enter the access key to continue +

+
+ +
+ { + setPassword(e.target.value); + if (error) setError(""); + }} + autoFocus + disabled={verifying} + /> + {error && ( +

{error}

+ )} + +
+
+
+
+ ); +}