From b0636ba5cef3f58b7c2a2857b9a8c5d4010b7d08 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 26 Jun 2026 02:21:58 -0400 Subject: [PATCH] Add login-page site gate and refresh marketing site for complex-care positioning. Replace HTTP Basic Auth with a branded /login flow and signed session cookie, using Next.js 16's proxy convention for route protection. Co-authored-by: Cursor --- .env.example | 4 + .../(marketing)/executive-summary/page.tsx | 333 ++++++++++++++++++ src/app/(marketing)/layout.tsx | 16 + src/app/{ => (marketing)}/page.tsx | 27 +- src/app/api/auth/route.ts | 41 +++ src/app/executive-summary/page.tsx | 174 --------- src/app/layout.tsx | 16 +- src/app/login/page.tsx | 19 + src/components/feature-showcase.tsx | 65 +++- src/components/login-form.tsx | 113 ++++++ src/components/print-button.tsx | 16 +- src/components/screen-background.tsx | 6 +- src/components/site-header.tsx | 149 ++++++-- src/components/ui/button.tsx | 3 +- src/env.js | 3 + src/lib/site-auth.ts | 53 +++ src/proxy.ts | 50 +++ src/styles/globals.css | 41 ++- 18 files changed, 877 insertions(+), 252 deletions(-) create mode 100644 src/app/(marketing)/executive-summary/page.tsx create mode 100644 src/app/(marketing)/layout.tsx rename src/app/{ => (marketing)}/page.tsx (92%) create mode 100644 src/app/api/auth/route.ts delete mode 100644 src/app/executive-summary/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/components/login-form.tsx create mode 100644 src/lib/site-auth.ts create mode 100644 src/proxy.ts diff --git a/.env.example b/.env.example index adfe836..9eefb4d 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,7 @@ # Example: # SERVERVAR="foo" # NEXT_PUBLIC_CLIENTVAR="bar" + +# Password required to view the site (login page at /login). +# Leave empty/unset to disable the gate. +SITE_PASSWORD="" diff --git a/src/app/(marketing)/executive-summary/page.tsx b/src/app/(marketing)/executive-summary/page.tsx new file mode 100644 index 0000000..ca06cad --- /dev/null +++ b/src/app/(marketing)/executive-summary/page.tsx @@ -0,0 +1,333 @@ +import type { CSSProperties } from "react"; + +import type { Metadata } from "next"; +import Image from "next/image"; + +import { MedscribeLogo } from "~/components/medscribe-logo"; +import { PhoneMockup } from "~/components/phone-mockup"; +import { PrintButton } from "~/components/print-button"; +import { SectionLabel } from "~/components/section-label"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +export const metadata: Metadata = { + title: "Executive Summary", + description: + "One-page executive summary for Medscribe, a private medical context app for families managing complex care.", +}; + +const TAGLINE = + "Your medical truth, in your pocket, and only where you say it goes."; + +const SNAPSHOT = + "Medscribe is a private, emergency-ready medical record for families managing complex care. Patients and caregivers keep medications, visits, timing, and baseline current day to day, so a clinician or provider can be handed exactly what matters when the system does not know the patient."; + +const PROBLEM = + "Someone managing a serious condition ends up somewhere new: an ER outside their network, a covering doctor, a hospital that runs different software. Their records don't transfer. The team assesses them on how they present, not on their baseline, their exact medication timing, or what changed at their last visit. The family is there and they know, but they can't produce a clear, structured record under pressure."; + +const PROBLEM_BROADER = + "This is not a rare edge case. It happens every day, to patients with Parkinson's, diabetes, heart disease, dementia, and a hundred other conditions that require precise, continuous management across a fragmented system. The failure is not that families don't care. The failure is that the patient's context does not travel with them."; + +const DIFFERENTIATION = + "The market is full of AI visit recorders, medication trackers, health-record apps, and emergency QR cards, each owning one slice. None owns the whole job. In Medscribe, the daily tools are the maintenance system for one emergency-ready record."; + +const differentiators = [ + { + title: "The record is the product", + body: "Recording, scanning, reminders, and Q&A keep one private, emergency-ready record accurate. Competitors ship visit summaries; we ship a record you can hand over in a crisis.", + }, + { + title: "Private by architecture", + body: "AI runs on-device: no cloud processing, no health-data upload, no tracking, no account. Others say \"encrypted\" while their store disclosures admit cloud collection.", + }, + { + title: "Facts-first, not a guess", + body: "Medication answers render trusted drug facts deterministically, deferring dosing and diagnosis to a clinician or pharmacist. Built for complex-care families, not a general health bot.", + }, +]; + +const scenarios = [ + { + title: "The ER that doesn't know you", + body: "A patient on time-critical medication ends up at a hospital outside their network. Missing a dose by hours isn't an inconvenience; it's a medical event. Nobody has the schedule, and the family can't produce it under pressure.", + }, + { + title: "The caregiver handoff", + body: "An adult child manages a parent's medications, appointments, and history. When they're not there, the context disappears: a sibling visits, a home aide covers, a shift changes. Critical details live in one person's memory.", + }, + { + title: "The new diabetic, overwhelmed", + body: "A newly diagnosed patient leaves with insulin timing, diet changes, and follow-ups they half-remember. By the next visit, the provider can't tell what was understood, tried, or missed.", + }, +]; + +const impact = [ + { + title: "Fewer blank handoffs", + body: "When a caregiver or clinician takes over, the facts are already assembled: conditions, medications, allergies, baseline, contacts, and recent changes.", + }, + { + title: "Safer medication moments", + body: "Time-critical schedules and dose history stay visible, so a missed or delayed medication is easier to catch before it becomes a crisis.", + }, + { + title: "Patients and providers aligned", + body: "Families see what changed and why; providers get a current snapshot instead of reconstructing it from a stressed family's memory.", + }, +]; + +const printLightTokens = { + "--color-foreground": "#0f172a", + "--color-primary": "#0f766e", + "--color-surface": "#ffffff", + "--color-border-soft": "#e2e8f0", + "--color-muted-foreground": "#475569", + color: "#0f172a", +} as CSSProperties; + +export default function ExecutiveSummaryPage() { + return ( + <> + {/* ===================== SCREEN ===================== */} +
+ {/* Hero */} +
+
+ Executive Summary · June 2026 +
+

+ Medscribe +

+

+ {TAGLINE} +

+
+

+ {SNAPSHOT} +

+
+ +
+ +
+
+ + {/* The Problem */} +
+ The problem +

+ The patient's context does not travel with them. +

+
+

{PROBLEM}

+

{PROBLEM_BROADER}

+
+
+ + {/* Scenarios */} +
+ Who this is for +

+ The moments where context is everything. +

+
+ {scenarios.map((scenario) => ( + + + {scenario.title} + {scenario.body} + + + ))} +
+
+ + {/* Differentiation */} +
+ Why this is different +

+ Everyone owns a slice. We own the whole job. +

+

+ {DIFFERENTIATION} +

+
+ {differentiators.map((item) => ( + + + {item.title} + {item.body} + + + ))} +
+
+ + {/* Impact */} +
+ Potential impact +

+ Less panic, more context, better handoffs. +

+
+ {impact.map((item) => ( + + + {item.title} + + {item.body} + + + + ))} +
+
+ + {/* Beta */} +
+

+ A beta is available on iOS through TestFlight. Everything runs + privately on the device, so no health data leaves the phone. If + you'd like to try it, reach out. +

+ +
+ +

+ Sean O'Connor +

+
+ + {/* ===================== PRINT: dense one-page PDF ===================== */} +
+ {/* Header */} +
+
+

+ Executive Summary · June 2026 +

+
+ +

+ Medscribe +

+
+

+ {TAGLINE} +

+

+ {SNAPSHOT} +

+
+
+
+ Medscribe home screen +
+
+
+ + {/* The Problem */} +
+

+ The Problem +

+

+ {PROBLEM} {PROBLEM_BROADER} +

+
+ + {/* Who This Is For */} +
+

+ Who This Is For +

+
+ {scenarios.map((scenario) => ( +
+

+ {scenario.title} +

+

+ {scenario.body} +

+
+ ))} +
+
+ + {/* Why This Is Different */} +
+

+ Why This Is Different +

+

+ {DIFFERENTIATION} +

+
+ {differentiators.map((item) => ( +

+ + {item.title}. + {" "} + {item.body} +

+ ))} +
+
+ + {/* Impact */} +
+

+ Impact +

+
+ {impact.map((item) => ( +

+ + {item.title}. + {" "} + {item.body} +

+ ))} +
+
+ +
+

Sean O'Connor

+

+ A beta is available on iOS through TestFlight. +

+
+
+ + ); +} diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx new file mode 100644 index 0000000..7b31c64 --- /dev/null +++ b/src/app/(marketing)/layout.tsx @@ -0,0 +1,16 @@ +import { ScreenBackground } from "~/components/screen-background"; +import { SiteFooter } from "~/components/site-footer"; +import { SiteHeader } from "~/components/site-header"; + +export default function MarketingLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + <> + + +
{children}
+ + + ); +} diff --git a/src/app/page.tsx b/src/app/(marketing)/page.tsx similarity index 92% rename from src/app/page.tsx rename to src/app/(marketing)/page.tsx index dc7c5bc..a17eb18 100644 --- a/src/app/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -93,18 +93,15 @@ export default function HomePage() { {/* Hero */}
-
- Private complex-care context -

- Medscribe -

-

+

+ Medscribe · Private complex-care context +

Be known when care gets complicated -

+

- Medscribe is a private medical context app for families managing - complex care. It keeps visits, medications, baseline, contacts, - allergies, and emergency facts ready before the chart catches up. + A private medical context app for families managing complex care. + It keeps visits, medications, baseline, contacts, allergies, and + emergency facts ready before the chart catches up.

{today}

@@ -147,7 +144,7 @@ export default function HomePage() {

- Be known before the handoff breaks + The story scatters right when it matters

A daughter remembers the medication schedule. A spouse knows what @@ -181,7 +178,7 @@ export default function HomePage() { return (

All AI models run on your device. No health data is uploaded for processing. The medication chatbot stays in drug-information - territory — it defers dosing and diagnosis questions to your + territory. It defers dosing and diagnosis questions to your clinician or pharmacist.

@@ -319,7 +316,7 @@ export default function HomePage() { {/* CTA */}
- +
Try Medscribe on iOS @@ -333,7 +330,7 @@ export default function HomePage() { asChild size="lg" variant="outline" - className="border-white/30 bg-white text-primary hover:bg-white/90" + className="border-white/30 bg-white text-primary-button hover:bg-white/90" > Request an invite diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..c9fd0bb --- /dev/null +++ b/src/app/api/auth/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { + AUTH_COOKIE_NAME, + createAccessCookieValue, +} from "~/lib/site-auth"; + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; + +export async function POST(req: Request) { + const password = process.env.SITE_PASSWORD; + + if (!password) { + return NextResponse.json({ ok: true }); + } + + let body: { password?: string }; + try { + body = (await req.json()) as { password?: string }; + } catch { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const supplied = body.password ?? ""; + if (supplied !== password) { + return NextResponse.json({ error: "Incorrect password" }, { status: 401 }); + } + + const token = await createAccessCookieValue(password); + const response = NextResponse.json({ ok: true }); + + response.cookies.set(AUTH_COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: COOKIE_MAX_AGE, + }); + + return response; +} diff --git a/src/app/executive-summary/page.tsx b/src/app/executive-summary/page.tsx deleted file mode 100644 index 6d0b1ae..0000000 --- a/src/app/executive-summary/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import type { Metadata } from "next"; -import Image from "next/image"; -import Link from "next/link"; - -import { PrintButton } from "~/components/print-button"; - -export const metadata: Metadata = { - title: "Medscribe Executive Summary", - description: - "One-page executive summary for Medscribe, a private medical context app for families managing complex care.", -}; - -const proofPoints = [ - "iOS TestFlight build", - "Visit capture and local transcription", - "Medication OCR, reminders, and history", - "Search and grounded medication questions", - "Emergency-ready profile direction", - "Full founder ownership", -]; - -const sections = [ - { - label: "Problem", - title: "Families hold the context care teams need most.", - body: - "In complex care, the real story often lives outside the chart: what normal looks like, which medication is time-critical, what changed after the last visit, and who can explain it under pressure. Portals store records, but families still perform the handoff from memory.", - }, - { - label: "Product", - title: "A private context layer for the person, not the institution.", - body: - "Medscribe keeps visits, medications, baseline, allergies, contacts, reminders, summaries, and questions under the person they belong to. The goal is not another medical inbox. It is the family-held record that makes someone easier to understand when care gets complicated.", - }, - { - label: "Wedge", - title: "Emergency-ready, caregiver-centered, private by architecture.", - body: - "The market has AI scribes, medication trackers, caregiver portals, and EHR-adjacent tools. Medscribe's wedge is the portable layer families control before an institution is ready: current context, source trails, time-critical medication visibility, and on-device AI where possible.", - }, -]; - -const opportunities = [ - { - title: "B2C", - body: "A caregiver app for families managing aging parents, chronic illness, disability, or high-friction medication routines.", - }, - { - title: "IP / partnership", - body: "A private family-context layer that could matter to care navigation, home health, senior care, pharmacy, or patient-engagement platforms.", - }, - { - title: "Impact", - body: "Fewer blank handoffs, safer medication moments, and more confident advocates when someone cannot fully speak for themselves.", - }, -]; - -export default function ExecutiveSummaryPage() { - return ( -
-
- - Back to site - - -
- -
-
-
-

- Executive Summary -

-

- Medscribe -

-

- Be known when care gets complicated. -

-

- Medscribe is a private medical context app for families managing - complex care. It helps the people closest to a patient keep the - living record current: medications, baseline, visits, contacts, - allergies, emergency facts, and the source trail behind what changed. -

-
- -
-
- Medscribe home screen -
-
-
- -
- {sections.map((section) => ( -
-

- {section.label} -

-

- {section.title} -

-

- {section.body} -

-
- ))} -
- -
-
-

- Current State -

-

- Built enough to show, focused enough to sharpen. -

-

- Medscribe is owned and built by Sean O'Connor. Bucknell - MedTech Entrepreneurial Fellows contributed market discovery and - pitch feedback; they are not founders, owners, or the technical - team. The near-term product spine is an emergency-ready profile - that can travel with the family across fragmented care settings. -

-
- -
- {proofPoints.map((point) => ( -
- {point} -
- ))} -
-
- -
- {opportunities.map((item) => ( -
-

- {item.title} -

-

- {item.body} -

-
- ))} -
- -
-

Sean O'Connor / Founder, full owner

-

Private complex-care context / iOS TestFlight

-
-
-
- ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fa420c0..ecc238f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,10 +3,6 @@ import "~/styles/globals.css"; import { type Metadata } from "next"; import { Instrument_Sans, Playfair_Display } from "next/font/google"; -import { ScreenBackground } from "~/components/screen-background"; -import { SiteFooter } from "~/components/site-footer"; -import { SiteHeader } from "~/components/site-header"; - const instrumentSans = Instrument_Sans({ subsets: ["latin"], variable: "--font-instrument-sans", @@ -18,7 +14,10 @@ const playfair = Playfair_Display({ }); export const metadata: Metadata = { - title: "Medscribe — be known when care gets complicated", + title: { + default: "Medscribe | Be known when care gets complicated", + template: "Medscribe | %s", + }, description: "A private, emergency-ready medical context app for families managing complex care. Keep visits, medications, baseline, allergies, and caregiver handoffs current on device.", icons: { @@ -43,12 +42,7 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { return ( - - - -
{children}
- - + {children} ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..07ce32d --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; + +import { LoginForm } from "~/components/login-form"; +import { safeRedirectPath } from "~/lib/site-auth"; + +export const metadata: Metadata = { + title: "Sign in", + robots: { index: false, follow: false }, +}; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ from?: string }>; +}) { + const { from } = await searchParams; + + return ; +} diff --git a/src/components/feature-showcase.tsx b/src/components/feature-showcase.tsx index ccb3377..7092c9c 100644 --- a/src/components/feature-showcase.tsx +++ b/src/components/feature-showcase.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { CalendarDays, Camera, + Check, ContactRound, Home, MessageCircle, @@ -21,6 +22,11 @@ const features = [ title: "One profile for the person you care for", description: "Visits, medications, reminders, and questions live together under the person they belong to.", + points: [ + "Switch between everyone you care for", + "One home for the whole picture", + "Nothing scattered across apps", + ], image: "/screenshots/home.png", alt: "Medscribe home dashboard with record visit, search, medications, and ask shortcuts", }, @@ -31,6 +37,11 @@ const features = [ title: "The context behind the handoff", description: "Conditions, medication changes, visit summaries, and caregiver notes stay close enough to become useful when time is short.", + points: [ + "Baseline, allergies, and conditions up front", + "Time-critical medications flagged", + "A source trail behind each fact", + ], image: "/screenshots/emergency-record.png", alt: "Medscribe emergency record with critical care context", }, @@ -41,6 +52,11 @@ const features = [ title: "Visit recordings that refresh the record", description: "A visit can become searchable context, so a medication change or follow-up instruction does not depend on memory alone.", + points: [ + "Consent-gated, on-device recording", + "Searchable transcript and summary", + "Follow-ups captured, not forgotten", + ], image: "/screenshots/visit-timeline.png", alt: "Medscribe visit timeline with appointment cards", }, @@ -51,6 +67,11 @@ const features = [ title: "Medication OCR, reminders, and history", description: "Prescription labels, reminders, refill alerts, and dose history help the family see what is supposed to happen and what actually happened.", + points: [ + "Scan labels with the camera", + "Reminders and refill alerts", + "Dose history at a glance", + ], image: "/screenshots/medications.png", alt: "Medscribe medications screen with active medications", }, @@ -61,6 +82,11 @@ const features = [ title: "Medication questions grounded in the record", description: "Plain-language questions can draw from the person's record and bundled FDA facts, while keeping diagnosis and dosing with clinicians.", + points: [ + "Plain-language answers", + "Grounded in the record and FDA facts", + "Defers dosing and diagnosis to clinicians", + ], image: "/screenshots/chat.png", alt: "Medscribe Ask chat answering a question about blood pressure medications", }, @@ -71,6 +97,11 @@ const features = [ title: "Private by architecture, not policy", description: "The sensitive work happens on device, so the family record is not another cloud inbox waiting to be mined.", + points: [ + "On-device AI, no cloud processing", + "No inbox to breach or mine", + "You control what is ever shared", + ], image: "/screenshots/settings.png", alt: "Medscribe settings showing on-device AI models and privacy notice", }, @@ -81,8 +112,8 @@ export function FeatureShowcase() { const active = features.find((f) => f.id === activeId) ?? features[0]; return ( -
-
+
+
{features.map((feature) => { const Icon = feature.icon; @@ -96,7 +127,7 @@ export function FeatureShowcase() { className={cn( "inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-all", isActive - ? "border-primary bg-primary text-white shadow-sm" + ? "border-primary-button bg-primary-button text-white shadow-sm" : "border-border-soft bg-surface text-muted-foreground hover:border-primary/20 hover:text-foreground", )} > @@ -107,22 +138,34 @@ export function FeatureShowcase() { })}
-
-

+
+

{active.title}

{active.description}

+ +
    + {active.points.map((point) => ( +
  • + + + + {point} +
  • + ))} +

-
- +
+
+ +
); diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx new file mode 100644 index 0000000..ca706f2 --- /dev/null +++ b/src/components/login-form.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Lock } from "lucide-react"; + +import { MedscribeLogo } from "~/components/medscribe-logo"; +import { ScreenBackground } from "~/components/screen-background"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +type LoginFormProps = { + redirectTo: string; +}; + +export function LoginForm({ redirectTo }: LoginFormProps) { + const router = useRouter(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + const response = await fetch("/api/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + + if (!response.ok) { + setError("Incorrect password. Try again."); + return; + } + + router.replace(redirectTo); + router.refresh(); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + + + + +
+ + Enter site password + + + This preview site is password protected. + +
+
+ + +
+
+ +
+ + setPassword(event.target.value)} + placeholder="Password" + className="h-12 w-full rounded-[14px] border border-border-soft bg-canvas pr-4 pl-11 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/40 focus:ring-2 focus:ring-primary/20" + /> +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + +
+
+
+
+ ); +} diff --git a/src/components/print-button.tsx b/src/components/print-button.tsx index 9929998..6bcf115 100644 --- a/src/components/print-button.tsx +++ b/src/components/print-button.tsx @@ -1,12 +1,24 @@ "use client"; +import type { ComponentProps } from "react"; import { Printer } from "lucide-react"; import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; -export function PrintButton() { +type PrintButtonProps = { + className?: string; + size?: ComponentProps["size"]; +}; + +export function PrintButton({ className, size }: PrintButtonProps) { return ( - diff --git a/src/components/screen-background.tsx b/src/components/screen-background.tsx index 0292dea..4d27074 100644 --- a/src/components/screen-background.tsx +++ b/src/components/screen-background.tsx @@ -1,12 +1,12 @@ export function ScreenBackground() { return ( -
+
-
- +
+ setIsOpen(false)} + > + + + + {/* Desktop nav */} + + + {/* Mobile menu toggle */} + +
+ + {/* Mobile dropdown */} +
- - + +
+ + {/* Backdrop */} +
setIsOpen(false)} + aria-hidden + /> ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index f8a717a..8765cf2 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -9,7 +9,8 @@ const buttonVariants = cva( { variants: { variant: { - default: "bg-primary text-white shadow-sm hover:bg-primary/90", + default: + "bg-primary-button text-white shadow-sm hover:bg-primary-button/90", outline: "border border-border-soft bg-surface text-foreground hover:bg-surface-soft", secondary: diff --git a/src/env.js b/src/env.js index 5c2f937..05877b7 100644 --- a/src/env.js +++ b/src/env.js @@ -8,6 +8,8 @@ export const env = createEnv({ */ server: { NODE_ENV: z.enum(["development", "test", "production"]), + // Optional site-wide password gate via `/login`, enforced in `src/proxy.ts`. + SITE_PASSWORD: z.string().optional(), }, /** @@ -25,6 +27,7 @@ export const env = createEnv({ */ runtimeEnv: { NODE_ENV: process.env.NODE_ENV, + SITE_PASSWORD: process.env.SITE_PASSWORD, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, }, /** diff --git a/src/lib/site-auth.ts b/src/lib/site-auth.ts new file mode 100644 index 0000000..28b421e --- /dev/null +++ b/src/lib/site-auth.ts @@ -0,0 +1,53 @@ +export const AUTH_COOKIE_NAME = "medscribe_site_access"; + +const ACCESS_PAYLOAD = "medscribe-site-access-v1"; + +async function signAccessToken(password: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(ACCESS_PAYLOAD), + ); + + return btoa(String.fromCharCode(...new Uint8Array(signature))); +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + + return result === 0; +} + +export async function verifyAccessToken( + password: string, + token: string | undefined, +): Promise { + if (!token) return false; + + const expected = await signAccessToken(password); + return timingSafeEqual(expected, token); +} + +export async function createAccessCookieValue( + password: string, +): Promise { + return signAccessToken(password); +} + +export function safeRedirectPath(path: string | null | undefined): string { + if (!path?.startsWith("/") || path.startsWith("//")) return "/"; + return path; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..de4a21c --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { + AUTH_COOKIE_NAME, + safeRedirectPath, + verifyAccessToken, +} from "~/lib/site-auth"; + +/** + * Site-wide password gate via a login page and signed session cookie. + * + * Set `SITE_PASSWORD` in `.env` to require a password before the site loads. + * Leave `SITE_PASSWORD` unset to disable the gate. + */ +export async function proxy(req: NextRequest) { + const password = process.env.SITE_PASSWORD; + + if (!password) return NextResponse.next(); + + const token = req.cookies.get(AUTH_COOKIE_NAME)?.value; + const isAuthed = await verifyAccessToken(password, token); + const { pathname } = req.nextUrl; + + if (pathname === "/login" || pathname === "/api/auth") { + if (isAuthed && pathname === "/login") { + const from = safeRedirectPath(req.nextUrl.searchParams.get("from")); + return NextResponse.redirect(new URL(from, req.url)); + } + + return NextResponse.next(); + } + + if (!isAuthed) { + const loginUrl = new URL("/login", req.url); + const returnPath = pathname + req.nextUrl.search; + + if (returnPath !== "/") { + loginUrl.searchParams.set("from", returnPath); + } + + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)"], +}; diff --git a/src/styles/globals.css b/src/styles/globals.css index f7e6938..6c9c5a9 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4,11 +4,13 @@ --font-sans: var(--font-instrument-sans), ui-sans-serif, system-ui, sans-serif; --font-serif: var(--font-playfair), ui-serif, Georgia, serif; + /* Light palette: mirrors the app's `lightColors` (constants/colors.ts). */ --color-canvas: #f8fafc; --color-surface: #ffffff; --color-surface-soft: #f1f5f9; --color-primary: #0f766e; --color-primary-light: #ccfbf1; + --color-primary-button: #0f766e; --color-foreground: #0f172a; --color-muted-foreground: #64748b; --color-border-soft: #e2e8f0; @@ -17,6 +19,35 @@ } @layer base { + :root { + color-scheme: light; + --grid-line: rgba(15, 23, 42, 0.055); + } + + /* + * Dark palette: mirrors the app's `darkColors` (constants/colors.ts). + * `--color-primary` stays the bright accent (text/icons/borders); filled + * buttons use the muted `--color-primary-button` so white text keeps its + * contrast, matching the app's primary vs. primaryButton split. + */ + @media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --color-canvas: #0b1220; + --color-surface: #1e293b; + --color-surface-soft: #111827; + --color-primary: #2dd4bf; + --color-primary-light: #134e4a; + --color-primary-button: #115e59; + --color-foreground: #f1f5f9; + --color-muted-foreground: #94a3b8; + --color-border-soft: #334155; + --color-success: #34d399; + --color-success-light: #064e3b; + --grid-line: rgba(45, 212, 191, 0.08); + } + } + html { scroll-behavior: smooth; } @@ -57,9 +88,13 @@ animation: fade-in-up 0.5s ease-out 0.3s both; } -.summary-sheet { +/* + * Branded one-page PDF sheet: a green-to-white gradient washed over a faint + * teal grid. Only rendered for print (the on-screen page mirrors the homepage). + */ +.print-sheet { background: - linear-gradient(135deg, rgba(240, 253, 250, 0.92), rgba(255, 255, 255, 0.96) 38%, rgba(236, 253, 245, 0.9)), + linear-gradient(150deg, #ccfbf1 0%, #f0fdfa 30%, #ffffff 64%), linear-gradient(rgba(15, 118, 110, 0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(15, 118, 110, 0.06) 1px, transparent 1px); background-size: auto, 22px 22px, 22px 22px; @@ -96,7 +131,7 @@ animation: none !important; } - .summary-sheet { + .print-sheet { print-color-adjust: exact; -webkit-print-color-adjust: exact; }