nerdsystem 0.0.3 → 0.0.5
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.
- package/dist/api.js +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/login.d.ts +8 -0
- package/dist/commands/login.js +66 -3
- package/dist/config.d.ts +9 -0
- package/dist/config.js +5 -1
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +4 -3
- package/template/.cursor/rules/nerdsystem.mdc +5 -4
- package/template/.github/copilot-instructions.md +5 -4
- package/template/AGENTS.md +5 -4
- package/template/CLAUDE.md +2 -2
- package/template/GEMINI.md +5 -4
- package/template/src/client/App.tsx +64 -122
- package/template/src/client/auth.tsx +163 -0
- package/template/src/client/main.tsx +4 -1
- package/template/src/client/router.tsx +42 -0
- package/template/src/client/styles.css +3 -1
package/dist/api.js
CHANGED
|
@@ -10,7 +10,7 @@ export class ApiClient {
|
|
|
10
10
|
static fromConfig(opts = {}) {
|
|
11
11
|
const cfg = loadConfig();
|
|
12
12
|
if (opts.requireAuth !== false && !cfg.token) {
|
|
13
|
-
throw new CliError("Not logged in. Run `nerd login
|
|
13
|
+
throw new CliError("Not logged in. Run `nerd login` (or set NERD_TOKEN).");
|
|
14
14
|
}
|
|
15
15
|
return new ApiClient(cfg.apiUrl, cfg.token);
|
|
16
16
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function doctor(opts) {
|
|
|
15
15
|
const gh = await exec("gh", ["auth", "status"], process.cwd());
|
|
16
16
|
checks.push({ name: "GitHub CLI signed in (optional)", ok: gh.code === 0, detail: gh.code === 0 ? "ok" : "not signed in", fix: "Only needed to create repos from the terminal: https://cli.github.com then `gh auth login`." });
|
|
17
17
|
const cfg = loadConfig();
|
|
18
|
-
checks.push({ name: "nerd login", ok: Boolean(cfg.token), detail: cfg.token ? cfg.apiUrl : "no token saved", fix: "
|
|
18
|
+
checks.push({ name: "nerd login", ok: Boolean(cfg.token), detail: cfg.token ? cfg.apiUrl : "no token saved", fix: "Run `nerd login` and approve the link it prints." });
|
|
19
19
|
let root = null;
|
|
20
20
|
try {
|
|
21
21
|
root = findProjectRoot();
|
package/dist/commands/init.js
CHANGED
|
@@ -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.
|
|
107
|
+
const SDK_VERSION = "0.0.5";
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nerd login` with no token starts a device login: prints a link, the person approves it in the
|
|
3
|
+
* dashboard (or from the email when --email is given), and the token arrives here. `--token` still
|
|
4
|
+
* works for scripts. A pending login is remembered, so running `nerd login` again after approving
|
|
5
|
+
* picks it up instead of starting over.
|
|
6
|
+
*/
|
|
1
7
|
export declare function login(args: {
|
|
2
8
|
token?: string;
|
|
3
9
|
"api-url"?: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
json?: boolean;
|
|
4
12
|
}): Promise<void>;
|
|
5
13
|
export declare function whoami(opts: {
|
|
6
14
|
json: boolean;
|
package/dist/commands/login.js
CHANGED
|
@@ -1,13 +1,76 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import { ApiClient } from "../api.js";
|
|
2
3
|
import { DEFAULT_API_URL, loadConfig, saveConfig } from "../config.js";
|
|
3
4
|
import { CliError, log, printJson } from "../util.js";
|
|
5
|
+
/** How long one `nerd login` call waits before handing back; agents run commands with short timeouts, so a rerun resumes. */
|
|
6
|
+
const WAIT_S = 90;
|
|
7
|
+
/**
|
|
8
|
+
* `nerd login` with no token starts a device login: prints a link, the person approves it in the
|
|
9
|
+
* dashboard (or from the email when --email is given), and the token arrives here. `--token` still
|
|
10
|
+
* works for scripts. A pending login is remembered, so running `nerd login` again after approving
|
|
11
|
+
* picks it up instead of starting over.
|
|
12
|
+
*/
|
|
4
13
|
export async function login(args) {
|
|
14
|
+
const cfg = loadConfig();
|
|
15
|
+
const apiUrl = (args["api-url"] ?? cfg.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
5
16
|
const token = args.token ?? process.env.NERD_TOKEN;
|
|
6
|
-
if (
|
|
7
|
-
|
|
8
|
-
const
|
|
17
|
+
if (token)
|
|
18
|
+
return finish(apiUrl, token, args.json);
|
|
19
|
+
const anon = new ApiClient(apiUrl, undefined);
|
|
20
|
+
let pending = cfg.pendingLogin && cfg.pendingLogin.apiUrl === apiUrl && cfg.pendingLogin.expiresAt > Date.now() ? cfg.pendingLogin : undefined;
|
|
21
|
+
if (!pending) {
|
|
22
|
+
const client = `nerd CLI on ${os.hostname()} (${os.platform()})`;
|
|
23
|
+
const start = await anon.post("/session/device", { client, ...(args.email ? { email: args.email } : {}) });
|
|
24
|
+
pending = { apiUrl, deviceCode: start.deviceCode, userCode: start.userCode, verificationUrl: start.verificationUrl, interval: start.interval, expiresAt: Date.now() + start.expiresIn * 1000 };
|
|
25
|
+
saveConfig({ ...cfg, pendingLogin: pending });
|
|
26
|
+
if (args.json)
|
|
27
|
+
printJson({ status: "pending", userCode: start.userCode, verificationUrl: start.verificationUrl, expiresIn: start.expiresIn });
|
|
28
|
+
else {
|
|
29
|
+
log.info(`Open this link in your browser and approve code ${start.userCode}:\n\n ${start.verificationUrl}\n`);
|
|
30
|
+
if (args.email)
|
|
31
|
+
log.info(`An email with the same link went to ${args.email}.`);
|
|
32
|
+
log.info("Waiting for approval…");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (!args.json) {
|
|
36
|
+
log.info(`Still waiting for code ${pending.userCode} to be approved at:\n\n ${pending.verificationUrl}\n`);
|
|
37
|
+
}
|
|
38
|
+
const deadline = Math.min(Date.now() + WAIT_S * 1000, pending.expiresAt);
|
|
39
|
+
while (Date.now() < deadline) {
|
|
40
|
+
let r;
|
|
41
|
+
try {
|
|
42
|
+
r = await anon.post("/session/device/token", { deviceCode: pending.deviceCode });
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
// 410: expired or already collected. Forget it so the next run starts fresh.
|
|
46
|
+
clearPending();
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (r.status === "ok") {
|
|
50
|
+
clearPending();
|
|
51
|
+
return finish(apiUrl, r.token, args.json);
|
|
52
|
+
}
|
|
53
|
+
await new Promise((res) => setTimeout(res, pending.interval * 1000));
|
|
54
|
+
}
|
|
55
|
+
if (pending.expiresAt <= Date.now()) {
|
|
56
|
+
clearPending();
|
|
57
|
+
throw new CliError("The login code expired before it was approved. Run `nerd login` again.");
|
|
58
|
+
}
|
|
59
|
+
const msg = `Not approved yet. Approve code ${pending.userCode} at ${pending.verificationUrl}, then run \`nerd login\` again to finish.`;
|
|
60
|
+
if (args.json)
|
|
61
|
+
printJson({ status: "pending", userCode: pending.userCode, verificationUrl: pending.verificationUrl });
|
|
62
|
+
else
|
|
63
|
+
log.warn(msg);
|
|
64
|
+
}
|
|
65
|
+
function clearPending() {
|
|
66
|
+
const { pendingLogin: _drop, ...rest } = loadConfig();
|
|
67
|
+
saveConfig(rest);
|
|
68
|
+
}
|
|
69
|
+
async function finish(apiUrl, token, json) {
|
|
9
70
|
const me = await new ApiClient(apiUrl, token).get("/v1/me");
|
|
10
71
|
const file = saveConfig({ apiUrl, token });
|
|
72
|
+
if (json)
|
|
73
|
+
return printJson({ status: "ok", apiUrl, user: me.user, file });
|
|
11
74
|
log.ok(`Logged in as ${me.user.email} (${apiUrl}). Saved to ${file}`);
|
|
12
75
|
}
|
|
13
76
|
export async function whoami(opts) {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
export interface GlobalConfig {
|
|
2
2
|
apiUrl: string;
|
|
3
3
|
token?: string;
|
|
4
|
+
/** A device login that was started but not yet approved; `nerd login` resumes it. */
|
|
5
|
+
pendingLogin?: {
|
|
6
|
+
apiUrl: string;
|
|
7
|
+
deviceCode: string;
|
|
8
|
+
userCode: string;
|
|
9
|
+
verificationUrl: string;
|
|
10
|
+
interval: number;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
};
|
|
4
13
|
}
|
|
5
14
|
export declare const DEFAULT_API_URL = "https://api.nerdsystem.com";
|
|
6
15
|
export declare function loadConfig(): GlobalConfig;
|
package/dist/config.js
CHANGED
|
@@ -12,11 +12,15 @@ export function loadConfig() {
|
|
|
12
12
|
return {
|
|
13
13
|
apiUrl: process.env.NERD_API_URL ?? file.apiUrl ?? DEFAULT_API_URL,
|
|
14
14
|
...(process.env.NERD_TOKEN ?? file.token ? { token: process.env.NERD_TOKEN ?? file.token } : {}),
|
|
15
|
+
...(file.pendingLogin ? { pendingLogin: file.pendingLogin } : {}),
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
18
|
export function saveConfig(cfg) {
|
|
18
19
|
const p = configPath();
|
|
19
20
|
mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
20
|
-
|
|
21
|
+
const out = { ...cfg };
|
|
22
|
+
if (!out.pendingLogin)
|
|
23
|
+
delete out.pendingLogin;
|
|
24
|
+
writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
|
|
21
25
|
return p;
|
|
22
26
|
}
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { findProjectRoot } from "./project.js";
|
|
|
19
19
|
import { CliError, log } from "./util.js";
|
|
20
20
|
const HELP = `nerd — deploy apps to nerdsystem
|
|
21
21
|
|
|
22
|
-
nerd login --
|
|
22
|
+
nerd login [--email you@x.com] Sign in: prints a link to approve in the browser (or --token nsk_...)
|
|
23
23
|
nerd whoami Show the current user and orgs
|
|
24
24
|
nerd doctor Check Node, pnpm, git, login, and the project
|
|
25
25
|
nerd init <dir> [--org] [--name] [--describe "what it does"] Create an app and a project for it
|
|
@@ -69,7 +69,7 @@ async function main(argv) {
|
|
|
69
69
|
const parse = (options, allowPositionals = true) => parseArgs({ args: rest, options: { json: BOOL, ...options }, allowPositionals, strict: cmd !== "dev" });
|
|
70
70
|
switch (cmd) {
|
|
71
71
|
case "login": {
|
|
72
|
-
const { values } = parse({ token: STR, "api-url": STR });
|
|
72
|
+
const { values } = parse({ token: STR, "api-url": STR, email: STR });
|
|
73
73
|
return login(values);
|
|
74
74
|
}
|
|
75
75
|
case "doctor": {
|
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
|
package/template/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
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.
|
|
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
|
|
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
|
|
package/template/CLAUDE.md
CHANGED
|
@@ -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.
|
package/template/GEMINI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
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.
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
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
|
-
<
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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; }
|