→ Template
Branded Access Template
A split-screen, logo-forward restricted-access screen — brand panel on one side, code entry on the other.
Best used for A private beta or soft-launch landing page where the access screen is also the first impression.
The one to reach for when the access screen is also the first real impression of your product — a soft-launch landing page with a lock on it, not a bare form.
- components
- knock-codes
- core
- react
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useKnockCodes } from "./useKnockCodes.ts";
import { DEFAULT_LABELS, type KnockCodesConfig, type KnockCodesLabels } from "./types.ts";
import { cx } from "./cx.ts";
export interface BrandedAccessTemplateLabels extends KnockCodesLabels {
description?: string;
supportLabel?: string;
footerText?: ReactNode;
}
export interface BrandedAccessTemplateProps extends KnockCodesConfig {
children: ReactNode;
/** Rendered on the brand panel, above the tagline — your own logo/wordmark. */
logo?: ReactNode;
/** Short line under the logo on the brand panel. */
tagline?: ReactNode;
labels?: BrandedAccessTemplateLabels;
/** Renders "Contact support" as a link. Ignored if `onContactSupport` is also set. */
supportHref?: string;
/** Renders "Contact support" as a button instead of a link. */
onContactSupport?: () => void;
/** Set false to embed this somewhere other than a real page root. @default true */
fullPage?: boolean;
/** Forces light or dark presentation, independent of any ancestor ".dark" class. Omit to follow one if it exists. */
theme?: "light" | "dark";
className?: string;
}
const TEMPLATE_LABELS: Required<BrandedAccessTemplateLabels> = {
...DEFAULT_LABELS,
heading: "Welcome back",
description: "Enter your access code to continue to your workspace.",
submitLabel: "Continue",
supportLabel: "Contact support",
footerText: "Don't have a code? Contact your administrator or support team for assistance.",
};
/**
* A split-screen, logo-forward restricted-access screen — brand panel on
* one side, code entry on the other. The brand panel collapses on small
* screens so the form stays the only thing visible on mobile. Same
* `useKnockCodes` contract as every other block.
*/
export function BrandedAccessTemplate({
children,
logo,
tagline,
labels,
supportHref,
onContactSupport,
fullPage = true,
theme,
className,
...config
}: BrandedAccessTemplateProps) {
const merged = { ...TEMPLATE_LABELS, ...labels };
const { state, error, submit } = useKnockCodes(config);
const [code, setCode] = useState("");
const [revealed, setRevealed] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (state === "idle" && error) inputRef.current?.focus();
}, [error, state]);
if (state === "unlocked") return <>{children}</>;
const handleSubmit = async () => {
if (!code || state === "submitting") return;
await submit(code);
setCode("");
};
const errorMessage = error ? (error.reason === "network" ? merged.networkErrorMessage : merged.invalidErrorMessage) : null;
const content = (
<div className={cx("grid w-full lg:grid-cols-2", fullPage ? "min-h-[100dvh]" : "h-full", className)}>
<div className="hidden flex-col justify-center bg-gradient-to-br from-blue-600 to-indigo-800 p-12 text-white lg:flex">
{logo && <div className="mb-6">{logo}</div>}
{tagline && <p className="max-w-sm text-lg text-white/80">{tagline}</p>}
</div>
<div className="flex items-center justify-center bg-white p-6 dark:bg-gray-950 lg:p-12">
<div className="w-full max-w-sm">
{logo && <div className="mb-6 lg:hidden">{logo}</div>}
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{merged.heading}</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{merged.description}</p>
<div className="mt-6">
<label htmlFor="branded-access-code" className="mb-1.5 block text-xs font-medium text-gray-500 dark:text-gray-400">
{merged.inputLabel}
</label>
<div className="relative">
<input
ref={inputRef}
id="branded-access-code"
type={revealed ? "text" : "password"}
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
placeholder={merged.placeholder}
autoComplete="off"
autoFocus
disabled={state === "submitting"}
aria-invalid={error ? true : undefined}
className="h-11 w-full rounded-lg border border-gray-300 px-3 pr-16 text-sm text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 focus:outline-none disabled:opacity-60 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-50"
/>
<button
type="button"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? merged.hideCodeLabel : merged.showCodeLabel}
className="absolute top-1/2 right-3 -translate-y-1/2 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{revealed ? merged.hideCodeLabel : merged.showCodeLabel}
</button>
</div>
<div role="status" aria-live="polite" className="mt-2 min-h-[1.1rem] text-xs text-red-600 dark:text-red-400">
{state === "submitting" ? merged.submittingLabel : (errorMessage ?? "")}
</div>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={!code || state === "submitting"}
className="mt-4 flex w-full items-center justify-center gap-2 rounded-full bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === "submitting" ? merged.submittingLabel : merged.submitLabel}
</button>
{(supportHref || onContactSupport) && (
<div className="mt-3 text-center">
{onContactSupport ? (
<button type="button" onClick={onContactSupport} className="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400">
{merged.supportLabel}
</button>
) : (
<a href={supportHref} className="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400">
{merged.supportLabel}
</a>
)}
</div>
)}
<p className="mt-6 text-center text-xs text-gray-400 dark:text-gray-500">{merged.footerText}</p>
</div>
</div>
</div>
);
return theme === "dark" ? <div className="dark h-full w-full">{content}</div> : content;
}
Generate a hash
Fully client-side — the plaintext code never leaves this page, and never touches a file. Only the hash below gets pasted into your project.
Add this template to your project
CLI
npx shadcn@latest add http://localhost:3000/r/react/branded-access-template.jsonRunning this site somewhere other than localhost? Set NEXT_PUBLIC_SITE_URL and this command switches to that host automatically.
Also installs
- Knock Codes Core
- Knock Codes Types
- useKnockCodes
- cx (classname helper)
Files created (8)
- components/knock-codes/core/hash.ts
- components/knock-codes/core/verify.ts
- components/knock-codes/core/session.ts
- components/knock-codes/core/storage.ts
- components/knock-codes/react/types.ts
- components/knock-codes/react/useKnockCodes.ts
- components/knock-codes/react/cx.ts
- components/knock-codes/react/BrandedAccessTemplate.tsx
No CLI? Copy the files by hand
- Open the Code tab in the preview above.
- Create each path listed below in your project and paste its contents in.
- Do the same for anything listed under “Also installs”, if present.
Installing via an AI agent?
Drop AGENTS.md into your project root — it instructs any coding agent to hash the code locally, write only the hash, and confirm the plaintext never touched a file. Thin pointer files exist for tools that read a different filename.
→ Props
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| expectedHash | string | — | SHA-256 hex hash to verify against, for local mode. |
| verify | VerifyFn | — | Custom async verification function, for server mode. |
| children * | ReactNode | — | Rendered once unlocked. |
| logo | ReactNode | — | Rendered on the brand panel (and, on mobile, above the form) — your own logo/wordmark. |
| tagline | ReactNode | — | Short line under the logo on the brand panel. Hidden along with the rest of that panel below the lg breakpoint. |
| supportHref | string | — | Renders "Contact support" as a link to this URL. |
| onContactSupport | () => void | — | Renders "Contact support" as a button instead of a link — e.g. to open a chat widget. |
| labels | BrandedAccessTemplateLabels | — | Overrides heading, description, input label, support label, footer text, and every KnockCodesLabels string. |
| fullPage | boolean | true | Set false to embed this somewhere other than a real page root (a demo, a docs preview) — drops the full-viewport (100dvh) sizing. |
| theme | "light" | "dark" | — | Forces light or dark presentation on its own, independent of any ancestor ".dark" class. Omit to follow the nearest ".dark" ancestor if one happens to exist. |
| className | string | — | Extra classes on the outer grid. |
Accessibility
The code field has its own label and an accessible show/hide toggle. The brand panel is decorative and collapses out of the DOM's visual flow on small screens rather than stacking above the form, so mobile visitors reach the field without scrolling. Errors and the submitting state announce through a shared aria-live="polite" region, same contract as every other block.
Customization
Everything is one file — brand panel, form panel, heading, description, field, button, support link, and footer are all inline. Pass `logo` and `tagline` for the brand panel, and `labels` to override every string. The brand panel is hidden below the `lg` breakpoint so mobile visitors see only the form.
The honest version
Knock Codes stops casual visitors, search engines, and forwarded links. Local mode does not stop anyone who opens DevTools — the hash ships in your client bundle by design. Server mode (swap one prop) stops determined visitors. This is a velvet rope, with an optional real lock — never marketed as more than that.
Need real protection?
Local mode is deterrence — the hash ships in your client bundle by design. Swap the expectedHash prop for a verify function pointing at one of these, and the code is checked server-side instead — same markup, same component, one prop different. Each template rate-limits attempts and returns a short-lived signed token on success.
// @ts-check
/**
* Knock Codes — Next.js route handler server verification template.
* Drop at app/api/verify-access/route.js (App Router).
*
* Set two env vars: KNOCK_CODES_SERVER_HASH, KNOCK_CODES_TOKEN_SECRET.
*
* Wire contract identical across all three server templates — see the
* Cloudflare Worker template in this same folder for the full contract.
*/
import { createHash, createHmac } from "node:crypto";
const RATE_LIMIT_MAX_ATTEMPTS = 5; // adjust per deployment
const RATE_LIMIT_WINDOW_SECONDS = 60;
const TOKEN_TTL_MS = 5 * 60_000; // 5 minutes
// In-memory counter — acceptable ONLY for a single-instance/dev deployment.
// Vercel and most production hosts run multiple instances, so this counter
// would not be shared across them; swap in Redis or Vercel KV (a single
// shared store, same key scheme below) before deploying for real.
/** @type {Map<string, number>} */
const attemptsByKey = new Map();
/** @param {string} input */
function sha256Hex(input) {
return createHash("sha256").update(input, "utf8").digest("hex");
}
/** @param {string} secret */
function signToken(secret) {
const payload = Buffer.from(JSON.stringify({ exp: Date.now() + TOKEN_TTL_MS })).toString("base64url");
const signature = createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${signature}`;
}
/** @param {string} identifier */
function checkRateLimit(identifier) {
const windowStart = Math.floor(Date.now() / 1000 / RATE_LIMIT_WINDOW_SECONDS);
const key = `${identifier}:${windowStart}`;
const attempts = attemptsByKey.get(key) ?? 0;
if (attempts >= RATE_LIMIT_MAX_ATTEMPTS) return false;
attemptsByKey.set(key, attempts + 1);
return true;
}
/** @param {Request} request */
export async function POST(request) {
const identifier = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!checkRateLimit(identifier)) {
return Response.json({ ok: false, reason: "network" }, { status: 429 });
}
/** @type {unknown} */
let code;
try {
({ code } = /** @type {{ code?: unknown }} */ (await request.json()));
} catch {
code = undefined;
}
if (typeof code !== "string") {
return Response.json({ ok: false, reason: "network" }, { status: 500 });
}
if (sha256Hex(code) !== process.env.KNOCK_CODES_SERVER_HASH) {
return Response.json({ ok: false, reason: "invalid" });
}
return Response.json({ ok: true, token: signToken(/** @type {string} */ (process.env.KNOCK_CODES_TOKEN_SECRET)) });
}→ Compose with
Blocks this template is built from
Drop down to these directly once you need more control than the single-file template gives you.
- Knock CodesThe main gate — renders children when unlocked, otherwise an access-code prompt.
- PIN InputMasked access-code input (PIN or passphrase) with paste support and accessible loading/error states.
- Protected LayoutKnock Codes shaped for a full-page shell — header/footer stay visible even while locked.