diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 5e4de53..9b7f7cb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -57,7 +57,7 @@ jobs: - name: Run Storybook tests run: pnpm test:storybook - - name: Run six Playwright browser checks + - name: Run Playwright browser checks run: pnpm test:e2e - name: Upload Playwright report on failure diff --git a/README.md b/README.md index 7fd38c9..2a574f5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pnpm dev Open `http://localhost:3000`. -Populate `.env.local` with the development values from the Clerk dashboard. Never commit `.env.local` or real secrets. +Populate `.env.local` with development values from the Clerk dashboard. Never commit `.env.local` or real secrets. ```dotenv NEXT_PUBLIC_APP_URL=http://localhost:3000 @@ -44,13 +44,58 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replace_me CLERK_SECRET_KEY=sk_test_replace_me ``` -`NEXT_PUBLIC_API_BASE_URL` points to the local APISIX gateway. +`NEXT_PUBLIC_API_BASE_URL` points to the local APISIX gateway. The checked-in `.env.example` intentionally contains placeholders only. -## Clerk bootstrap behavior +## Authentication configuration -`src/app/layout.tsx` installs `ClerkProvider`, and `src/proxy.ts` installs `clerkMiddleware()` when both Clerk keys are real values. +`src/lib/clerk-config.ts` classifies the Clerk key pair as one of: -The checked-in placeholders intentionally keep Clerk inactive so dependency installation, static builds, Storybook, and public foundation smoke tests can run without secrets. This is bootstrap behavior only. Protected product routes must be added explicitly and must fail closed before authenticated features are shipped. +- `configured` — both keys follow Clerk's documented formats and belong to the same test or live environment; +- `missing` — one or both values are absent or blank; +- `placeholder` — checked-in example values or other obvious placeholders are present; +- `malformed` — key formats are invalid or test/live environments do not match. + +A Publishable Key is validated as a `pk_test_` or `pk_live_` value containing a base64-encoded Frontend API value with Clerk's trailing `$` delimiter. A Secret Key is treated as opaque after its documented `sk_test_` or `sk_live_` prefix and requires only a non-empty payload; the frontend does not impose an undocumented charset or length. + +`src/lib/clerk-config.server.ts` is the only environment-reading boundary. It validates the secret but never returns, logs, renders, or serializes it. `src/lib/clerk-config.server.ts` and `src/lib/auth-session.server.ts` both import `server-only`, so Next.js rejects either module when it is pulled into a Client Component. The root layout receives only the publishable key when configuration is valid. + +Secretless builds, public pages, Storybook, unit tests, and public browser tests continue to work. Protected routes never become public when Clerk is unavailable: + +| Route | Policy when Clerk is configured | Policy when Clerk is unavailable | +| --- | --- | --- | +| `/` | Public | Public | +| `/sign-in/[[...sign-in]]` | Clerk sign-in; signed-in users return to `/app` | Safe configuration state | +| `/sign-up/[[...sign-up]]` | Clerk sign-up; signed-in users return to `/app` | Safe configuration state | +| `/app` and `/app/**` | Authentication required | HTTP 503 fail-closed response | +| `/api/**` | No global policy; each future route defines its own boundary | No global policy | + +The proxy provides an early redirect for unauthenticated document requests. The protected layout and protected page both repeat the server-side Clerk session check. This defense in depth is intentional because a layout check alone is not sufficient for every client-side navigation or future server resource. + +## Application shell + +The current protected shell provides only one real navigation entry: + +```text +Overview → /app +``` + +It includes: + +- a skip link and semantic header, navigation, and main landmarks; +- a keyboard-accessible Radix mobile navigation sheet with Escape handling and focus return; +- persistent navigation from 1024 px upward; +- constrained content width at large viewports; +- a Clerk `UserButton` with stable loading dimensions; +- loading, configuration unavailable, session unavailable, and unexpected error states; +- reduced-motion behavior and visible focus states. + +No jobs, talent, organizations, marketplace, applications, billing, messaging, fake metrics, fake users, or invented backend calls are included. + +## Backend authentication contract + +The backend verifies Clerk session JWTs at the service boundary. Browser requests must send `Authorization: Bearer ` through APISIX. APISIX forwards the header, applies browser CORS and `X-Request-Id`, and does not perform JWT verification itself. + +This frontend PR does not call a backend endpoint. A future API client must preserve `X-Request-Id`, parse the backend error envelope `{code,message,request_id,details}`, and map account and dependency states without exposing raw backend errors. ## Commands @@ -58,16 +103,41 @@ The checked-in placeholders intentionally keep Clerk inactive so dependency inst pnpm dev # local development pnpm lint # ESLint pnpm typecheck # TypeScript -pnpm test # Vitest +pnpm test # unit Vitest project pnpm build # production build pnpm storybook # Storybook development server pnpm build-storybook # static Storybook build pnpm test:storybook # Storybook component tests -pnpm test:e2e # local Playwright tests +pnpm test:e2e # local Playwright tests in three browsers pnpm check # lint, typecheck, unit test, app build, Storybook build ``` -Playwright starts the local Next.js server automatically. Local runs cover Chromium, Firefox, and WebKit. CI uses Chromium for a bounded cross-commit smoke check. +Playwright starts the local Next.js server automatically and never visits an external website. + +Without a dedicated Clerk test instance, CI covers: + +- public routes; +- configuration classification; +- public/protected pathname policy; +- presentation-only shell rendering and keyboard navigation; +- deterministic auth-unavailable pages; +- protected-route HTTP 503 fail-closed behavior; +- axe checks across public and unavailable states. + +CI does not claim an authenticated end-to-end Clerk redirect or sign-in flow without actual Clerk test credentials. + +### Manual authenticated smoke checklist + +**Status: pending.** This checklist has not been executed for this PR because no dedicated Clerk test instance and credentials were available in the automated environment. + +- [ ] A signed-out request to `/app` redirects to `/sign-in` with a valid return path. +- [ ] The real Clerk sign-in component renders without a configuration or network error. +- [ ] Successful sign-in returns the user to `/app`. +- [ ] Refreshing `/app` preserves the authenticated session. +- [ ] The `UserButton` opens and signing out completes successfully. +- [ ] After sign-out, accessing `/app` is protected again and returns to the sign-in flow. +- [ ] `.env.local` remains ignored and is not staged or committed. +- [ ] Browser HTML, console output, network payloads, and client bundles contain no `CLERK_SECRET_KEY` value or other server secret. ## Agent instructions and UI skills @@ -82,7 +152,7 @@ BridgeWorks-specific rules, verified backend contracts, and the existing design ## Repository boundaries - Do not invent backend endpoints. -- Verify routes, auth, status, error-envelope, CORS, and request-ID contracts from the backend `main` branch. +- Verify routes, auth, status, error-envelope, CORS, and request-ID contracts from backend `main`. - Keep generic primitives in `src/components/ui`. - Keep feature-specific code in `src/features`. - Prefer Server Components and keep Client Components small. @@ -95,9 +165,9 @@ The frontend workflow validates: - frozen dependency installation; - lint and typecheck; -- Vitest; +- Vitest unit tests; - Next.js production build; - Storybook build and Storybook tests; -- Playwright smoke and axe checks. +- Playwright smoke, fail-closed, and axe checks across Chromium, Firefox, and WebKit. Playwright reports are retained only when the workflow fails. diff --git a/package.json b/package.json index 758bfb2..197f3ee 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "vitest run --project=unit --passWithNoTests", "test:storybook": "vitest --project=storybook --run --passWithNoTests", "test:e2e": "playwright test", "storybook": "storybook dev -p 6006", diff --git a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..d627ca4 --- /dev/null +++ b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,82 @@ +import { SignIn } from "@clerk/nextjs"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { + AuthComponentFallback, + AuthPage, +} from "@/components/layout/auth-page"; +import { + AuthState, + configurationStateKind, +} from "@/components/layout/auth-state"; +import { getClerkSessionState } from "@/lib/auth-session.server"; +import { + APP_ROUTE, + SIGN_IN_ROUTE, + SIGN_UP_ROUTE, +} from "@/lib/auth-routes"; +import { getClerkConfiguration } from "@/lib/clerk-config.server"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Sign in", + description: "Sign in to the protected BridgeWorks application.", +}; + +export default async function SignInPage() { + const configuration = getClerkConfiguration(); + + if (configuration.status !== "configured") { + return ( + + + + ); + } + + const session = await getClerkSessionState(); + if (session.status === "signed-in") { + redirect(APP_ROUTE); + } + if (session.status === "session-unavailable") { + return ( + + + + ); + } + if (session.status === "unexpected") { + return ( + + + + ); + } + + return ( + + } + /> + + ); +} diff --git a/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx new file mode 100644 index 0000000..9e60d92 --- /dev/null +++ b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -0,0 +1,82 @@ +import { SignUp } from "@clerk/nextjs"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { + AuthComponentFallback, + AuthPage, +} from "@/components/layout/auth-page"; +import { + AuthState, + configurationStateKind, +} from "@/components/layout/auth-state"; +import { getClerkSessionState } from "@/lib/auth-session.server"; +import { + APP_ROUTE, + SIGN_IN_ROUTE, + SIGN_UP_ROUTE, +} from "@/lib/auth-routes"; +import { getClerkConfiguration } from "@/lib/clerk-config.server"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Create account", + description: "Create an account for the protected BridgeWorks application.", +}; + +export default async function SignUpPage() { + const configuration = getClerkConfiguration(); + + if (configuration.status !== "configured") { + return ( + + + + ); + } + + const session = await getClerkSessionState(); + if (session.status === "signed-in") { + redirect(APP_ROUTE); + } + if (session.status === "session-unavailable") { + return ( + + + + ); + } + if (session.status === "unexpected") { + return ( + + + + ); + } + + return ( + + } + /> + + ); +} diff --git a/src/app/(protected)/app/error.tsx b/src/app/(protected)/app/error.tsx new file mode 100644 index 0000000..3f162c1 --- /dev/null +++ b/src/app/(protected)/app/error.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { HOME_ROUTE } from "@/lib/auth-routes"; + +type AppErrorProps = { + error: Error & { digest?: string }; + reset: () => void; +}; + +export default function AppError({ reset }: AppErrorProps) { + return ( +
+
+
+

+ The application could not finish loading +

+

+ BridgeWorks stopped before rendering incomplete protected content. Try + the request again, or return to the public site. +

+
+ + +
+
+ ); +} diff --git a/src/app/(protected)/app/layout.tsx b/src/app/(protected)/app/layout.tsx new file mode 100644 index 0000000..da544fd --- /dev/null +++ b/src/app/(protected)/app/layout.tsx @@ -0,0 +1,77 @@ +import { UserButton } from "@clerk/nextjs"; + +import { AppShell } from "@/components/layout/app-shell"; +import { + AuthState, + configurationStateKind, +} from "@/components/layout/auth-state"; +import { getClerkSessionState } from "@/lib/auth-session.server"; +import { APP_ROUTE } from "@/lib/auth-routes"; +import { getClerkConfiguration } from "@/lib/clerk-config.server"; + +export const dynamic = "force-dynamic"; + +function StandaloneAuthState({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} + +function AccountControlFallback() { + return ( +
+ Loading account controls +
+ ); +} + +export default async function ProtectedAppLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const configuration = getClerkConfiguration(); + if (configuration.status !== "configured") { + return ( + + + + ); + } + + const session = await getClerkSessionState(); + if (session.status === "signed-out") { + return session.redirectToSignIn({ returnBackUrl: APP_ROUTE }); + } + if (session.status === "session-unavailable") { + return ( + + + + ); + } + if (session.status === "unexpected") { + return ( + + + + ); + } + + const accountControl = ( + } /> + ); + + return {children}; +} diff --git a/src/app/(protected)/app/loading.tsx b/src/app/(protected)/app/loading.tsx new file mode 100644 index 0000000..1d47235 --- /dev/null +++ b/src/app/(protected)/app/loading.tsx @@ -0,0 +1,17 @@ +export default function AppLoading() { + return ( +
+
+
+
+
+
+
+ Loading application overview +
+ ); +} diff --git a/src/app/(protected)/app/page.tsx b/src/app/(protected)/app/page.tsx new file mode 100644 index 0000000..8834934 --- /dev/null +++ b/src/app/(protected)/app/page.tsx @@ -0,0 +1,98 @@ +import { CheckCircle2 } from "lucide-react"; +import type { Metadata } from "next"; + +import { + AuthState, + configurationStateKind, +} from "@/components/layout/auth-state"; +import { getClerkSessionState } from "@/lib/auth-session.server"; +import { APP_ROUTE } from "@/lib/auth-routes"; +import { getClerkConfiguration } from "@/lib/clerk-config.server"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Overview", + description: "The protected BridgeWorks application foundation.", +}; + +const foundationCapabilities = [ + "Protected server rendering with a fail-closed authentication boundary", + "Responsive navigation for mobile, tablet, and desktop workspaces", + "Accessible account controls ready for future product vertical slices", +]; + +export default async function AppOverviewPage() { + const configuration = getClerkConfiguration(); + if (configuration.status !== "configured") { + return ( + + ); + } + + const session = await getClerkSessionState(); + if (session.status === "signed-out") { + return session.redirectToSignIn({ returnBackUrl: APP_ROUTE }); + } + if (session.status === "session-unavailable") { + return ; + } + if (session.status === "unexpected") { + return ; + } + + return ( +
+
+

+ Authenticated workspace +

+

+ Overview +

+

+ The protected BridgeWorks application shell is ready. Product + workflows will be added as focused vertical slices without weakening + this authentication boundary. +

+
+ +
+

+ Foundation ready +

+
    + {foundationCapabilities.map((capability) => ( +
  • +
  • + ))} +
+
+ +
+

+ What comes next +

+

+ The next approved feature can attach to this shell after its backend + contract, authorization policy, states, and accessibility behavior are + verified. +

+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..17fe016 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -127,4 +127,15 @@ html { @apply font-sans; } -} \ No newline at end of file +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fe96084..65dc169 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,13 @@ import { ClerkProvider } from "@clerk/nextjs"; import type { Metadata } from "next"; import { Geist_Mono, Inter } from "next/font/google"; +import { + APP_ROUTE, + HOME_ROUTE, + SIGN_IN_ROUTE, + SIGN_UP_ROUTE, +} from "@/lib/auth-routes"; +import { getClerkConfiguration } from "@/lib/clerk-config.server"; import { cn } from "@/lib/utils"; import "./globals.css"; @@ -30,6 +37,7 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const clerkConfiguration = getClerkConfiguration(); const document = ( ); - const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; - const secretKey = process.env.CLERK_SECRET_KEY; - const clerkConfigured = Boolean( - publishableKey && - secretKey && - !publishableKey.includes("replace_me") && - !secretKey.includes("replace_me"), - ); - - if (clerkConfigured && publishableKey) { - return ( - {document} - ); + if (clerkConfiguration.status !== "configured") { + return document; } - return document; + return ( + + {document} + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 36d051f..84249de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; const foundations = [ "Next.js App Router with strict TypeScript", - "Clerk-ready authentication boundary", - "Storybook, Vitest, Playwright, and axe", + "Fail-closed Clerk authentication boundary", + "Secretless Storybook, Vitest, Playwright, and axe coverage", ]; export default function Home() { @@ -12,9 +12,12 @@ export default function Home() {
BridgeWorks - - Frontend foundation - + + Sign in +
@@ -32,15 +35,18 @@ export default function Home() { work.

+ + Sign in to BridgeWorks + - Explore the product foundation + Review the foundation - - Product workflows arrive in focused vertical slices. -
diff --git a/src/components/layout/app-shell.stories.tsx b/src/components/layout/app-shell.stories.tsx new file mode 100644 index 0000000..f0cc5e8 --- /dev/null +++ b/src/components/layout/app-shell.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import { AppShell } from "@/components/layout/app-shell"; + +function OverviewContent() { + return ( +
+
+

+ Authenticated workspace +

+

Overview

+

+ The protected BridgeWorks application shell is ready for focused + product vertical slices. +

+
+
+

Foundation ready

+

+ This story uses presentation-only account data and does not require a + Clerk secret. +

+
+
+ ); +} + +const meta = { + title: "Layout/App Shell", + component: AppShell, + parameters: { + layout: "fullscreen", + controls: { disable: true }, + }, + args: { + accountControl: , + children: , + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + parameters: { + viewport: { defaultViewport: "desktop" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("main")).toBeVisible(); + await expect( + canvas.getByRole("navigation", { name: "Application" }), + ).toBeVisible(); + }, +}; + +export const MobileNavigation: Story = { + parameters: { + viewport: { defaultViewport: "mobile1" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { name: "Open navigation" }); + + await userEvent.click(trigger); + + const page = within(canvasElement.ownerDocument.body); + const dialog = page.getByRole("dialog"); + await waitFor(() => expect(dialog).toBeVisible()); + await waitFor(() => + expect( + page.getByRole("navigation", { name: "Mobile application" }), + ).toBeVisible(), + ); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + }, +}; + +export const LongDisplayName: Story = { + args: { + accountControl: ( + + ), + }, +}; diff --git a/src/components/layout/app-shell.test.tsx b/src/components/layout/app-shell.test.tsx new file mode 100644 index 0000000..9034d1d --- /dev/null +++ b/src/components/layout/app-shell.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { AppShell } from "@/components/layout/app-shell"; + +function renderShell(accountName = "Taylor Bridge") { + return render( + Account for {accountName}} + > +

Overview

+

Protected application content

+
, + ); +} + +describe("AppShell", () => { + it("renders semantic navigation, a skip link, and the main landmark", () => { + renderShell(); + + expect( + screen.getByRole("link", { name: "Skip to main content" }), + ).toHaveAttribute("href", "#main-content"); + expect(screen.getByRole("navigation", { name: "Application" })).toBeVisible(); + expect( + within(screen.getByRole("navigation", { name: "Application" })).getByRole( + "link", + { name: "Overview" }, + ), + ).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); + expect(screen.getByRole("heading", { name: "Overview" })).toBeVisible(); + }); + + it("opens and closes mobile navigation with keyboard focus returned", async () => { + const user = userEvent.setup(); + renderShell(); + + const trigger = screen.getByRole("button", { name: "Open navigation" }); + trigger.focus(); + await user.keyboard("{Enter}"); + + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByRole("navigation", { + name: "Mobile application", + }), + ).toBeVisible(); + expect( + within(dialog).getByRole("button", { name: "Close navigation" }), + ).toBeVisible(); + + await user.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it("keeps the shell usable with a long account display name", () => { + renderShell( + "Alexandria Montgomery-Wellington the Third from BridgeWorks Operations", + ); + + expect(screen.getByRole("main")).toBeVisible(); + expect( + screen.getByRole("button", { + name: /Alexandria Montgomery-Wellington/, + }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..9a4297c --- /dev/null +++ b/src/components/layout/app-shell.tsx @@ -0,0 +1,75 @@ +import { Home } from "lucide-react"; +import Link from "next/link"; + +import { MobileAppNavigation } from "@/components/layout/mobile-app-navigation"; +import { APP_ROUTE } from "@/lib/auth-routes"; + +type AppShellProps = { + accountControl: React.ReactNode; + children: React.ReactNode; +}; + +function BrandLink() { + return ( + + BridgeWorks + + ); +} + +export function AppShell({ accountControl, children }: AppShellProps) { + return ( +
+ + Skip to main content + + +
+ + +
+ +
+ + +
+
{children}
+
+
+
+ ); +} diff --git a/src/components/layout/auth-page.tsx b/src/components/layout/auth-page.tsx new file mode 100644 index 0000000..d2c4abd --- /dev/null +++ b/src/components/layout/auth-page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; + +import { HOME_ROUTE } from "@/lib/auth-routes"; + +type AuthPageProps = { + title: string; + description: string; + children: React.ReactNode; +}; + +export function AuthPage({ title, description, children }: AuthPageProps) { + return ( +
+
+ + BridgeWorks + + +
+
+

+ Secure workspace access +

+

+ {title} +

+

+ {description} +

+
+ +
+ {children} +
+
+
+
+ ); +} + +export function AuthComponentFallback() { + return ( +
+ Loading authentication form +
+ ); +} diff --git a/src/components/layout/auth-state.stories.tsx b/src/components/layout/auth-state.stories.tsx new file mode 100644 index 0000000..625c413 --- /dev/null +++ b/src/components/layout/auth-state.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { AuthState } from "@/components/layout/auth-state"; + +const meta = { + title: "Layout/Authentication States", + component: AuthState, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MissingConfiguration: Story = { + args: { kind: "configuration-missing", headingLevel: "h1" }, +}; + +export const PlaceholderConfiguration: Story = { + args: { kind: "configuration-placeholder", headingLevel: "h1" }, +}; + +export const MalformedConfiguration: Story = { + args: { kind: "configuration-malformed", headingLevel: "h1" }, +}; + +export const SessionUnavailable: Story = { + args: { kind: "session-unavailable", headingLevel: "h1" }, +}; diff --git a/src/components/layout/auth-state.tsx b/src/components/layout/auth-state.tsx new file mode 100644 index 0000000..b828cc9 --- /dev/null +++ b/src/components/layout/auth-state.tsx @@ -0,0 +1,95 @@ +import { AlertTriangle } from "lucide-react"; +import Link from "next/link"; + +import { APP_ROUTE, HOME_ROUTE } from "@/lib/auth-routes"; +import type { ClerkConfiguration } from "@/lib/clerk-config"; + +type AuthStateKind = + | "configuration-missing" + | "configuration-placeholder" + | "configuration-malformed" + | "session-unavailable" + | "unexpected"; + +type AuthStateProps = { + kind: AuthStateKind; + headingLevel?: "h1" | "h2"; +}; + +const stateContent: Record< + AuthStateKind, + { title: string; description: string; actionLabel: string; actionHref: string } +> = { + "configuration-missing": { + title: "Authentication is not configured", + description: + "BridgeWorks public pages remain available, but secure account access requires the application operator to finish the Clerk configuration.", + actionLabel: "Return to BridgeWorks", + actionHref: HOME_ROUTE, + }, + "configuration-placeholder": { + title: "Authentication setup is incomplete", + description: + "Example credentials are still active. Replace them with a valid Clerk development or production key pair before opening secure routes.", + actionLabel: "Return to BridgeWorks", + actionHref: HOME_ROUTE, + }, + "configuration-malformed": { + title: "Authentication configuration is invalid", + description: + "The configured Clerk key pair cannot be used safely. The application operator must correct the configuration before secure routes can open.", + actionLabel: "Return to BridgeWorks", + actionHref: HOME_ROUTE, + }, + "session-unavailable": { + title: "Your session is not ready", + description: + "BridgeWorks confirmed an account but could not establish a usable session. Reload the protected application to try again.", + actionLabel: "Reload the application", + actionHref: APP_ROUTE, + }, + unexpected: { + title: "Authentication could not be verified", + description: + "A temporary authentication error prevented BridgeWorks from confirming the session. No protected content was rendered.", + actionLabel: "Try the application again", + actionHref: APP_ROUTE, + }, +}; + +export function configurationStateKind( + configuration: Exclude, +): AuthStateKind { + return `configuration-${configuration.status}`; +} + +export function AuthState({ kind, headingLevel = "h2" }: AuthStateProps) { + const content = stateContent[kind]; + const Heading = headingLevel; + + return ( +
+
+
+ + {content.title} + +

+ {content.description} +

+ + {content.actionLabel} + +
+ ); +} diff --git a/src/components/layout/mobile-app-navigation.tsx b/src/components/layout/mobile-app-navigation.tsx new file mode 100644 index 0000000..b4de3ed --- /dev/null +++ b/src/components/layout/mobile-app-navigation.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Home, Menu, X } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { APP_ROUTE } from "@/lib/auth-routes"; + +type MobileAppNavigationProps = { + accountControl: React.ReactNode; +}; + +export function MobileAppNavigation({ + accountControl, +}: MobileAppNavigationProps) { + return ( + + + + + + +
+
+ + BridgeWorks + + + Application navigation + +
+ + + +
+
+ + + + +

+ Account +

+
{accountControl}
+
+
+
+ ); +} diff --git a/src/lib/auth-routes.test.ts b/src/lib/auth-routes.test.ts new file mode 100644 index 0000000..ed2c184 --- /dev/null +++ b/src/lib/auth-routes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { isProtectedAppPathname } from "@/lib/auth-routes"; + +describe("isProtectedAppPathname", () => { + it.each(["/app", "/app/", "/app/settings", "/app/settings/profile/"])( + "protects %s", + (pathname) => { + expect(isProtectedAppPathname(pathname)).toBe(true); + }, + ); + + it.each([ + "/", + "/sign-in", + "/sign-up", + "/application", + "/app-store", + "/api/app", + "/api/v1/me", + ])("leaves %s outside the application policy", (pathname) => { + expect(isProtectedAppPathname(pathname)).toBe(false); + }); +}); diff --git a/src/lib/auth-routes.ts b/src/lib/auth-routes.ts new file mode 100644 index 0000000..9ae1989 --- /dev/null +++ b/src/lib/auth-routes.ts @@ -0,0 +1,14 @@ +export const HOME_ROUTE = "/"; +export const APP_ROUTE = "/app"; +export const SIGN_IN_ROUTE = "/sign-in"; +export const SIGN_UP_ROUTE = "/sign-up"; + +export function isProtectedAppPathname(pathname: string) { + const normalizedPathname = + pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; + + return ( + normalizedPathname === APP_ROUTE || + normalizedPathname.startsWith(`${APP_ROUTE}/`) + ); +} diff --git a/src/lib/auth-session.server.ts b/src/lib/auth-session.server.ts new file mode 100644 index 0000000..21624cf --- /dev/null +++ b/src/lib/auth-session.server.ts @@ -0,0 +1,27 @@ +import "server-only"; + +import { auth } from "@clerk/nextjs/server"; + +export async function getClerkSessionState() { + try { + const session = await auth(); + + if (!session.isAuthenticated) { + return { + status: "signed-out" as const, + redirectToSignIn: session.redirectToSignIn, + }; + } + + if (!session.sessionId) { + return { status: "session-unavailable" as const }; + } + + return { + status: "signed-in" as const, + sessionId: session.sessionId, + }; + } catch { + return { status: "unexpected" as const }; + } +} diff --git a/src/lib/clerk-config.server.ts b/src/lib/clerk-config.server.ts new file mode 100644 index 0000000..94bfe69 --- /dev/null +++ b/src/lib/clerk-config.server.ts @@ -0,0 +1,17 @@ +import "server-only"; + +import { + classifyClerkConfiguration, + type ClerkConfiguration, +} from "@/lib/clerk-config"; + +/** + * Server-only configuration boundary. The secret is inspected for validity and + * discarded; callers can never read or serialize it. + */ +export function getClerkConfiguration(): ClerkConfiguration { + return classifyClerkConfiguration({ + publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, + secretKey: process.env.CLERK_SECRET_KEY, + }); +} diff --git a/src/lib/clerk-config.test.ts b/src/lib/clerk-config.test.ts new file mode 100644 index 0000000..ac6702f --- /dev/null +++ b/src/lib/clerk-config.test.ts @@ -0,0 +1,109 @@ +import { Buffer } from "node:buffer"; + +import { describe, expect, it } from "vitest"; + +import { classifyClerkConfiguration } from "@/lib/clerk-config"; + +const frontendApiDomain = "bridgeworks-test.accounts.dev"; +const encodedFrontendApi = Buffer.from(`${frontendApiDomain}$`, "utf8").toString( + "base64", +); +const publishableTestKey = `pk_test_${encodedFrontendApi}`; +const publishableLiveKey = `pk_live_${encodedFrontendApi}`; +const secretTestKey = "sk_test_opaque:fixture/with+punctuation="; + +describe("classifyClerkConfiguration", () => { + it("accepts an official-shaped test key pair without returning the secret", () => { + const result = classifyClerkConfiguration({ + publishableKey: ` ${publishableTestKey} `, + secretKey: ` ${secretTestKey} `, + }); + + expect(result).toEqual({ + status: "configured", + publishableKey: publishableTestKey, + environment: "test", + }); + expect(result).not.toHaveProperty("secretKey"); + expect(JSON.stringify(result)).not.toContain(secretTestKey); + }); + + it("reports every missing key", () => { + expect(classifyClerkConfiguration({})).toEqual({ + status: "missing", + keys: [ + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "CLERK_SECRET_KEY", + ], + }); + }); + + it("treats blank values as missing before inspecting placeholders", () => { + expect( + classifyClerkConfiguration({ + publishableKey: " ", + secretKey: "sk_test_replace_me", + }), + ).toEqual({ + status: "missing", + keys: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"], + }); + }); + + it("classifies checked-in example values as placeholders", () => { + expect( + classifyClerkConfiguration({ + publishableKey: "pk_test_replace_me", + secretKey: "sk_test_replace_me", + }), + ).toEqual({ + status: "placeholder", + keys: [ + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "CLERK_SECRET_KEY", + ], + }); + }); + + it("classifies an invalid publishable key as malformed", () => { + expect( + classifyClerkConfiguration({ + publishableKey: "publishable-value", + secretKey: secretTestKey, + }), + ).toEqual({ + status: "malformed", + keys: ["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"], + reason: "invalid_format", + }); + }); + + it("classifies a Secret Key without an opaque payload as malformed", () => { + expect( + classifyClerkConfiguration({ + publishableKey: publishableTestKey, + secretKey: "sk_test_", + }), + ).toEqual({ + status: "malformed", + keys: ["CLERK_SECRET_KEY"], + reason: "invalid_format", + }); + }); + + it("rejects a live publishable key paired with a test Secret Key", () => { + expect( + classifyClerkConfiguration({ + publishableKey: publishableLiveKey, + secretKey: secretTestKey, + }), + ).toEqual({ + status: "malformed", + keys: [ + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "CLERK_SECRET_KEY", + ], + reason: "environment_mismatch", + }); + }); +}); diff --git a/src/lib/clerk-config.ts b/src/lib/clerk-config.ts new file mode 100644 index 0000000..b40f3f8 --- /dev/null +++ b/src/lib/clerk-config.ts @@ -0,0 +1,162 @@ +export const CLERK_ENV_KEYS = { + publishableKey: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + secretKey: "CLERK_SECRET_KEY", +} as const; + +type ClerkEnvironmentKey = + (typeof CLERK_ENV_KEYS)[keyof typeof CLERK_ENV_KEYS]; + +type ClerkInstanceEnvironment = "test" | "live"; +type ClerkKeyKind = "pk" | "sk"; + +export type ClerkConfiguration = + | { + status: "configured"; + publishableKey: string; + environment: ClerkInstanceEnvironment; + } + | { + status: "missing"; + keys: readonly ClerkEnvironmentKey[]; + } + | { + status: "placeholder"; + keys: readonly ClerkEnvironmentKey[]; + } + | { + status: "malformed"; + keys: readonly ClerkEnvironmentKey[]; + reason: "invalid_format" | "environment_mismatch"; + }; + +type ClerkEnvironmentInput = { + publishableKey?: string; + secretKey?: string; +}; + +function normalize(value: string | undefined) { + return value?.trim() ?? ""; +} + +function isPlaceholder(value: string) { + const normalized = value.toLowerCase(); + + return ( + normalized.includes("replace_me") || + normalized.includes("placeholder") || + normalized.includes("your_key") || + normalized.startsWith("<") || + normalized.endsWith(">") + ); +} + +function parsePrefixedEnvironment( + value: string, + kind: ClerkKeyKind, +): ClerkInstanceEnvironment | null { + for (const environment of ["test", "live"] as const) { + const prefix = `${kind}_${environment}_`; + if (value.startsWith(prefix) && value.length > prefix.length) { + return environment; + } + } + + return null; +} + +function parsePublishableKeyEnvironment( + publishableKey: string, +): ClerkInstanceEnvironment | null { + const environment = parsePrefixedEnvironment(publishableKey, "pk"); + if (!environment) { + return null; + } + + const encodedFrontendApi = publishableKey.slice( + `pk_${environment}_`.length, + ); + + try { + const decodedFrontendApi = atob(encodedFrontendApi); + if ( + decodedFrontendApi.length <= 1 || + !decodedFrontendApi.endsWith("$") + ) { + return null; + } + } catch { + return null; + } + + return environment; +} + +function parseSecretKeyEnvironment( + secretKey: string, +): ClerkInstanceEnvironment | null { + // Clerk documents the environment prefixes, but the remaining secret payload + // is opaque. Do not impose an undocumented charset or minimum length on it. + return parsePrefixedEnvironment(secretKey, "sk"); +} + +export function classifyClerkConfiguration( + input: ClerkEnvironmentInput, +): ClerkConfiguration { + const publishableKey = normalize(input.publishableKey); + const secretKey = normalize(input.secretKey); + + const missingKeys: ClerkEnvironmentKey[] = []; + if (!publishableKey) { + missingKeys.push(CLERK_ENV_KEYS.publishableKey); + } + if (!secretKey) { + missingKeys.push(CLERK_ENV_KEYS.secretKey); + } + if (missingKeys.length > 0) { + return { status: "missing", keys: missingKeys }; + } + + const placeholderKeys: ClerkEnvironmentKey[] = []; + if (isPlaceholder(publishableKey)) { + placeholderKeys.push(CLERK_ENV_KEYS.publishableKey); + } + if (isPlaceholder(secretKey)) { + placeholderKeys.push(CLERK_ENV_KEYS.secretKey); + } + if (placeholderKeys.length > 0) { + return { status: "placeholder", keys: placeholderKeys }; + } + + const publishableEnvironment = + parsePublishableKeyEnvironment(publishableKey); + const secretEnvironment = parseSecretKeyEnvironment(secretKey); + if (!publishableEnvironment || !secretEnvironment) { + const malformedKeys: ClerkEnvironmentKey[] = []; + if (!publishableEnvironment) { + malformedKeys.push(CLERK_ENV_KEYS.publishableKey); + } + if (!secretEnvironment) { + malformedKeys.push(CLERK_ENV_KEYS.secretKey); + } + + return { + status: "malformed", + keys: malformedKeys, + reason: "invalid_format", + }; + } + + if (publishableEnvironment !== secretEnvironment) { + return { + status: "malformed", + keys: [CLERK_ENV_KEYS.publishableKey, CLERK_ENV_KEYS.secretKey], + reason: "environment_mismatch", + }; + } + + return { + status: "configured", + publishableKey, + environment: publishableEnvironment, + }; +} diff --git a/src/proxy.ts b/src/proxy.ts index cf4d593..20a48f2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,23 +1,91 @@ import { clerkMiddleware } from "@clerk/nextjs/server"; -import { NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; -const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; -const secretKey = process.env.CLERK_SECRET_KEY; +import { + SIGN_IN_ROUTE, + SIGN_UP_ROUTE, + isProtectedAppPathname, +} from "@/lib/auth-routes"; +import { getClerkConfiguration } from "@/lib/clerk-config.server"; -const clerkConfigured = Boolean( - publishableKey && - secretKey && - !publishableKey.includes("replace_me") && - !secretKey.includes("replace_me"), -); +const clerkConfiguration = getClerkConfiguration(); -const passThrough = () => NextResponse.next(); +function clerkUnavailableResponse() { + return new NextResponse( + ` + + + + + + Authentication unavailable | BridgeWorks + + + +
+

BridgeWorks

+

Authentication is temporarily unavailable

+

The protected application cannot open until authentication is configured correctly. Return to the public site or contact the application operator.

+

Return to BridgeWorks

+
+ +`, + { + status: 503, + headers: { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + "Content-Type": "text/html; charset=utf-8", + "X-Robots-Tag": "noindex", + }, + }, + ); +} -export default clerkConfigured ? clerkMiddleware() : passThrough; +function unavailableProxy(request: NextRequest) { + if (isProtectedAppPathname(request.nextUrl.pathname)) { + return clerkUnavailableResponse(); + } + + return NextResponse.next(); +} + +const configuredProxy = + clerkConfiguration.status === "configured" + ? clerkMiddleware( + async (auth, request) => { + if (!isProtectedAppPathname(request.nextUrl.pathname)) { + return NextResponse.next(); + } + + const session = await auth(); + if (!session.isAuthenticated) { + return session.redirectToSignIn({ returnBackUrl: request.url }); + } + + return NextResponse.next(); + }, + { + signInUrl: SIGN_IN_ROUTE, + signUpUrl: SIGN_UP_ROUTE, + }, + ) + : unavailableProxy; + +export default configuredProxy; export const config = { matcher: [ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", + "/__clerk/(.*)", ], }; diff --git a/tests/example.spec.ts b/tests/example.spec.ts index 57ff993..08f9804 100644 --- a/tests/example.spec.ts +++ b/tests/example.spec.ts @@ -1,7 +1,7 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; -test("renders the BridgeWorks frontend foundation", async ({ page }) => { +test("renders the BridgeWorks public foundation", async ({ page }) => { await page.goto("/"); await expect(page).toHaveTitle(/BridgeWorks/); @@ -10,17 +10,60 @@ test("renders the BridgeWorks frontend foundation", async ({ page }) => { name: "Build trusted work relationships, step by step.", }), ).toBeVisible(); +}); + +test("renders deterministic secretless sign-in and sign-up states", async ({ + page, +}) => { + await page.goto("/sign-in"); + await expect( + page.getByRole("heading", { name: "Sign in to BridgeWorks", level: 1 }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + name: /Authentication (is not configured|setup is incomplete|configuration is invalid)/, + level: 2, + }), + ).toBeVisible(); + + await page.goto("/sign-up"); await expect( - page.getByRole("link", { name: "Explore the product foundation" }), + page.getByRole("heading", { + name: "Create your BridgeWorks account", + level: 1, + }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + name: /Authentication (is not configured|setup is incomplete|configuration is invalid)/, + level: 2, + }), ).toBeVisible(); }); -test("has no automatically detectable accessibility violations", async ({ +test("fails closed when a protected route has no usable Clerk configuration", async ({ page, }) => { - await page.goto("/"); + const response = await page.goto("/app"); - const results = await new AxeBuilder({ page }).analyze(); + expect(response?.status()).toBe(503); + await expect( + page.getByRole("heading", { + name: "Authentication is temporarily unavailable", + level: 1, + }), + ).toBeVisible(); + await expect(page.getByText(/sk_test|CLERK_SECRET_KEY/)).toHaveCount(0); +}); - expect(results.violations).toEqual([]); +test("has no automatically detectable accessibility violations", async ({ + page, +}) => { + for (const route of ["/", "/sign-in", "/sign-up", "/app"]) { + await page.goto(route); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations, `accessibility violations on ${route}`).toEqual( + [], + ); + } }); diff --git a/vitest.config.ts b/vitest.config.ts index 9e4b59b..c1ac732 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,33 +1,44 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -import { defineConfig } from 'vitest/config'; - -import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; - -import { playwright } from '@vitest/browser-playwright'; +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; const dirname = - typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + typeof __dirname !== "undefined" + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); -// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(dirname, "src"), + }, + }, test: { projects: [ + { + extends: true, + test: { + name: "unit", + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + setupFiles: [path.join(dirname, "vitest.setup.ts")], + }, + }, { extends: true, plugins: [ - // The plugin will run tests for the stories defined in your Storybook config - // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest - storybookTest({ configDir: path.join(dirname, '.storybook') }), + storybookTest({ configDir: path.join(dirname, ".storybook") }), ], test: { - name: 'storybook', + name: "storybook", browser: { enabled: true, headless: true, provider: playwright({}), - instances: [{ browser: 'chromium' }], + instances: [{ browser: "chromium" }], }, }, }, diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..8b4cea8 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,33 @@ +import "@testing-library/jest-dom/vitest"; + +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +afterEach(() => { + cleanup(); +}); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.defineProperty(globalThis, "ResizeObserver", { + writable: true, + value: ResizeObserverMock, +});