→ Template
Minimal Access Template
The leanest restricted-access screen — a single masked field, a small plain card, no default footer copy.
Best used for A quick, no-frills gate for an internal tool where tone doesn't matter.
For when a segmented code doesn't fit the tone of what you're gating — one field, one button, done. No footer text unless you pass your own.
- 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 MinimalAccessTemplateLabels extends KnockCodesLabels {
description?: string;
supportLabel?: string;
footerText?: ReactNode;
}
export interface MinimalAccessTemplateProps extends KnockCodesConfig {
children: ReactNode;
/** Rendered above the heading — your own logo/wordmark. Omitted entirely if not passed. */
logo?: ReactNode;
labels?: MinimalAccessTemplateLabels;
/** 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<MinimalAccessTemplateLabels> = {
...DEFAULT_LABELS,
heading: "Enter access code",
description: "This page is private.",
supportLabel: "Contact support",
footerText: "",
};
/**
* The leanest possible restricted-access screen — a single masked field, a
* small plain card, no segmented boxes and no default footer copy. Same
* `useKnockCodes` contract as every other block, just the smallest possible
* presentation. For segmented code entry, use `<KnockCodesTemplate>` instead.
*/
export function MinimalAccessTemplate({
children,
logo,
labels,
supportHref,
onContactSupport,
fullPage = true,
theme,
className,
...config
}: MinimalAccessTemplateProps) {
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(
"flex w-full items-center justify-center bg-gray-50 p-6 dark:bg-[#0b1220]",
fullPage ? "min-h-[100dvh]" : "h-full",
className
)}
>
<div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-7 dark:border-gray-800 dark:bg-gray-950">
{logo && <div className="mb-5">{logo}</div>}
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{merged.heading}</h1>
{merged.description && <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{merged.description}</p>}
<div className="mt-5">
<label htmlFor="minimal-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="minimal-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-10 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-3 w-full rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-200"
>
{state === "submitting" ? merged.submittingLabel : merged.submitLabel}
</button>
{(supportHref || onContactSupport) && (
<div className="mt-3 text-center">
{onContactSupport ? (
<button type="button" onClick={onContactSupport} className="text-xs font-medium text-gray-500 hover:underline dark:text-gray-400">
{merged.supportLabel}
</button>
) : (
<a href={supportHref} className="text-xs font-medium text-gray-500 hover:underline dark:text-gray-400">
{merged.supportLabel}
</a>
)}
</div>
)}
{merged.footerText && <p className="mt-4 text-center text-xs text-gray-400 dark:text-gray-500">{merged.footerText}</p>}
</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/minimal-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/MinimalAccessTemplate.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 above the heading — your own logo/wordmark. Omitted entirely if not passed. |
| 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 | MinimalAccessTemplateLabels | — | 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 full-page backdrop. |
Accessibility
The code field has its own label and an accessible show/hide toggle (aria-label switches between "Show code"/"Hide code"). Errors and the submitting state announce through a shared aria-live="polite" region, same contract as every other block.
Customization
Everything is one file — background, card, heading, description, field, button, support link, and footer are all inline, not composed from Gate Wrapper/PIN Input. Pass `logo` for your own wordmark and `labels` to override every string. For segmented code entry instead of a single field, use Knock Codes Template instead.
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.