Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3b13f8e
feat(auth): classify Clerk configuration states
DoMinhHHung Aug 4, 2026
e5dd5ff
feat(auth): read Clerk configuration centrally
DoMinhHHung Aug 4, 2026
b5234b1
test(auth): cover Clerk configuration states
DoMinhHHung Aug 4, 2026
eb7e0bd
feat(auth): define explicit application routes
DoMinhHHung Aug 4, 2026
2720dc6
test(auth): cover protected route matching
DoMinhHHung Aug 4, 2026
312df03
feat(auth): centralize server session state
DoMinhHHung Aug 4, 2026
9c7f5cc
feat(auth): fail closed for protected application routes
DoMinhHHung Aug 4, 2026
2417d1b
refactor(auth): use centralized Clerk provider configuration
DoMinhHHung Aug 4, 2026
1878a37
feat(auth): add safe authentication state presentation
DoMinhHHung Aug 4, 2026
f46bd97
feat(auth): add responsive authentication page layout
DoMinhHHung Aug 4, 2026
6000339
feat(shell): add keyboard accessible mobile navigation
DoMinhHHung Aug 4, 2026
efc163b
feat(shell): add responsive authenticated application shell
DoMinhHHung Aug 4, 2026
912c379
feat(auth): add Clerk sign-in route
DoMinhHHung Aug 4, 2026
a34e6a0
feat(auth): add Clerk sign-up route
DoMinhHHung Aug 4, 2026
a4e61ea
feat(shell): protect and render authenticated application layout
DoMinhHHung Aug 4, 2026
6d22fdf
feat(shell): add authenticated overview foundation page
DoMinhHHung Aug 4, 2026
f670932
feat(shell): add stable application loading state
DoMinhHHung Aug 4, 2026
c95daa3
feat(shell): add redacted application error recovery
DoMinhHHung Aug 4, 2026
32adca4
style(a11y): respect reduced motion globally
DoMinhHHung Aug 4, 2026
5bc63f7
test(shell): cover rendering and keyboard navigation
DoMinhHHung Aug 4, 2026
ddbfdf7
test(storybook): add authenticated shell stories
DoMinhHHung Aug 4, 2026
7b1d51f
test(storybook): add authentication unavailable stories
DoMinhHHung Aug 4, 2026
ce07aa7
test: configure DOM component test environment
DoMinhHHung Aug 4, 2026
9386d29
test: add unit and Storybook Vitest projects
DoMinhHHung Aug 4, 2026
7eba616
test: run the dedicated unit project
DoMinhHHung Aug 4, 2026
a795e10
test(e2e): cover public and fail-closed authentication states
DoMinhHHung Aug 4, 2026
26a82e9
feat(auth): link the public foundation to sign-in
DoMinhHHung Aug 4, 2026
3f58eeb
docs(auth): document protected application shell
DoMinhHHung Aug 4, 2026
44e9f90
ci: name expanded browser validation accurately
DoMinhHHung Aug 4, 2026
45a15eb
fix(auth): narrow invalid Clerk key matches
DoMinhHHung Aug 4, 2026
44b8377
fix(auth): render sign-in per request
DoMinhHHung Aug 4, 2026
6d6868d
fix(auth): render sign-up per request
DoMinhHHung Aug 4, 2026
83b9ba9
fix(auth): render protected shell per request
DoMinhHHung Aug 4, 2026
b3dc281
fix(auth): verify protected page per request
DoMinhHHung Aug 4, 2026
0834d35
fix(shell): use the Next.js error boundary contract
DoMinhHHung Aug 4, 2026
1802f55
fix(storybook): wait for mobile sheet transitions
DoMinhHHung Aug 4, 2026
e9d0351
harden server-only clerk config boundary
DoMinhHHung Aug 5, 2026
177afa9
harden server-only auth session boundary
DoMinhHHung Aug 5, 2026
f53ba55
align clerk key validation with documented formats
DoMinhHHung Aug 5, 2026
32036b8
harden clerk configuration fixtures and coverage
DoMinhHHung Aug 5, 2026
c3024ec
document authenticated smoke hardening
DoMinhHHung Aug 5, 2026
7699056
verify server-only client import rejection
DoMinhHHung Aug 5, 2026
968c3cf
remove ignored server-only verification fixture
DoMinhHHung Aug 5, 2026
cf14d08
verify routable server-only client import rejection
DoMinhHHung Aug 5, 2026
3c1f3e9
remove server-only verification fixture
DoMinhHHung Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 81 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,30 +44,100 @@ 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 <session token>` 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

```bash
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

Expand All @@ -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.
Expand All @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test": "vitest run --project=unit --passWithNoTests",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Không che giấu lỗi không phát hiện kiểm thử.

Repository đã có unit test trong src/. --passWithNoTests làm bước CI thành công nếu glob src/**/*.test.{ts,tsx} bị sai hoặc không còn tìm thấy kiểm thử. Hãy để Vitest thất bại trong trường hợp đó.

Đề xuất sửa
-    "test": "vitest run --project=unit --passWithNoTests",
+    "test": "vitest run --project=unit",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"test": "vitest run --project=unit --passWithNoTests",
"test": "vitest run --project=unit",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 11, Remove the --passWithNoTests flag from the
package.json test script while preserving the unit project selection, so Vitest
fails when the expected src/**/*.test.{ts,tsx} tests are not discovered.

"test:storybook": "vitest --project=storybook --run --passWithNoTests",
"test:e2e": "playwright test",
"storybook": "storybook dev -p 6006",
Expand Down
82 changes: 82 additions & 0 deletions src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthPage
title="Sign in to BridgeWorks"
description="Continue to the secure application workspace after your account is authenticated."
>
<AuthState kind={configurationStateKind(configuration)} />
</AuthPage>
);
}

const session = await getClerkSessionState();
if (session.status === "signed-in") {
redirect(APP_ROUTE);
}
if (session.status === "session-unavailable") {
return (
<AuthPage
title="Sign in to BridgeWorks"
description="Continue to the secure application workspace after your account is authenticated."
>
<AuthState kind="session-unavailable" />
</AuthPage>
);
}
if (session.status === "unexpected") {
return (
<AuthPage
title="Sign in to BridgeWorks"
description="Continue to the secure application workspace after your account is authenticated."
>
<AuthState kind="unexpected" />
</AuthPage>
);
}

return (
<AuthPage
title="Sign in to BridgeWorks"
description="Continue to the secure application workspace after your account is authenticated."
>
<SignIn
path={SIGN_IN_ROUTE}
routing="path"
signUpUrl={SIGN_UP_ROUTE}
fallbackRedirectUrl={APP_ROUTE}
signUpFallbackRedirectUrl={APP_ROUTE}
fallback={<AuthComponentFallback />}
/>
</AuthPage>
);
}
82 changes: 82 additions & 0 deletions src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthPage
title="Create your BridgeWorks account"
description="Create a secure account before entering the BridgeWorks application workspace."
>
<AuthState kind={configurationStateKind(configuration)} />
</AuthPage>
);
}

const session = await getClerkSessionState();
if (session.status === "signed-in") {
redirect(APP_ROUTE);
}
if (session.status === "session-unavailable") {
return (
<AuthPage
title="Create your BridgeWorks account"
description="Create a secure account before entering the BridgeWorks application workspace."
>
<AuthState kind="session-unavailable" />
</AuthPage>
);
}
if (session.status === "unexpected") {
return (
<AuthPage
title="Create your BridgeWorks account"
description="Create a secure account before entering the BridgeWorks application workspace."
>
<AuthState kind="unexpected" />
</AuthPage>
);
}

return (
<AuthPage
title="Create your BridgeWorks account"
description="Create a secure account before entering the BridgeWorks application workspace."
>
<SignUp
path={SIGN_UP_ROUTE}
routing="path"
signInUrl={SIGN_IN_ROUTE}
fallbackRedirectUrl={APP_ROUTE}
signInFallbackRedirectUrl={APP_ROUTE}
fallback={<AuthComponentFallback />}
/>
</AuthPage>
);
}
43 changes: 43 additions & 0 deletions src/app/(protected)/app/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
aria-labelledby="application-error-title"
className="max-w-xl rounded-xl border border-border bg-card p-6 shadow-sm sm:p-8"
>
<div className="flex size-11 items-center justify-center rounded-lg bg-muted text-foreground">
<AlertTriangle aria-hidden="true" className="size-5" />
</div>
<h1
id="application-error-title"
className="mt-5 text-2xl font-semibold tracking-tight"
>
The application could not finish loading
</h1>
<p className="mt-3 max-w-prose text-base leading-7 text-muted-foreground">
BridgeWorks stopped before rendering incomplete protected content. Try
the request again, or return to the public site.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button type="button" size="lg" onClick={reset}>
Try again
</Button>
<Button asChild variant="outline" size="lg">
<Link href={HOME_ROUTE}>Return to BridgeWorks</Link>
</Button>
</div>
</section>
);
}
Loading
Loading