@pithy-sh/ui-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/src/templates.ts +147 -0
  4. package/src/testing/virtualAuth.ts +12 -0
  5. package/src/testing/virtualI18n.ts +16 -0
  6. package/src/testing/virtualPayments.ts +5 -0
  7. package/src/testing/virtualTurnstile.ts +5 -0
  8. package/templates/client-env.d.ts +299 -0
  9. package/templates/index.html +14 -0
  10. package/templates/src/client.test.tsx +98 -0
  11. package/templates/src/client.tsx +51 -0
  12. package/templates/src/payments.tsx +147 -0
  13. package/templates/src/pithy-config.tsx +93 -0
  14. package/templates/src/pithy-locale.test.tsx +140 -0
  15. package/templates/src/pithy-locale.tsx +134 -0
  16. package/templates/src/pithy-screens.css +379 -0
  17. package/templates/src/router.test.tsx +63 -0
  18. package/templates/src/router.tsx +618 -0
  19. package/templates/src/routes/app/home.bare.tsx +96 -0
  20. package/templates/src/routes/app/home.tsx +41 -0
  21. package/templates/src/routes/pithy/callback.tsx +42 -0
  22. package/templates/src/routes/pithy/otp.tsx +127 -0
  23. package/templates/src/routes/pithy/paywall.tsx +160 -0
  24. package/templates/src/routes/pithy/pricing.tsx +312 -0
  25. package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
  26. package/templates/src/routes/pithy/sign-in.tsx +470 -0
  27. package/templates/src/routes/pithy/subscription.tsx +183 -0
  28. package/templates/src/session.tsx +78 -0
  29. package/templates/src/styles.css +53 -0
  30. package/templates/src/turnstile.test.tsx +119 -0
  31. package/templates/src/turnstile.tsx +105 -0
  32. package/templates/tsconfig.client.json +28 -0
  33. package/templates/tsconfig.node.json +22 -0
  34. package/templates/vite.config.ts +45 -0
@@ -0,0 +1,78 @@
1
+ import type { AuthSession, AuthUser } from "@pithy-sh/auth/src/client/api";
2
+ import { signOut as endSession, getSession as readSession } from "@pithy-sh/auth/src/client/api";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { authConfig } from "./pithy-config";
5
+ import { navigate, routeTable, screenPath } from "./router";
6
+
7
+ /**
8
+ * Session state, read from the server.
9
+ *
10
+ * COOKIE/SESSION, NOT BEARER. The SPA is same-origin with its worker, so the session rides an
11
+ * httpOnly cookie: no access token, no refresh token, nothing in localStorage or sessionStorage, and
12
+ * no rotation logic here. JavaScript cannot read the cookie, so XSS has nothing to steal.
13
+ *
14
+ * **The requests themselves are not in this file, and that is deliberate.** Pithy wrote this file once
15
+ * and may never rewrite it — it is yours now — so a `fetch` written here would freeze the base-path
16
+ * join, the cookie mode and the failure handling into your repository on the day you scaffolded. They
17
+ * live in `@pithy-sh/auth/src/client/api`, which is the one place that knows how a browser program
18
+ * asks this worker an auth question, and which upgrades with a minor release. Same-origin and CSRF are
19
+ * its problem, not this screen's.
20
+ */
21
+
22
+ /** The signed-in user, as the session route returns them. */
23
+ export type SessionUser = AuthUser;
24
+
25
+ /** A live session, or `null` when nobody is signed in. */
26
+ export type Session = AuthSession;
27
+
28
+ /** Where the auth routes are, for every call this file makes. */
29
+ const client = { basePath: authConfig.basePath };
30
+
31
+ /**
32
+ * The current session, or `null`.
33
+ *
34
+ * Never throws. A worker that could not be reached, or answered with something unreadable, reads as
35
+ * signed out here — which is a decision this file makes rather than one the package makes for it. If
36
+ * you would rather show "we couldn't check" than a sign-in form, read `result.failure` instead.
37
+ */
38
+ export async function getSession(): Promise<Session> {
39
+ if (!authConfig.enabled) return null;
40
+ const result = await readSession(client);
41
+ return result.ok ? result.value : null;
42
+ }
43
+
44
+ /** End the session server-side and return to the sign-in screen. */
45
+ export async function signOut(): Promise<void> {
46
+ await endSession(client);
47
+ // Where "the sign-in screen" is comes from the screen itself, never a literal here. Renaming its
48
+ // path is one edit and this follows it (#393).
49
+ navigate(screenPath(await routeTable(), "sign-in"));
50
+ }
51
+
52
+ /** The session as component state, plus a `refresh` for after a sign-in completes. */
53
+ export function useSession(): { session: Session; loading: boolean; refresh: () => void } {
54
+ const [current, setCurrent] = useState<Session>(null);
55
+ const [loading, setLoading] = useState(true);
56
+ // Guards against setting state after unmount. A ref rather than a local, because `refresh` is
57
+ // callable from an event handler long after the effect that created it has been cleaned up.
58
+ const live = useRef(true);
59
+
60
+ const refresh = useCallback(() => {
61
+ setLoading(true);
62
+ void getSession().then((value) => {
63
+ if (!live.current) return;
64
+ setCurrent(value);
65
+ setLoading(false);
66
+ });
67
+ }, []);
68
+
69
+ useEffect(() => {
70
+ live.current = true;
71
+ refresh();
72
+ return () => {
73
+ live.current = false;
74
+ };
75
+ }, [refresh]);
76
+
77
+ return { session: current, loading, refresh };
78
+ }
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Your stylesheet. Pithy writes it once, when there is none, and never touches it again.
3
+ *
4
+ * The classes Pithy's own screens render are NOT here — they live in `pithy-screens.css`, which those
5
+ * screens import themselves. So you can replace everything below without unstyling the sign-in screen,
6
+ * and a project that adds the auth screens years later gets them styled without this file being
7
+ * touched.
8
+ *
9
+ * The custom properties are the seam between the two. `pithy-screens.css` reads each of them with a
10
+ * fallback, so editing a value here restyles Pithy's screens along with your own, and deleting one
11
+ * leaves them standing.
12
+ *
13
+ * Declare them as a set, or declare none. Half a set is the one arrangement no fallback can catch —
14
+ * your background under Pithy's text — and `pithy-screens.test.tsx` beside this file is what notices.
15
+ */
16
+
17
+ :root {
18
+ --bg: #fafaf6;
19
+ --surface: #ffffff;
20
+ --fg: #111111;
21
+ --fg-muted: #5f5d57;
22
+ --border: #e5e3db;
23
+ --accent: #d4a017;
24
+ --danger: #b3261e;
25
+ color-scheme: light dark;
26
+ }
27
+
28
+ @media (prefers-color-scheme: dark) {
29
+ :root {
30
+ --bg: #111111;
31
+ --surface: #1a1a19;
32
+ --fg: #fafaf6;
33
+ --fg-muted: #9a988f;
34
+ --border: #2e2e2b;
35
+ --danger: #f2b8b5;
36
+ }
37
+ }
38
+
39
+ * {
40
+ box-sizing: border-box;
41
+ }
42
+
43
+ body {
44
+ margin: 0;
45
+ background: var(--bg);
46
+ color: var(--fg);
47
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
48
+ line-height: 1.5;
49
+ }
50
+
51
+ a {
52
+ color: var(--accent);
53
+ }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, expect, test, vi } from "vitest";
6
+
7
+ /**
8
+ * **The widget is solved for the action the projection names, and states none of its own.**
9
+ *
10
+ * Turnstile bakes an action label into the token when the widget renders, and siteverify echoes it
11
+ * back; the sign-in route asserts it. One contract, two ends. `src/turnstile.tsx` is yours to edit,
12
+ * and the one edit it must never take is retyping that label as a literal.
13
+ *
14
+ * ## Why nothing you run before production can catch the two drifting apart
15
+ *
16
+ * `pithy turnstile provision` wires Cloudflare's documented **always-pass test secret** into dev and
17
+ * staging, and that secret's siteverify answer carries **no `action` field at all**. The gate accepts
18
+ * exactly that answer in exactly those environments. So in every environment you develop or test in,
19
+ * there is no action coming back to compare, and two strings that disagree behave precisely like two
20
+ * that agree.
21
+ *
22
+ * The first environment that can tell is production. There a real widget echoes what it was solved
23
+ * for, and a mismatch refuses **every** sign-in with a 403 that truthfully says the challenge failed
24
+ * — which sends whoever is paged to the user, the sitekey and the secret, in that order, and to the
25
+ * mismatch last. That is the whole reason this file exists rather than a comment asking nicely.
26
+ *
27
+ * ## What this proves, and how it goes red
28
+ *
29
+ * The projection is mocked with an action that is **deliberately not** the real one. A widget that
30
+ * read a literal of its own would render that literal, and this goes red; only a widget that renders
31
+ * what it was handed can pass. The expected value is invented here and reachable from nowhere else,
32
+ * which is what a canary is for — asserting the real action would pass against the very bug this
33
+ * catches.
34
+ *
35
+ * This is the widget half. That the *route* asserts the action the projection carries is the kit's
36
+ * own gate, in `@pithy-sh/auth`. Neither is sufficient alone: this one says the widget carries what
37
+ * it is told, that one says the server expects what is told.
38
+ */
39
+
40
+ /**
41
+ * The action the mocked projection carries. Not the real one, on purpose: it is the difference
42
+ * between proving the widget *reads* the projection and proving only that two literals happen to
43
+ * match.
44
+ */
45
+ const CANARY_ACTION = "pithy-gate-canary-not-a-real-action";
46
+
47
+ /** A sitekey to match, so a mock that never took effect fails as loudly as a widget that ignored it. */
48
+ const CANARY_SITEKEY = "0xCANARY";
49
+
50
+ /**
51
+ * The projection, stubbed at the module the widget reads it through.
52
+ *
53
+ * `src/pithy-config.tsx` is mocked rather than `virtual:pithy/turnstile` because the virtual modules
54
+ * are served by `@pithy-sh/vite` during a build and resolve nowhere else — mocking the config module
55
+ * keeps this gate running under the plain `vitest run` a scaffolded project already has.
56
+ */
57
+ vi.mock("./pithy-config", () => ({
58
+ turnstileConfig: {
59
+ enabled: true,
60
+ sitekey: CANARY_SITEKEY,
61
+ mode: "visible",
62
+ action: CANARY_ACTION,
63
+ token: { field: "cf-turnstile-response", header: null },
64
+ },
65
+ }));
66
+
67
+ // React refuses to run `act` unless the environment says it is a test one.
68
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
69
+
70
+ /** The options the component handed `turnstile.render`, or null if it never rendered a widget. */
71
+ let rendered: { sitekey: string; action: string } | null = null;
72
+
73
+ beforeEach(() => {
74
+ rendered = null;
75
+ // The component loads Cloudflare's script by appending it to `<head>` and awaiting `onload`. Nothing
76
+ // in a test environment can serve it, so the append is intercepted: no element is added, no request
77
+ // is made, and the handler the component just assigned is called as if the script had arrived.
78
+ vi.spyOn(document.head, "appendChild").mockImplementation(<T extends Node>(node: T): T => {
79
+ queueMicrotask(() => {
80
+ (node as unknown as HTMLScriptElement).onload?.(new Event("load"));
81
+ });
82
+ return node;
83
+ });
84
+ window.turnstile = {
85
+ render: (_element, options) => {
86
+ rendered = { sitekey: options.sitekey, action: options.action };
87
+ return "widget-id";
88
+ },
89
+ };
90
+ });
91
+
92
+ afterEach(() => {
93
+ vi.restoreAllMocks();
94
+ window.turnstile = undefined;
95
+ });
96
+
97
+ test("the widget is solved for the action the projection carries, not one of its own", async () => {
98
+ // The one place the kit's default action may appear: as the value the expectation must NOT be. A
99
+ // canary that drifted onto a real action would pass against the drift it exists to catch.
100
+ expect(CANARY_ACTION).not.toBe("login");
101
+
102
+ // Imported inside the case so the mocked config module is in place before the widget's module scope
103
+ // reads it.
104
+ const { Turnstile } = await import("./turnstile");
105
+ const host = document.createElement("div");
106
+ document.body.appendChild(host);
107
+
108
+ await act(async () => {
109
+ createRoot(host).render(<Turnstile onToken={() => {}} />);
110
+ });
111
+ // One more turn for the script promise's continuation, which is where `render` is called.
112
+ await act(async () => {
113
+ await Promise.resolve();
114
+ });
115
+
116
+ expect(rendered, "the widget never rendered — the config mock or the script stub did not take").not.toBeNull();
117
+ expect(rendered?.sitekey).toBe(CANARY_SITEKEY);
118
+ expect(rendered?.action).toBe(CANARY_ACTION);
119
+ });
@@ -0,0 +1,105 @@
1
+ import { type ReactNode, useEffect, useRef } from "react";
2
+ import { turnstileConfig } from "./pithy-config";
3
+
4
+ const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
5
+
6
+ interface TurnstileApi {
7
+ render: (
8
+ element: HTMLElement,
9
+ options: {
10
+ sitekey: string;
11
+ action: string;
12
+ appearance: "always" | "execute";
13
+ /** The widget is a cross-origin iframe we cannot style, so its palette is an argument, not CSS. */
14
+ theme: "light" | "dark" | "auto";
15
+ /**
16
+ * Its width is an argument too, and this is the half that is easy to miss. `normal` is a fixed
17
+ * 300px box — which is why a widget styled `width: 100%` still sat narrower than the email field
18
+ * beside it. `flexible` fills its container instead, with a 300px floor `pithy-screens.css`
19
+ * keeps the form's column above.
20
+ */
21
+ size: "normal" | "flexible" | "compact";
22
+ callback: (value: string) => void;
23
+ "expired-callback": () => void;
24
+ },
25
+ ) => string;
26
+ }
27
+
28
+ declare global {
29
+ interface Window {
30
+ turnstile?: TurnstileApi;
31
+ }
32
+ }
33
+
34
+ let script: Promise<void> | undefined;
35
+
36
+ function loadScript(): Promise<void> {
37
+ script ??= new Promise<void>((resolve, reject) => {
38
+ const element = document.createElement("script");
39
+ element.src = SCRIPT_SRC;
40
+ element.async = true;
41
+ element.onload = () => resolve();
42
+ element.onerror = () => reject(new Error("Turnstile failed to load."));
43
+ document.head.appendChild(element);
44
+ });
45
+ return script;
46
+ }
47
+
48
+ /**
49
+ * Where the response token goes on a gated request. The server reads `token.header` when one is
50
+ * configured and `token.field` in the body otherwise — this returns both halves already decided.
51
+ */
52
+ export function turnstileRequest(
53
+ body: Record<string, unknown>,
54
+ value: string | null,
55
+ ): { body: Record<string, unknown>; headers: Record<string, string> } {
56
+ if (!turnstileConfig.enabled || !value) return { body, headers: {} };
57
+ if (turnstileConfig.token.header) return { body, headers: { [turnstileConfig.token.header]: value } };
58
+ return { body: { ...body, [turnstileConfig.token.field]: value }, headers: {} };
59
+ }
60
+
61
+ /** True when a gated form still needs a token before it can submit. */
62
+ export function turnstilePending(value: string | null): boolean {
63
+ return turnstileConfig.enabled && !value;
64
+ }
65
+
66
+ /** The widget. Renders nothing at all when the capability is not composed. */
67
+ export function Turnstile(props: { onToken: (value: string | null) => void }): ReactNode {
68
+ const host = useRef<HTMLDivElement>(null);
69
+ const onToken = props.onToken;
70
+
71
+ useEffect(() => {
72
+ if (!turnstileConfig.enabled) return;
73
+ let live = true;
74
+ void loadScript()
75
+ .then(() => {
76
+ if (!live || !host.current || !window.turnstile) return;
77
+ window.turnstile.render(host.current, {
78
+ sitekey: turnstileConfig.sitekey,
79
+ // The action comes from the projection, and writing it out here again would be a bug nothing
80
+ // short of production could see: the server asserts this exact string against the token, and
81
+ // dev and staging run Cloudflare test keys, whose answer carries no action to compare. A
82
+ // drifted copy is silent everywhere until it refuses every sign-in in prod. #377.
83
+ action: turnstileConfig.action,
84
+ appearance: turnstileConfig.mode === "invisible" ? "execute" : "always",
85
+ // `auto` is Turnstile's own name for `prefers-color-scheme`, which is what `pithy-screens.css`
86
+ // answers too. The widget is a cross-origin iframe, so this argument is the only lever — and
87
+ // it resolves the same question from the same source, with nothing to keep in step.
88
+ theme: "auto",
89
+ // Fill the column, so the check matches the field and the button above and below it. The host
90
+ // element supplies the width; see `.auth__check` in pithy-screens.css. Both halves are
91
+ // required — either one alone leaves a ragged edge beside a full-width input.
92
+ size: "flexible",
93
+ callback: (value) => onToken(value),
94
+ "expired-callback": () => onToken(null),
95
+ });
96
+ })
97
+ .catch(() => onToken(null));
98
+ return () => {
99
+ live = false;
100
+ };
101
+ }, [onToken]);
102
+
103
+ if (!turnstileConfig.enabled) return null;
104
+ return <div ref={host} />;
105
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ // The CLIENT program. Deliberately separate from tsconfig.json (the Worker's): the client needs JSX
3
+ // and the DOM, the Worker needs the Workers globals, and nothing may import across the two.
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "Bundler",
9
+ "moduleDetection": "force",
10
+ "jsx": "react-jsx",
11
+ "verbatimModuleSyntax": true,
12
+ "isolatedModules": true,
13
+ "esModuleInterop": true,
14
+ "strict": true,
15
+ "noImplicitOverride": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "skipLibCheck": true,
18
+ "types": ["vite/client"],
19
+ // Referenced from the project's root tsconfig.json, which requires `composite` — and `composite`
20
+ // makes tsc write build state. It goes under the PROJECT's `dist/`, already gitignored, never under
21
+ // this Worker's: Vite owns `apps/<worker>/dist` and empties it on every build, which would throw the
22
+ // incremental state away each time.
23
+ "composite": true,
24
+ "tsBuildInfoFile": "../../dist/__PITHY_WORKER__.client.tsbuildinfo",
25
+ "noEmit": true
26
+ },
27
+ "include": ["src/**/*.tsx", "client-env.d.ts"]
28
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ // The BUILD program: vite.config.ts runs on Node.
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "moduleDetection": "force",
9
+ "verbatimModuleSyntax": true,
10
+ "isolatedModules": true,
11
+ "esModuleInterop": true,
12
+ "strict": true,
13
+ "skipLibCheck": true,
14
+ "types": ["node"],
15
+ // Referenced from the project's root tsconfig.json — see tsconfig.client.json for why the build
16
+ // state lands in the project's `dist/` and not this Worker's.
17
+ "composite": true,
18
+ "tsBuildInfoFile": "../../dist/__PITHY_WORKER__.node.tsbuildinfo",
19
+ "noEmit": true
20
+ },
21
+ "include": ["vite.config.ts"]
22
+ }
@@ -0,0 +1,45 @@
1
+ import { cloudflare } from "@cloudflare/vite-plugin";
2
+ import { devWorkerConfig } from "@pithy-sh/vite/src/devOrigin";
3
+ import { pithy } from "@pithy-sh/vite/src/plugin";
4
+ import react from "@vitejs/plugin-react";
5
+ import { defineConfig } from "vite";
6
+
7
+ export default defineConfig({
8
+ /**
9
+ * One React, however many checkouts the packages live in.
10
+ *
11
+ * Vite resolves a symlinked package from its realpath, so a package linked in from somewhere else —
12
+ * the Pithy kit, a design system, any workspace you point at by path — imports `react` out of *its*
13
+ * tree rather than out of this Worker's. Two copies of React is `invalid hook call` on the first
14
+ * component that package renders, and the stack blames the component rather than the resolution.
15
+ *
16
+ * `dedupe` resolves both names from Vite's root no matter who asked, and the root here is this
17
+ * Worker's own directory, where `react` is a dependency and there is therefore something to resolve.
18
+ * That is not true one level up: the project's `vitest.config.ts` is rooted at the repository, which
19
+ * has no React, so it states the same rule as an explicit alias instead. Read the note there before
20
+ * moving either.
21
+ *
22
+ * It is not a workaround for a symlink. It is what every linked-package setup needs, it costs nothing
23
+ * when nothing is linked, and it goes on costing nothing the day `@pithy-sh/*` is published.
24
+ */
25
+ resolve: { dedupe: ["react", "react-dom"] },
26
+ plugins: [
27
+ react(),
28
+ cloudflare({
29
+ // `BASE_URL` from the port block `pithy dev` allocated *this checkout*, overriding the one in
30
+ // wrangler.jsonc while dev is running. A dev port is allocated rather than configured, so a
31
+ // literal there is right in the first checkout on a machine and wrong in every other one — and
32
+ // `BASE_URL` is the `iss` on every control-plane token this Worker signs and the origin its
33
+ // callback links are built against. Outside `pithy dev` it does nothing and the declared value
34
+ // stands, which is what a deployed environment wants. See `devWorkerConfig`.
35
+ config: devWorkerConfig(),
36
+ // Local state lives at the PROJECT root, not here — the same store pithy dev, migrate, and seed
37
+ // use. Per-worker state would silently give two workers separate copies of a shared database.
38
+ persistState: { path: "../../.wrangler/state" },
39
+ // Pinned off. The inspector defaults to 9229 and silently advances on a collision, so two
40
+ // UI-bearing workers under one pithy dev would drift onto ports nobody assigned them.
41
+ inspectorPort: false,
42
+ }),
43
+ pithy(),
44
+ ],
45
+ });