nerdsystem 0.0.3 → 0.0.4

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.
@@ -104,4 +104,4 @@ export async function init(dir, args) {
104
104
  log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
105
105
  }
106
106
  // Kept in sync with packages/sdk/package.json by the release script.
107
- const SDK_VERSION = "0.0.3";
107
+ const SDK_VERSION = "0.0.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerdsystem",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "nerd: deploy apps to nerdsystem from the terminal or a coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ If this project still has the template's todo app (the `todos` table and `src/se
13
13
 
14
14
  1. Ask one question: what should the app do, in a sentence or two. Do not ask about stacks, databases, or hosting; those are decided.
15
15
  2. Say what you will build first, in plain words: the one table and the one screen that make the idea real. Keep the first feature small enough to deploy within the session.
16
- 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. Keep signup and login as they are; they already work.
16
+ 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. The template is a normal website: a public homepage at `/`, auth pages at `/login`, `/signup`, `/forgot-password`, `/reset-password`, and a signed-in area. Reshape it to the idea (a public landing page with sign-in in the header, a members-only area, an admin section) rather than forcing a login wall; the forms already work.
17
17
  4. Run `nerd check`, then `nerd dev`, and tell the person to open the local URL, create an account, and try it. Fix what they report.
18
18
  5. Run `nerd deploy` and give them the live URL. Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd verify` any time.
19
19
  6. Offer the next steps once, briefly: link the GitHub repo for previews on pull requests (`nerd github connect`), add a custom domain, or invite a teammate from the dashboard.
@@ -47,12 +47,13 @@ Once the repo is linked (`nerd github connect`, then `nerd github link owner/rep
47
47
 
48
48
  ## Auth and authorization
49
49
 
50
- The SDK handles sessions. There is no login code to write.
50
+ The SDK handles sessions. There is no server-side login code to write; the client only decides where the forms appear.
51
51
 
52
52
  - `c.var.user` is `{ id, email, name, roles }` or `null` on every request.
53
53
  - `requireAuth()` middleware returns 401 when signed out.
54
54
  - `requireRole("admin")` returns 403 unless the user has one of the given roles. Multiple roles are OR.
55
- - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them. Password reset emails link to `/reset-password?token=...`, which the template client renders; keep that route if you restructure the client.
55
+ - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them, and `src/client/auth.tsx` turns them into drop-in components: `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm`, plus `useUser()` for the current user anywhere in the tree and `RequireUser` to guard a screen (it sends signed-out visitors to `/login?next=...`). Put the forms wherever the design wants: their own pages, a modal, inline on the homepage. Password reset emails link to `/reset-password?token=...`; keep that route if you restructure the client.
56
+ - Pages are public by default because they are static files. Guarding a screen with `RequireUser` is a courtesy to the visitor; the data is protected by `requireAuth()` / `requireRole()` on the API routes, which is what the platform checks.
56
57
  - Roles are a list the platform owns. See the current list with `nerd auth config`. Add one with `nerd auth config --roles admin,member,editor`. The default role for new signups is set with `--default-role`. Turn public signup off with `--signup off`.
57
58
  - To promote a user from code, use the admin client: `new AuthServiceClient(c.env).users.setRoles(userId, ["admin"])`. Guard that route with `requireRole("admin")`.
58
59
 
@@ -2,7 +2,7 @@
2
2
  description: How to build, run, and deploy this app on nerdsystem. Applies to server, database, auth, and deploy work.
3
3
  alwaysApply: true
4
4
  ---
5
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (2834c4c81f7f); edit the skill, then run: nerd agents sync -->
5
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (ca70e3512072); edit the skill, then run: nerd agents sync -->
6
6
 
7
7
  # Building on nerdsystem
8
8
 
@@ -14,7 +14,7 @@ If this project still has the template's todo app (the `todos` table and `src/se
14
14
 
15
15
  1. Ask one question: what should the app do, in a sentence or two. Do not ask about stacks, databases, or hosting; those are decided.
16
16
  2. Say what you will build first, in plain words: the one table and the one screen that make the idea real. Keep the first feature small enough to deploy within the session.
17
- 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. Keep signup and login as they are; they already work.
17
+ 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. The template is a normal website: a public homepage at `/`, auth pages at `/login`, `/signup`, `/forgot-password`, `/reset-password`, and a signed-in area. Reshape it to the idea (a public landing page with sign-in in the header, a members-only area, an admin section) rather than forcing a login wall; the forms already work.
18
18
  4. Run `nerd check`, then `nerd dev`, and tell the person to open the local URL, create an account, and try it. Fix what they report.
19
19
  5. Run `nerd deploy` and give them the live URL. Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd verify` any time.
20
20
  6. Offer the next steps once, briefly: link the GitHub repo for previews on pull requests (`nerd github connect`), add a custom domain, or invite a teammate from the dashboard.
@@ -48,12 +48,13 @@ Once the repo is linked (`nerd github connect`, then `nerd github link owner/rep
48
48
 
49
49
  ## Auth and authorization
50
50
 
51
- The SDK handles sessions. There is no login code to write.
51
+ The SDK handles sessions. There is no server-side login code to write; the client only decides where the forms appear.
52
52
 
53
53
  - `c.var.user` is `{ id, email, name, roles }` or `null` on every request.
54
54
  - `requireAuth()` middleware returns 401 when signed out.
55
55
  - `requireRole("admin")` returns 403 unless the user has one of the given roles. Multiple roles are OR.
56
- - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them. Password reset emails link to `/reset-password?token=...`, which the template client renders; keep that route if you restructure the client.
56
+ - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them, and `src/client/auth.tsx` turns them into drop-in components: `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm`, plus `useUser()` for the current user anywhere in the tree and `RequireUser` to guard a screen (it sends signed-out visitors to `/login?next=...`). Put the forms wherever the design wants: their own pages, a modal, inline on the homepage. Password reset emails link to `/reset-password?token=...`; keep that route if you restructure the client.
57
+ - Pages are public by default because they are static files. Guarding a screen with `RequireUser` is a courtesy to the visitor; the data is protected by `requireAuth()` / `requireRole()` on the API routes, which is what the platform checks.
57
58
  - Roles are a list the platform owns. See the current list with `nerd auth config`. Add one with `nerd auth config --roles admin,member,editor`. The default role for new signups is set with `--default-role`. Turn public signup off with `--signup off`.
58
59
  - To promote a user from code, use the admin client: `new AuthServiceClient(c.env).users.setRoles(userId, ["admin"])`. Guard that route with `requireRole("admin")`.
59
60
 
@@ -1,4 +1,4 @@
1
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (2834c4c81f7f); edit the skill, then run: nerd agents sync -->
1
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (ca70e3512072); edit the skill, then run: nerd agents sync -->
2
2
 
3
3
  # Building on nerdsystem
4
4
 
@@ -10,7 +10,7 @@ If this project still has the template's todo app (the `todos` table and `src/se
10
10
 
11
11
  1. Ask one question: what should the app do, in a sentence or two. Do not ask about stacks, databases, or hosting; those are decided.
12
12
  2. Say what you will build first, in plain words: the one table and the one screen that make the idea real. Keep the first feature small enough to deploy within the session.
13
- 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. Keep signup and login as they are; they already work.
13
+ 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. The template is a normal website: a public homepage at `/`, auth pages at `/login`, `/signup`, `/forgot-password`, `/reset-password`, and a signed-in area. Reshape it to the idea (a public landing page with sign-in in the header, a members-only area, an admin section) rather than forcing a login wall; the forms already work.
14
14
  4. Run `nerd check`, then `nerd dev`, and tell the person to open the local URL, create an account, and try it. Fix what they report.
15
15
  5. Run `nerd deploy` and give them the live URL. Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd verify` any time.
16
16
  6. Offer the next steps once, briefly: link the GitHub repo for previews on pull requests (`nerd github connect`), add a custom domain, or invite a teammate from the dashboard.
@@ -44,12 +44,13 @@ Once the repo is linked (`nerd github connect`, then `nerd github link owner/rep
44
44
 
45
45
  ## Auth and authorization
46
46
 
47
- The SDK handles sessions. There is no login code to write.
47
+ The SDK handles sessions. There is no server-side login code to write; the client only decides where the forms appear.
48
48
 
49
49
  - `c.var.user` is `{ id, email, name, roles }` or `null` on every request.
50
50
  - `requireAuth()` middleware returns 401 when signed out.
51
51
  - `requireRole("admin")` returns 403 unless the user has one of the given roles. Multiple roles are OR.
52
- - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them. Password reset emails link to `/reset-password?token=...`, which the template client renders; keep that route if you restructure the client.
52
+ - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them, and `src/client/auth.tsx` turns them into drop-in components: `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm`, plus `useUser()` for the current user anywhere in the tree and `RequireUser` to guard a screen (it sends signed-out visitors to `/login?next=...`). Put the forms wherever the design wants: their own pages, a modal, inline on the homepage. Password reset emails link to `/reset-password?token=...`; keep that route if you restructure the client.
53
+ - Pages are public by default because they are static files. Guarding a screen with `RequireUser` is a courtesy to the visitor; the data is protected by `requireAuth()` / `requireRole()` on the API routes, which is what the platform checks.
53
54
  - Roles are a list the platform owns. See the current list with `nerd auth config`. Add one with `nerd auth config --roles admin,member,editor`. The default role for new signups is set with `--default-role`. Turn public signup off with `--signup off`.
54
55
  - To promote a user from code, use the admin client: `new AuthServiceClient(c.env).users.setRoles(userId, ["admin"])`. Guard that route with `requireRole("admin")`.
55
56
 
@@ -1,4 +1,4 @@
1
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (2834c4c81f7f); edit the skill, then run: nerd agents sync -->
1
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (ca70e3512072); edit the skill, then run: nerd agents sync -->
2
2
 
3
3
  # Building on nerdsystem
4
4
 
@@ -10,7 +10,7 @@ If this project still has the template's todo app (the `todos` table and `src/se
10
10
 
11
11
  1. Ask one question: what should the app do, in a sentence or two. Do not ask about stacks, databases, or hosting; those are decided.
12
12
  2. Say what you will build first, in plain words: the one table and the one screen that make the idea real. Keep the first feature small enough to deploy within the session.
13
- 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. Keep signup and login as they are; they already work.
13
+ 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. The template is a normal website: a public homepage at `/`, auth pages at `/login`, `/signup`, `/forgot-password`, `/reset-password`, and a signed-in area. Reshape it to the idea (a public landing page with sign-in in the header, a members-only area, an admin section) rather than forcing a login wall; the forms already work.
14
14
  4. Run `nerd check`, then `nerd dev`, and tell the person to open the local URL, create an account, and try it. Fix what they report.
15
15
  5. Run `nerd deploy` and give them the live URL. Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd verify` any time.
16
16
  6. Offer the next steps once, briefly: link the GitHub repo for previews on pull requests (`nerd github connect`), add a custom domain, or invite a teammate from the dashboard.
@@ -44,12 +44,13 @@ Once the repo is linked (`nerd github connect`, then `nerd github link owner/rep
44
44
 
45
45
  ## Auth and authorization
46
46
 
47
- The SDK handles sessions. There is no login code to write.
47
+ The SDK handles sessions. There is no server-side login code to write; the client only decides where the forms appear.
48
48
 
49
49
  - `c.var.user` is `{ id, email, name, roles }` or `null` on every request.
50
50
  - `requireAuth()` middleware returns 401 when signed out.
51
51
  - `requireRole("admin")` returns 403 unless the user has one of the given roles. Multiple roles are OR.
52
- - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them. Password reset emails link to `/reset-password?token=...`, which the template client renders; keep that route if you restructure the client.
52
+ - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them, and `src/client/auth.tsx` turns them into drop-in components: `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm`, plus `useUser()` for the current user anywhere in the tree and `RequireUser` to guard a screen (it sends signed-out visitors to `/login?next=...`). Put the forms wherever the design wants: their own pages, a modal, inline on the homepage. Password reset emails link to `/reset-password?token=...`; keep that route if you restructure the client.
53
+ - Pages are public by default because they are static files. Guarding a screen with `RequireUser` is a courtesy to the visitor; the data is protected by `requireAuth()` / `requireRole()` on the API routes, which is what the platform checks.
53
54
  - Roles are a list the platform owns. See the current list with `nerd auth config`. Add one with `nerd auth config --roles admin,member,editor`. The default role for new signups is set with `--default-role`. Turn public signup off with `--signup off`.
54
55
  - To promote a user from code, use the admin client: `new AuthServiceClient(c.env).users.setRoles(userId, ["admin"])`. Guard that route with `requireRole("admin")`.
55
56
 
@@ -14,13 +14,13 @@ nerdsystem deploys this app to Cloudflare and provides authentication and author
14
14
  ## Layout
15
15
 
16
16
  - `src/server/index.ts` is the Worker entry. Build the API here with Hono; mount routers under `/api/...`.
17
- - `src/client/` is the React app, built by Vite and served as static assets for every path outside `/api`.
17
+ - `src/client/` is the React app, built by Vite and served as static assets for every path outside `/api`. `App.tsx` routes by path (public homepage, `/login`, `/signup`, `/forgot-password`, `/reset-password`, a signed-in area); `auth.tsx` has the session hook and auth forms; `router.tsx` is a tiny pushState router you may replace.
18
18
  - `src/server/db/schema.ts` is the Drizzle schema for the app's D1 database (`c.env.DB`). Files go in `c.env.STORAGE` (R2).
19
19
 
20
20
  ## Rules the platform enforces
21
21
 
22
22
  1. Keep `createNerdApp()` from `@nerdsystem/sdk` as the app's root. Do not replace it with a bare Hono instance.
23
- 2. Authentication is handled by the SDK. Do not write login, signup, password, or token code. Use `c.var.user`, `requireAuth()`, and `requireRole("admin")`.
23
+ 2. Authentication is handled by the SDK. Do not write server-side login, signup, password, or token code. Use `c.var.user`, `requireAuth()`, and `requireRole("admin")`. The sign-in, sign-up, and password forms in `src/client/auth.tsx` are plain components; place them wherever the design wants (pages, a modal, the homepage). Pages are public unless the data behind them is guarded on the server.
24
24
  3. Every table that stores user data carries a `user_id` column, and every query filters by `c.var.user.id`. Users must never see each other's rows.
25
25
  4. Database changes go through Drizzle migrations in `migrations/`. `nerd deploy` applies them.
26
26
  5. Keep every API route under `/api/`. Other paths are served from static assets and never reach the Worker.
@@ -1,4 +1,4 @@
1
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (2834c4c81f7f); edit the skill, then run: nerd agents sync -->
1
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (ca70e3512072); edit the skill, then run: nerd agents sync -->
2
2
 
3
3
  # Building on nerdsystem
4
4
 
@@ -10,7 +10,7 @@ If this project still has the template's todo app (the `todos` table and `src/se
10
10
 
11
11
  1. Ask one question: what should the app do, in a sentence or two. Do not ask about stacks, databases, or hosting; those are decided.
12
12
  2. Say what you will build first, in plain words: the one table and the one screen that make the idea real. Keep the first feature small enough to deploy within the session.
13
- 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. Keep signup and login as they are; they already work.
13
+ 3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. The template is a normal website: a public homepage at `/`, auth pages at `/login`, `/signup`, `/forgot-password`, `/reset-password`, and a signed-in area. Reshape it to the idea (a public landing page with sign-in in the header, a members-only area, an admin section) rather than forcing a login wall; the forms already work.
14
14
  4. Run `nerd check`, then `nerd dev`, and tell the person to open the local URL, create an account, and try it. Fix what they report.
15
15
  5. Run `nerd deploy` and give them the live URL. Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd verify` any time.
16
16
  6. Offer the next steps once, briefly: link the GitHub repo for previews on pull requests (`nerd github connect`), add a custom domain, or invite a teammate from the dashboard.
@@ -44,12 +44,13 @@ Once the repo is linked (`nerd github connect`, then `nerd github link owner/rep
44
44
 
45
45
  ## Auth and authorization
46
46
 
47
- The SDK handles sessions. There is no login code to write.
47
+ The SDK handles sessions. There is no server-side login code to write; the client only decides where the forms appear.
48
48
 
49
49
  - `c.var.user` is `{ id, email, name, roles }` or `null` on every request.
50
50
  - `requireAuth()` middleware returns 401 when signed out.
51
51
  - `requireRole("admin")` returns 403 unless the user has one of the given roles. Multiple roles are OR.
52
- - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them. Password reset emails link to `/reset-password?token=...`, which the template client renders; keep that route if you restructure the client.
52
+ - The frontend calls `/api/auth/signup`, `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`, `/api/auth/forgot`, and `/api/auth/reset`. The template's `auth` helper in `src/client/api.ts` wraps them, and `src/client/auth.tsx` turns them into drop-in components: `SignInForm`, `SignUpForm`, `ForgotPasswordForm`, `ResetPasswordForm`, plus `useUser()` for the current user anywhere in the tree and `RequireUser` to guard a screen (it sends signed-out visitors to `/login?next=...`). Put the forms wherever the design wants: their own pages, a modal, inline on the homepage. Password reset emails link to `/reset-password?token=...`; keep that route if you restructure the client.
53
+ - Pages are public by default because they are static files. Guarding a screen with `RequireUser` is a courtesy to the visitor; the data is protected by `requireAuth()` / `requireRole()` on the API routes, which is what the platform checks.
53
54
  - Roles are a list the platform owns. See the current list with `nerd auth config`. Add one with `nerd auth config --roles admin,member,editor`. The default role for new signups is set with `--default-role`. Turn public signup off with `--signup off`.
54
55
  - To promote a user from code, use the admin client: `new AuthServiceClient(c.env).users.setRoles(userId, ["admin"])`. Guard that route with `requireRole("admin")`.
55
56
 
@@ -1,128 +1,79 @@
1
- import { useEffect, useRef, useState, type FormEvent } from "react";
2
-
3
- // Optional bot protection on signup: set VITE_TURNSTILE_SITE_KEY (client) and NERD_TURNSTILE_SECRET
4
- // (server, via `nerd secrets`) from a Cloudflare Turnstile widget. Without them, signup is open.
5
- const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;
6
-
7
- function Turnstile({ onToken }: { onToken: (t: string) => void }) {
8
- const ref = useRef<HTMLDivElement>(null);
9
- useEffect(() => {
10
- if (!TURNSTILE_SITE_KEY || !ref.current) return;
11
- const w = window as unknown as { turnstile?: { render: (el: HTMLElement, opts: { sitekey: string; callback: (t: string) => void }) => void } };
12
- const render = () => w.turnstile?.render(ref.current!, { sitekey: TURNSTILE_SITE_KEY, callback: onToken });
13
- if (w.turnstile) render();
14
- else {
15
- const s = document.createElement("script");
16
- s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
17
- s.async = true;
18
- s.onload = render;
19
- document.head.appendChild(s);
20
- }
21
- }, [onToken]);
22
- return TURNSTILE_SITE_KEY ? <div ref={ref} /> : null;
23
- }
24
- import { api, auth, ApiError, type User } from "./api";
25
-
26
- interface Todo {
27
- id: number;
28
- title: string;
29
- done: boolean;
30
- }
1
+ // A normal website: a public homepage, auth pages, and a signed-in area. Pages are plain functions;
2
+ // add one by adding a case in App(). Everyone can load every page (they are static files); what a
3
+ // page can read is decided by the server routes it calls, which use requireAuth() and requireRole().
4
+ import { useEffect, useState, type FormEvent, type ReactNode } from "react";
5
+ import { api } from "./api";
6
+ import { afterSignIn, ForgotPasswordForm, RequireUser, ResetPasswordForm, SignInForm, SignUpForm, useUser } from "./auth";
7
+ import { Link, navigate, usePath } from "./router";
31
8
 
32
9
  export default function App() {
33
- const [user, setUser] = useState<User | null | undefined>(undefined);
34
-
35
- useEffect(() => {
36
- auth.me().then((r) => setUser(r.user)).catch(() => setUser(null));
37
- }, []);
38
-
39
- if (user === undefined) return <main className="shell">Loading…</main>;
40
- if (window.location.pathname === "/reset-password") return <ResetPassword onSignedIn={(u) => { window.history.replaceState(null, "", "/"); setUser(u); }} />;
41
- if (!user) return <AuthScreen onSignedIn={setUser} />;
42
- return <Todos user={user} onSignedOut={() => setUser(null)} />;
43
- }
44
-
45
- function AuthScreen({ onSignedIn }: { onSignedIn: (u: User) => void }) {
46
- const [mode, setMode] = useState<"login" | "signup" | "forgot">("login");
47
- const [error, setError] = useState<string | null>(null);
48
- const [notice, setNotice] = useState<string | null>(null);
49
- const [busy, setBusy] = useState(false);
50
- const [turnstileToken, setTurnstileToken] = useState<string | undefined>(undefined);
51
-
52
- async function submit(e: FormEvent<HTMLFormElement>) {
53
- e.preventDefault();
54
- const form = new FormData(e.currentTarget);
55
- const email = String(form.get("email"));
56
- const password = String(form.get("password"));
57
- setBusy(true);
58
- setError(null);
59
- try {
60
- if (mode === "forgot") {
61
- await auth.forgot(email);
62
- setNotice("If that email has an account, a reset link is on its way.");
63
- return;
64
- }
65
- const r = mode === "login" ? await auth.login(email, password) : await auth.signup(email, password, String(form.get("name")), turnstileToken);
66
- onSignedIn(r.user);
67
- } catch (err) {
68
- setError(err instanceof ApiError ? err.message : "Something went wrong");
69
- } finally {
70
- setBusy(false);
71
- }
10
+ const path = usePath();
11
+ switch (path) {
12
+ case "/":
13
+ return <Home />;
14
+ case "/login":
15
+ return <Page title="Sign in"><SignInForm onSignedIn={() => navigate(afterSignIn("/todos"))} /><p className="muted"><Link href="/forgot-password">Forgot your password?</Link> · <Link href="/signup">Need an account?</Link></p></Page>;
16
+ case "/signup":
17
+ return <Page title="Create an account"><SignUpForm onSignedIn={() => navigate("/todos")} /><p className="muted"><Link href="/login">Have an account? Sign in</Link></p></Page>;
18
+ case "/forgot-password":
19
+ return <Page title="Reset your password"><ForgotPasswordForm /></Page>;
20
+ case "/reset-password": // password reset emails link here; keep this route
21
+ return <Page title="Choose a new password"><ResetPasswordForm onSignedIn={() => navigate("/todos", { replace: true })} /></Page>;
22
+ case "/todos":
23
+ return <Page title="Todos"><RequireUser><Todos /></RequireUser></Page>;
24
+ default:
25
+ return <Page title="Not found"><p className="muted">Nothing lives at {path}. <Link href="/">Go home</Link>.</p></Page>;
72
26
  }
27
+ }
73
28
 
29
+ /** Shared frame: the site name, navigation that knows whether you are signed in, and the page body. */
30
+ function Page({ title, children }: { title: string; children: ReactNode }) {
31
+ const { user, signOut } = useUser();
74
32
  return (
75
33
  <main className="shell">
76
- <h1>nerd-app</h1>
77
- <form onSubmit={submit} className="card">
78
- {mode === "signup" && <input name="name" placeholder="Your name" required />}
79
- <input name="email" type="email" placeholder="Email" required />
80
- {mode !== "forgot" && <input name="password" type="password" placeholder="Password (8+ characters)" minLength={8} required />}
81
- {mode === "signup" && <Turnstile onToken={setTurnstileToken} />}
82
- {error && <p className="error">{error}</p>}
83
- {notice && <p className="muted">{notice}</p>}
84
- <button disabled={busy}>{mode === "login" ? "Sign in" : mode === "signup" ? "Create account" : "Email me a reset link"}</button>
85
- <button type="button" className="link" onClick={() => { setNotice(null); setMode(mode === "login" ? "signup" : "login"); }}>
86
- {mode === "login" ? "Need an account? Sign up" : "Have an account? Sign in"}
87
- </button>
88
- {mode === "login" && (
89
- <button type="button" className="link" onClick={() => { setNotice(null); setMode("forgot"); }}>Forgot your password?</button>
90
- )}
91
- </form>
34
+ <nav className="nav">
35
+ <Link href="/"><strong>nerd-app</strong></Link>
36
+ <span>
37
+ {user ? (
38
+ <>
39
+ <Link href="/todos">Todos</Link> · {user.name} · <button className="link" onClick={() => signOut().then(() => navigate("/"))}>Sign out</button>
40
+ </>
41
+ ) : user === null ? (
42
+ <>
43
+ <Link href="/login">Sign in</Link> · <Link href="/signup">Sign up</Link>
44
+ </>
45
+ ) : null}
46
+ </span>
47
+ </nav>
48
+ <h1>{title}</h1>
49
+ {children}
92
50
  </main>
93
51
  );
94
52
  }
95
53
 
96
- function ResetPassword({ onSignedIn }: { onSignedIn: (u: User) => void }) {
97
- const token = new URLSearchParams(window.location.search).get("token") ?? "";
98
- const [error, setError] = useState<string | null>(null);
99
- const [busy, setBusy] = useState(false);
100
- async function submit(e: FormEvent<HTMLFormElement>) {
101
- e.preventDefault();
102
- setBusy(true);
103
- setError(null);
104
- try {
105
- const r = await auth.reset(token, String(new FormData(e.currentTarget).get("password")));
106
- onSignedIn(r.user);
107
- } catch (err) {
108
- setError(err instanceof ApiError ? err.message : "Something went wrong");
109
- } finally {
110
- setBusy(false);
111
- }
112
- }
54
+ /** Public. Replace this with the app's real landing page. */
55
+ function Home() {
56
+ const { user } = useUser();
113
57
  return (
114
- <main className="shell">
115
- <h1>Choose a new password</h1>
116
- <form onSubmit={submit} className="card">
117
- <input name="password" type="password" placeholder="New password (8+ characters)" minLength={8} required autoFocus />
118
- {error && <p className="error">{error}</p>}
119
- <button disabled={busy || !token}>Set password and sign in</button>
120
- </form>
121
- </main>
58
+ <Page title="Welcome to nerd-app">
59
+ <p>This page is public. Anyone can read it without an account.</p>
60
+ {user ? (
61
+ <p><Link href="/todos">Open your todos</Link></p>
62
+ ) : (
63
+ <p><Link href="/signup">Create an account</Link> or <Link href="/login">sign in</Link> to keep a todo list.</p>
64
+ )}
65
+ </Page>
122
66
  );
123
67
  }
124
68
 
125
- function Todos({ user, onSignedOut }: { user: User; onSignedOut: () => void }) {
69
+ interface Todo {
70
+ id: number;
71
+ title: string;
72
+ done: boolean;
73
+ }
74
+
75
+ /** Signed-in only: RequireUser above guards the screen, requireAuth() on /api/todos guards the data. */
76
+ function Todos() {
126
77
  const [todos, setTodos] = useState<Todo[]>([]);
127
78
  const [title, setTitle] = useState("");
128
79
 
@@ -150,16 +101,7 @@ function Todos({ user, onSignedOut }: { user: User; onSignedOut: () => void }) {
150
101
  }
151
102
 
152
103
  return (
153
- <main className="shell">
154
- <header>
155
- <h1>Todos</h1>
156
- <span>
157
- {user.name} · {user.roles.join(", ")}{" "}
158
- <button className="link" onClick={() => auth.logout().then(onSignedOut)}>
159
- Sign out
160
- </button>
161
- </span>
162
- </header>
104
+ <>
163
105
  <form onSubmit={add} className="row">
164
106
  <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs doing?" />
165
107
  <button>Add</button>
@@ -177,6 +119,6 @@ function Todos({ user, onSignedOut }: { user: User; onSignedOut: () => void }) {
177
119
  ))}
178
120
  {todos.length === 0 && <li className="muted">Nothing yet.</li>}
179
121
  </ul>
180
- </main>
122
+ </>
181
123
  );
182
124
  }
@@ -0,0 +1,163 @@
1
+ // Sign-in, sign-up, and password forms as plain components. Put them wherever the design wants:
2
+ // on their own pages (the default routes in App.tsx), inside a modal, or inline on the homepage.
3
+ // They only call the platform's session endpoints through `auth` in ./api; access to data is
4
+ // decided on the server with requireAuth() and requireRole(), never by which screen is showing.
5
+ import { createContext, useContext, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
6
+ import { auth, ApiError, type User } from "./api";
7
+ import { navigate } from "./router";
8
+
9
+ // Optional bot protection on signup: set VITE_TURNSTILE_SITE_KEY (client) and NERD_TURNSTILE_SECRET
10
+ // (server, via `nerd secrets`) from a Cloudflare Turnstile widget. Without them, signup is open.
11
+ const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;
12
+
13
+ function Turnstile({ onToken }: { onToken: (t: string) => void }) {
14
+ const ref = useRef<HTMLDivElement>(null);
15
+ useEffect(() => {
16
+ if (!TURNSTILE_SITE_KEY || !ref.current) return;
17
+ const w = window as unknown as { turnstile?: { render: (el: HTMLElement, opts: { sitekey: string; callback: (t: string) => void }) => void } };
18
+ const render = () => w.turnstile?.render(ref.current!, { sitekey: TURNSTILE_SITE_KEY, callback: onToken });
19
+ if (w.turnstile) render();
20
+ else {
21
+ const s = document.createElement("script");
22
+ s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
23
+ s.async = true;
24
+ s.onload = render;
25
+ document.head.appendChild(s);
26
+ }
27
+ }, [onToken]);
28
+ return TURNSTILE_SITE_KEY ? <div ref={ref} /> : null;
29
+ }
30
+
31
+ // ---- Session state, available anywhere with useUser() ----
32
+
33
+ interface Session {
34
+ /** undefined while the first /api/auth/me call is in flight, null when signed out. */
35
+ user: User | null | undefined;
36
+ setUser: (u: User | null) => void;
37
+ signOut: () => Promise<void>;
38
+ }
39
+
40
+ const SessionContext = createContext<Session | null>(null);
41
+
42
+ export function SessionProvider({ children }: { children: ReactNode }) {
43
+ const [user, setUser] = useState<User | null | undefined>(undefined);
44
+ useEffect(() => {
45
+ auth.me().then((r) => setUser(r.user)).catch(() => setUser(null));
46
+ }, []);
47
+ const signOut = () => auth.logout().then(() => setUser(null));
48
+ return <SessionContext.Provider value={{ user, setUser, signOut }}>{children}</SessionContext.Provider>;
49
+ }
50
+
51
+ export function useUser(): Session {
52
+ const s = useContext(SessionContext);
53
+ if (!s) throw new Error("useUser() needs <SessionProvider> above it (see main.tsx)");
54
+ return s;
55
+ }
56
+
57
+ /** Renders children only when signed in; otherwise sends the visitor to the sign-in page and back afterwards. */
58
+ export function RequireUser({ children, to = "/login" }: { children: ReactNode; to?: string }) {
59
+ const { user } = useUser();
60
+ useEffect(() => {
61
+ if (user === null) navigate(`${to}?next=${encodeURIComponent(window.location.pathname + window.location.search)}`);
62
+ }, [user, to]);
63
+ if (!user) return <p className="muted">Loading…</p>;
64
+ return <>{children}</>;
65
+ }
66
+
67
+ /** Where to go after signing in: the `next` query parameter if present, otherwise the given fallback. */
68
+ export function afterSignIn(fallback = "/"): string {
69
+ const next = new URLSearchParams(window.location.search).get("next");
70
+ return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
71
+ }
72
+
73
+ // ---- Forms ----
74
+
75
+ function useSubmit(run: (form: FormData) => Promise<void>) {
76
+ const [error, setError] = useState<string | null>(null);
77
+ const [busy, setBusy] = useState(false);
78
+ async function onSubmit(e: FormEvent<HTMLFormElement>) {
79
+ e.preventDefault();
80
+ const form = new FormData(e.currentTarget);
81
+ setBusy(true);
82
+ setError(null);
83
+ try {
84
+ await run(form);
85
+ } catch (err) {
86
+ setError(err instanceof ApiError ? err.message : "Something went wrong");
87
+ } finally {
88
+ setBusy(false);
89
+ }
90
+ }
91
+ return { onSubmit, error, busy };
92
+ }
93
+
94
+ export function SignInForm({ onSignedIn }: { onSignedIn?: (u: User) => void }) {
95
+ const { setUser } = useUser();
96
+ const { onSubmit, error, busy } = useSubmit(async (form) => {
97
+ const r = await auth.login(String(form.get("email")), String(form.get("password")));
98
+ setUser(r.user);
99
+ onSignedIn?.(r.user);
100
+ });
101
+ return (
102
+ <form onSubmit={onSubmit} className="card">
103
+ <input name="email" type="email" placeholder="Email" autoComplete="email" required />
104
+ <input name="password" type="password" placeholder="Password" autoComplete="current-password" required />
105
+ {error && <p className="error">{error}</p>}
106
+ <button disabled={busy}>Sign in</button>
107
+ </form>
108
+ );
109
+ }
110
+
111
+ export function SignUpForm({ onSignedIn }: { onSignedIn?: (u: User) => void }) {
112
+ const { setUser } = useUser();
113
+ const [turnstileToken, setTurnstileToken] = useState<string | undefined>(undefined);
114
+ const { onSubmit, error, busy } = useSubmit(async (form) => {
115
+ const r = await auth.signup(String(form.get("email")), String(form.get("password")), String(form.get("name")), turnstileToken);
116
+ setUser(r.user);
117
+ onSignedIn?.(r.user);
118
+ });
119
+ return (
120
+ <form onSubmit={onSubmit} className="card">
121
+ <input name="name" placeholder="Your name" autoComplete="name" required />
122
+ <input name="email" type="email" placeholder="Email" autoComplete="email" required />
123
+ <input name="password" type="password" placeholder="Password (8+ characters)" autoComplete="new-password" minLength={8} required />
124
+ <Turnstile onToken={setTurnstileToken} />
125
+ {error && <p className="error">{error}</p>}
126
+ <button disabled={busy}>Create account</button>
127
+ </form>
128
+ );
129
+ }
130
+
131
+ export function ForgotPasswordForm() {
132
+ const [sent, setSent] = useState(false);
133
+ const { onSubmit, error, busy } = useSubmit(async (form) => {
134
+ await auth.forgot(String(form.get("email")));
135
+ setSent(true);
136
+ });
137
+ if (sent) return <p className="muted">If that email has an account, a reset link is on its way.</p>;
138
+ return (
139
+ <form onSubmit={onSubmit} className="card">
140
+ <input name="email" type="email" placeholder="Email" autoComplete="email" required />
141
+ {error && <p className="error">{error}</p>}
142
+ <button disabled={busy}>Email me a reset link</button>
143
+ </form>
144
+ );
145
+ }
146
+
147
+ /** Reads the token from `?token=`; password reset emails link to /reset-password?token=..., so keep that route. */
148
+ export function ResetPasswordForm({ onSignedIn }: { onSignedIn?: (u: User) => void }) {
149
+ const { setUser } = useUser();
150
+ const token = new URLSearchParams(window.location.search).get("token") ?? "";
151
+ const { onSubmit, error, busy } = useSubmit(async (form) => {
152
+ const r = await auth.reset(token, String(form.get("password")));
153
+ setUser(r.user);
154
+ onSignedIn?.(r.user);
155
+ });
156
+ return (
157
+ <form onSubmit={onSubmit} className="card">
158
+ <input name="password" type="password" placeholder="New password (8+ characters)" autoComplete="new-password" minLength={8} required autoFocus />
159
+ {error && <p className="error">{error}</p>}
160
+ <button disabled={busy || !token}>Set password and sign in</button>
161
+ </form>
162
+ );
163
+ }
@@ -1,10 +1,13 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import App from "./App";
4
+ import { SessionProvider } from "./auth";
4
5
  import "./styles.css";
5
6
 
6
7
  createRoot(document.getElementById("root")!).render(
7
8
  <StrictMode>
8
- <App />
9
+ <SessionProvider>
10
+ <App />
11
+ </SessionProvider>
9
12
  </StrictMode>,
10
13
  );
@@ -0,0 +1,42 @@
1
+ // The smallest possible client-side router: a path, a way to change it, and links that use it.
2
+ // Replace with react-router (or anything else) when the app outgrows it; nothing on the platform cares.
3
+ import { useEffect, useState, type AnchorHTMLAttributes } from "react";
4
+
5
+ const listeners = new Set<() => void>();
6
+
7
+ export function navigate(to: string, { replace = false }: { replace?: boolean } = {}): void {
8
+ if (replace) window.history.replaceState(null, "", to);
9
+ else window.history.pushState(null, "", to);
10
+ listeners.forEach((l) => l());
11
+ }
12
+
13
+ /** The current pathname, re-rendering on navigate() and on back/forward. */
14
+ export function usePath(): string {
15
+ const [path, setPath] = useState(window.location.pathname);
16
+ useEffect(() => {
17
+ const update = () => setPath(window.location.pathname);
18
+ listeners.add(update);
19
+ window.addEventListener("popstate", update);
20
+ return () => {
21
+ listeners.delete(update);
22
+ window.removeEventListener("popstate", update);
23
+ };
24
+ }, []);
25
+ return path;
26
+ }
27
+
28
+ /** An <a> that navigates without a full page load. External links and modified clicks fall through to the browser. */
29
+ export function Link({ href, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) {
30
+ return (
31
+ <a
32
+ href={href}
33
+ {...rest}
34
+ onClick={(e) => {
35
+ rest.onClick?.(e);
36
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0 || !href.startsWith("/")) return;
37
+ e.preventDefault();
38
+ navigate(href);
39
+ }}
40
+ />
41
+ );
42
+ }
@@ -1,7 +1,9 @@
1
1
  :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
2
2
  body { margin: 0; }
3
3
  .shell { max-width: 32rem; margin: 4rem auto; padding: 0 1rem; }
4
- header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
4
+ header, .nav { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
5
+ .nav { margin-bottom: 2rem; }
6
+ .nav a { color: inherit; }
5
7
  h1 { margin: 0 0 1rem; }
6
8
  .card, .row { display: flex; flex-direction: column; gap: 0.5rem; }
7
9
  .row { flex-direction: row; }