@clubedge/create-clubedge-app 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +11 -10
  3. package/package.json +3 -3
  4. package/template/.env.example +6 -0
  5. package/template/README.md +10 -4
  6. package/template/SETUP.md +14 -3
  7. package/template/apps/start/.env.example +6 -0
  8. package/template/apps/start/AGENTS.md +1 -1
  9. package/template/apps/start/package.json +1 -1
  10. package/template/apps/start/src/components/auth/auth-form-controls.tsx +27 -8
  11. package/template/apps/start/src/components/dashboard/account-button.tsx +20 -14
  12. package/template/apps/start/src/components/error-reporting.tsx +49 -0
  13. package/template/apps/start/src/components/route-error.tsx +35 -0
  14. package/template/apps/start/src/components/variants/error-reporting.sentry.tsx +34 -0
  15. package/template/apps/start/src/env/auth.ts +9 -1
  16. package/template/apps/start/src/jobs/index.ts +24 -0
  17. package/template/apps/start/src/routes/__root.tsx +8 -0
  18. package/template/apps/start/src/routes/api/client-errors.ts +49 -0
  19. package/template/apps/start/src/routes/auth/change-password.ts +12 -0
  20. package/template/apps/start/src/routes/auth/delete-account.ts +12 -0
  21. package/template/apps/start/src/routes/auth/update-profile.ts +12 -0
  22. package/template/apps/start/src/routes/dashboard.tsx +25 -19
  23. package/template/apps/start/src/routes/index.tsx +29 -26
  24. package/template/apps/start/src/routes/login.tsx +1 -0
  25. package/template/apps/start/src/routes/settings.tsx +156 -0
  26. package/template/apps/start/src/server/auth-email.ts +13 -2
  27. package/template/apps/start/src/server/observability.ts +6 -1
  28. package/template/apps/start/src/server/payments.ts +6 -1
  29. package/template/apps/start/src/server/settings-forms.ts +110 -0
  30. package/template/apps/start/src/server/settings.functions.ts +24 -0
  31. package/template/apps/start/src/server/variants/observability.sentry.ts +17 -1
  32. package/template/apps/start/vite.config.ts +10 -2
  33. package/template/apps/web/next.config.ts +22 -0
  34. package/template/apps/web/package.json +1 -1
  35. package/template/apps/web/src/app/api/client-errors/route.ts +48 -0
  36. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +20 -14
  37. package/template/apps/web/src/app/dashboard/page.tsx +25 -25
  38. package/template/apps/web/src/app/error.tsx +30 -0
  39. package/template/apps/web/src/app/layout.tsx +6 -0
  40. package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +27 -8
  41. package/template/apps/web/src/app/login/page.tsx +1 -0
  42. package/template/apps/web/src/app/page.tsx +29 -26
  43. package/template/apps/web/src/app/settings/actions.ts +102 -0
  44. package/template/apps/web/src/app/settings/page.tsx +160 -0
  45. package/template/apps/web/src/components/error-reporting.tsx +51 -0
  46. package/template/apps/web/src/components/variants/error-reporting.sentry.tsx +36 -0
  47. package/template/apps/web/src/env/auth.ts +9 -1
  48. package/template/apps/web/src/jobs/index.ts +24 -0
  49. package/template/apps/web/src/server/auth-email.ts +13 -2
  50. package/template/apps/web/src/server/observability.ts +6 -1
  51. package/template/apps/web/src/server/payments.ts +6 -1
  52. package/template/apps/web/src/server/variants/observability.sentry.ts +17 -1
  53. package/template/clubedge.template.json +32 -2
  54. package/template/e2e/auth.spec.ts +1 -1
  55. package/template/e2e/email-verification.spec.ts +3 -1
  56. package/template/e2e/observability.spec.ts +48 -22
  57. package/template/e2e/password-reset.spec.ts +1 -1
  58. package/template/e2e/settings.spec.ts +78 -0
  59. package/template/e2e/telemetry-fake.ts +7 -0
  60. package/template/package.json +1 -1
  61. package/template/packages/auth/package.json +1 -1
  62. package/template/packages/auth/src/types.ts +19 -0
  63. package/template/packages/auth-better-auth/package.json +1 -1
  64. package/template/packages/auth-better-auth/src/index.test.ts +80 -1
  65. package/template/packages/auth-better-auth/src/index.ts +38 -0
  66. package/template/packages/auth-supabase/package.json +1 -1
  67. package/template/packages/auth-supabase/src/index.test.ts +86 -2
  68. package/template/packages/auth-supabase/src/index.ts +42 -2
  69. package/template/packages/cache/package.json +1 -1
  70. package/template/packages/cache-redis/package.json +1 -1
  71. package/template/packages/core/package.json +1 -1
  72. package/template/packages/core/src/index.ts +1 -0
  73. package/template/packages/core/src/request-body.test.ts +36 -0
  74. package/template/packages/core/src/request-body.ts +29 -0
  75. package/template/packages/db/package.json +2 -1
  76. package/template/packages/db/src/index.ts +7 -1
  77. package/template/packages/db/src/users.test.ts +39 -0
  78. package/template/packages/db/src/users.ts +27 -0
  79. package/template/packages/email/package.json +1 -1
  80. package/template/packages/email-resend/package.json +1 -1
  81. package/template/packages/email-smtp/package.json +1 -1
  82. package/template/packages/jobs/package.json +1 -1
  83. package/template/packages/jobs-pg-boss/package.json +1 -1
  84. package/template/packages/local-db/package.json +1 -1
  85. package/template/packages/observability/package.json +1 -1
  86. package/template/packages/observability/src/index.test.ts +67 -0
  87. package/template/packages/observability/src/index.ts +56 -0
  88. package/template/packages/observability-otel/package.json +1 -1
  89. package/template/packages/observability-sentry/package.json +8 -3
  90. package/template/packages/observability-sentry/src/browser.ts +36 -0
  91. package/template/packages/observability-sentry/src/sourcemaps.cjs +52 -0
  92. package/template/packages/observability-sentry/src/sourcemaps.d.cts +10 -0
  93. package/template/packages/observability-sentry/src/sourcemaps.test.ts +56 -0
  94. package/template/packages/observability-sentry/src/vite.d.mts +3 -0
  95. package/template/packages/observability-sentry/src/vite.mjs +21 -0
  96. package/template/packages/payments/package.json +1 -1
  97. package/template/packages/payments/src/billing.test.ts +2 -1
  98. package/template/packages/payments/src/billing.ts +15 -10
  99. package/template/packages/payments/src/index.ts +1 -0
  100. package/template/packages/payments-stripe/package.json +1 -1
  101. package/template/packages/storage/package.json +1 -1
  102. package/template/packages/storage-s3/package.json +1 -1
  103. package/template/packages/storage-supabase/package.json +1 -1
  104. package/template/packages/ui/package.json +1 -1
  105. package/template/playwright.config.ts +15 -2
  106. package/template/pnpm-lock.yaml +172 -0
  107. package/template.lock.json +29 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.16.0
6
+
7
+ - Default to Starter `v0.14.0`, which adds:
8
+ - An account settings page at `/settings` when an auth provider is selected: display name, password change (signing out other sessions), and account deletion. Supabase projects set `SUPABASE_SECRET_KEY` to allow deletion.
9
+ - Browser error reporting with observability on: uncaught errors, unhandled rejections, and render errors caught by the new error pages, through `/api/client-errors` (`otel`) or Sentry's browser SDK (`sentry`). Sentry builds with `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` upload source maps and then delete them.
10
+ - Auth emails sent through the background-jobs queue, with retries, when both email and jobs are selected.
11
+ - None of this needs CLI code: it all comes from the Starter's manifest.
12
+
5
13
  ## 0.15.0
6
14
 
7
15
  - Add `status`: a project's Starter release, framework, modules, and Git state, whether a newer Starter release exists (from npm, still working offline), and the command to run next.
package/README.md CHANGED
@@ -127,7 +127,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
127
127
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
128
128
 
129
129
  # Reproduce a specific Starter release or commit
130
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.13.1
130
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.14.0
131
131
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
132
132
 
133
133
  # Test local Starter changes before releasing them
@@ -146,7 +146,7 @@ pnpm clubedge add email resend payments stripe # several modules as
146
146
  pnpm clubedge remove storage payments
147
147
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
148
148
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
149
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.13.1 # or a specific tag, branch, or commit
149
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.14.0 # or a specific tag, branch, or commit
150
150
  ```
151
151
 
152
152
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -177,27 +177,27 @@ pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and p
177
177
  Each CLI release is tested against one specific Starter release:
178
178
 
179
179
  ```text
180
- create-clubedge-app v0.15.0
180
+ create-clubedge-app v0.16.0
181
181
  |
182
182
  v
183
- Clubedge Starter v0.13.1
184
- commit 28bfbf98c85ae500ace6a062dd10d67746e1a330
183
+ Clubedge Starter v0.14.0
184
+ commit 2219a21c2eb237b7d8ee004ea8bbeb098b70106e
185
185
  ```
186
186
 
187
187
  The current relationship is:
188
188
 
189
189
  | CLI release | Default Starter release | Meaning |
190
190
  | --- | --- | --- |
191
- | `create-clubedge-app@0.15.0` | `clubedge-starter@v0.13.1` | The CLI's required, tested default |
191
+ | `create-clubedge-app@0.16.0` | `clubedge-starter@v0.14.0` | The CLI's required, tested default |
192
192
 
193
193
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
194
194
 
195
- The `v0.13.1` tag is expected to resolve to commit
196
- `28bfbf98c85ae500ace6a062dd10d67746e1a330`. The CLI verifies this before
195
+ The `v0.14.0` tag is expected to resolve to commit
196
+ `2219a21c2eb237b7d8ee004ea8bbeb098b70106e`. The CLI verifies this before
197
197
  scaffolding with the default reference and fails rather than silently using a
198
198
  moved tag.
199
199
 
200
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.13.1`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
200
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.14.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
201
201
 
202
202
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
203
203
 
@@ -252,7 +252,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
252
252
  1. Make and validate changes in `clubedge-starter`.
253
253
  2. Run lint, typecheck, unit tests, browser tests, and production build.
254
254
  3. Update the Starter package versions.
255
- 4. Commit `main`, create a release tag such as `v0.13.1`, and publish the Starter release.
255
+ 4. Commit `main`, create a release tag such as `v0.14.0`, and publish the Starter release.
256
256
 
257
257
  ### CLI maintainers
258
258
 
@@ -277,6 +277,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
277
277
  | 0.13.0 | v0.12.0 | Background jobs |
278
278
  | 0.14.0 | v0.13.0 | Observability |
279
279
  | 0.15.0 | v0.13.1 | `status` and `doctor`, several modules in one change, conflict and upgrade summaries, macOS CI |
280
+ | 0.16.0 | v0.14.0 | Account settings page, browser error reporting and Sentry source maps, auth emails through the jobs queue |
280
281
  | next | | Show the Starter's version of a conflicted file next to yours |
281
282
 
282
283
  ## Community
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.13.1",
26
- "starterCommit": "28bfbf98c85ae500ace6a062dd10d67746e1a330"
25
+ "starterRef": "v0.14.0",
26
+ "starterCommit": "2219a21c2eb237b7d8ee004ea8bbeb098b70106e"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -16,6 +16,8 @@ NEXT_PUBLIC_SUPABASE_URL= # clubedge:only infra!=supabase
16
16
  # Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
17
17
  NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
18
18
  NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
19
+ # Optional, server-side only: lets users delete their account from the settings page.
20
+ SUPABASE_SECRET_KEY=
19
21
  # clubedge:end
20
22
 
21
23
  # clubedge:if auth=better-auth
@@ -82,6 +84,10 @@ OTEL_SERVICE_NAME=
82
84
  SENTRY_DSN=
83
85
  SENTRY_ENVIRONMENT=
84
86
  SENTRY_TRACES_SAMPLE_RATE=
87
+ # Build time only: with an auth token, the build uploads browser source maps to this project.
88
+ # SENTRY_AUTH_TOKEN=
89
+ # SENTRY_ORG=
90
+ # SENTRY_PROJECT=
85
91
  # clubedge:end
86
92
 
87
93
  # clubedge:if storage=s3
@@ -57,6 +57,7 @@ Every combination in the CI matrix is generated as a real project and runs its f
57
57
  - Better Auth with users and sessions in your own PostgreSQL, secure session cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=better-auth -->
58
58
  - Password reset by email, and email verification that can be required before sign-in. <!-- clubedge:only auth=better-auth && email!=none -->
59
59
  - Password reset by email, sent by Supabase. <!-- clubedge:only auth=supabase -->
60
+ - An account settings page: display name, password change, and account deletion. <!-- clubedge:only auth!=none -->
60
61
  - Email over SMTP, for any provider or a local inbox; development prints emails to the server log until SMTP is configured. <!-- clubedge:only email=smtp -->
61
62
  - Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
62
63
  - S3-compatible storage adapter for AWS S3, Cloudflare R2, and MinIO. <!-- clubedge:only storage=s3 -->
@@ -276,13 +277,18 @@ Local services:
276
277
  - `pnpm infra:up`: run only the local database, for example for `pnpm check:services` or browser tests. <!-- clubedge:only infra=local -->
277
278
  - `pnpm infra:reset`: delete the local database and files in `.data/` (stop `pnpm dev` first). <!-- clubedge:only infra=local -->
278
279
 
280
+ Check the project:
281
+
282
+ - `pnpm clubedge status`: the project's Starter release, modules, and Git state, whether a newer Starter release exists, and the command to run next.
283
+ - `pnpm clubedge doctor`: check Node.js, pnpm, the dependencies, the lockfile, and `apps/web/.env.local` against the example, then run `pnpm check:services`. It changes nothing and exits with an error when a check fails.
284
+
279
285
  Change the project later:
280
286
 
281
- - `pnpm clubedge add <module> <option>`: add a module or switch its option, for example `pnpm clubedge add auth better-auth` or `pnpm clubedge add infra local`. Run `pnpm clubedge add` without arguments to choose from a list.
282
- - `pnpm clubedge remove <module>`: remove a module, for example `pnpm clubedge remove storage`.
283
- - `pnpm clubedge upgrade`: move the project to the newest Starter release.
287
+ - `pnpm clubedge add <module> <option>`: add a module or switch its option, for example `pnpm clubedge add auth better-auth` or `pnpm clubedge add infra local`. Name several to change them together, as in `pnpm clubedge add email resend payments stripe`. Run `pnpm clubedge add` without arguments to choose from a list.
288
+ - `pnpm clubedge remove <module>`: remove one or more modules, for example `pnpm clubedge remove storage`.
289
+ - `pnpm clubedge upgrade`: move the project to the newest Starter release. It first lists the releases in between.
284
290
 
285
- These commands merge the change into your code instead of overwriting it: files you never touched are updated, your edits are kept, and lines you both changed get conflict markers. Commit first, because they need a clean Git tree so that `git diff` shows the result. Add `--dry-run` to preview a change.
291
+ These commands merge the change into your code instead of overwriting it: files you never touched are updated, your edits are kept, and lines you both changed get conflict markers, listed per file afterward. Commit first, because they need a clean Git tree so that `git diff` shows the result. Add `--dry-run` to preview a change.
286
292
 
287
293
  ## Documentation
288
294
 
package/template/SETUP.md CHANGED
@@ -66,7 +66,8 @@ The app is available at <http://localhost:3000>, the liveness endpoint at <http:
66
66
 
67
67
  ## Supabase Auth
68
68
 
69
- Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase; never use a service role key.
69
+ Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase, and the publishable key is all sign-in needs. To let users delete their account from the settings page, also set `SUPABASE_SECRET_KEY` to a secret key from the same settings; it stays on the server, so never give it a `NEXT_PUBLIC_` prefix. <!-- clubedge:only framework=next -->
70
+ Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase, and the publishable key is all sign-in needs. To let users delete their account from the settings page, also set `SUPABASE_SECRET_KEY` to a secret key from the same settings; it stays on the server. <!-- clubedge:only framework=tanstack-start -->
70
71
 
71
72
  In Supabase Auth URL configuration, add this redirect URL for local email confirmation and password reset links:
72
73
 
@@ -96,6 +97,8 @@ Better Auth sends its emails through the [email module](#email): <!-- clubedge:o
96
97
  - **Password reset:** "Forgot password?" on the login page emails a link to `/reset-password`, valid for one hour and usable once. <!-- clubedge:only email!=none -->
97
98
  - **Email verification:** every new account gets a confirmation link. Sign-up signs the user in right away unless `BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=true`, which refuses sign-in until the link is opened. <!-- clubedge:only email!=none -->
98
99
 
100
+ These emails are sent by the `send-email` [background job](#background-jobs), so sign-up never waits for the email provider, and a failed send is retried with backoff. <!-- clubedge:only email!=none && jobs!=none -->
101
+
99
102
  <!-- clubedge:end -->
100
103
 
101
104
  ## Local services
@@ -260,7 +263,8 @@ export const welcomeEmail = defineJob<{ userId: string }>({
260
263
  });
261
264
  ```
262
265
 
263
- Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own.
266
+ Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. To send any email in the background, enqueue `sendEmailJob` from `@/jobs` with the message, as the auth emails do. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own. <!-- clubedge:only email!=none -->
267
+ Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own. <!-- clubedge:only email=none -->
264
268
 
265
269
  Where no server keeps running, as on Vercel and Netlify, there is no worker: `JOBS_WORKER` defaults to `off` there, and you can set it yourself anywhere. Set `CRON_SECRET` to 16 or more random characters and call `GET /api/jobs/run` every minute or so with the header `Authorization: Bearer <CRON_SECRET>`, from Vercel Cron, a Netlify scheduled function, or any scheduler. Each call runs the jobs that are due. Scheduled jobs need a worker.
266
270
 
@@ -291,10 +295,14 @@ docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:latest
291
295
  Create a Node.js project in [Sentry](https://sentry.io) and set `SENTRY_DSN` to its DSN. `SENTRY_TRACES_SAMPLE_RATE` is the share of requests traced, from 0 to 1 (default 0.1; use 1 while trying it out), and `SENTRY_ENVIRONMENT` labels events (default `NODE_ENV`). The SDK sends no user details, cookies, query strings, or request bodies. The spans Next.js and pg-boss create join the same traces. <!-- clubedge:only framework=next -->
292
296
  Create a Node.js project in [Sentry](https://sentry.io) and set `SENTRY_DSN` to its DSN. `SENTRY_TRACES_SAMPLE_RATE` is the share of requests traced, from 0 to 1 (default 0.1; use 1 while trying it out), and `SENTRY_ENVIRONMENT` labels events (default `NODE_ENV`). The SDK sends no user details, cookies, query strings, or request bodies. <!-- clubedge:only framework=tanstack-start -->
293
297
 
298
+ To see your own code in browser stack traces, give the build `SENTRY_AUTH_TOKEN` (an auth token with the `project:releases` scope), `SENTRY_ORG`, and `SENTRY_PROJECT`. The build then makes source maps for the browser bundle, uploads them to Sentry, and deletes them, so they are never served. Without the token the build makes no source maps. Set the three variables where the build runs: in CI, or as build-time variables on your platform.
299
+
294
300
  <!-- clubedge:end -->
295
301
  <!-- clubedge:if observability!=none -->
296
302
 
297
- Only server-side errors are reported; errors in the browser are not. On Vercel and Netlify, a function can stop right after it responds, so spans still waiting in a batch at that moment can be lost.
303
+ Uncaught errors in the browser are reported too, with the page's path and no query string. With OpenTelemetry, the browser sends them to `/api/client-errors`, which reports them like server errors and accepts 30 reports a minute per address. With Sentry, Sentry's browser SDK sends them directly; it is downloaded only when `SENTRY_DSN` is set. In both cases the browser reads its settings from `/api/client-errors` at runtime, so builds never bake them in.
304
+
305
+ On Vercel and Netlify, a function can stop right after it responds, so spans still waiting in a batch at that moment can be lost.
298
306
 
299
307
  Observability goes through the provider-neutral `ObservabilityProvider` interface in `packages/observability` and the telemetry hooks in `@clubedge/core`. To use another tool, implement that interface in a new package and install it from `apps/web/src/server/observability.ts`; error reports and spans across the app stay the same.
300
308
 
@@ -433,6 +441,7 @@ Production needs these variables:
433
441
  - `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=next && auth=supabase -->
434
442
  - `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=tanstack-start && auth=supabase -->
435
443
  - `BETTER_AUTH_SECRET`, at least 32 random characters, different from development. <!-- clubedge:only auth=better-auth -->
444
+ - `SUPABASE_SECRET_KEY`, if users should be able to delete their account. <!-- clubedge:only auth=supabase -->
436
445
  - `SMTP_URL` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=smtp -->
437
446
  - `RESEND_API_KEY` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=resend -->
438
447
  - `STRIPE_SECRET_KEY` (live mode), `STRIPE_WEBHOOK_SECRET` from the production webhook endpoint, and `STRIPE_PRICE_<PLAN>` for each plan. <!-- clubedge:only payments=stripe -->
@@ -445,6 +454,8 @@ Production needs these variables:
445
454
 
446
455
  ## Checks
447
456
 
457
+ `pnpm clubedge doctor` checks that the project can run: Node.js and pnpm versions, installed dependencies, the lockfile, and `apps/web/.env.local` against the example, followed by `pnpm check:services`. Start there when something does not work.
458
+
448
459
  Run the checks relevant to your change before opening a pull request:
449
460
 
450
461
  ```sh
@@ -18,6 +18,8 @@ SUPABASE_URL= # clubedge:only infra!=supabase
18
18
  # Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
19
19
  SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
20
20
  SUPABASE_PUBLISHABLE_KEY=
21
+ # Optional: lets users delete their account from the settings page.
22
+ SUPABASE_SECRET_KEY=
21
23
  # clubedge:end
22
24
 
23
25
  # clubedge:if auth=better-auth
@@ -84,6 +86,10 @@ OTEL_SERVICE_NAME=
84
86
  SENTRY_DSN=
85
87
  SENTRY_ENVIRONMENT=
86
88
  SENTRY_TRACES_SAMPLE_RATE=
89
+ # Build time only: with an auth token, the build uploads browser source maps to this project.
90
+ # SENTRY_AUTH_TOKEN=
91
+ # SENTRY_ORG=
92
+ # SENTRY_PROJECT=
87
93
  # clubedge:end
88
94
 
89
95
  # clubedge:if storage=s3
@@ -5,7 +5,7 @@ This app mirrors `apps/web` with TanStack Start. Follow the root `AGENTS.md`, pl
5
5
  - Read the skills bundled with the installed packages before changing framework code: `node_modules/@tanstack/react-start/skills/` and `node_modules/@tanstack/start-client-core/skills/`. They match the installed version; APIs change often.
6
6
  - Route loaders run on the server and in the browser. Keep database, provider, and secret access in `src/server/`, reached from loaders through `createServerFn` (see `src/server/auth.functions.ts`).
7
7
  - Every server function and server route that touches private data must check the user itself. A route guard only protects the page, not the endpoint.
8
- - Modules in `src/server/` start with `import "@tanstack/react-start/server-only";` so they can never reach the browser bundle.
8
+ - Modules in `src/server/` start with `import "@tanstack/react-start/server-only";` so they can never reach the browser bundle. The exception is `*.functions.ts`: routes import their `createServerFn` functions, and the Start compiler replaces each handler with a call to the server in the browser build. Keep them thin, importing the server-only modules they call.
9
9
  - Forms post to server routes that answer with 303 redirects (`src/server/auth-forms.ts`), so they work before hydration. Do not throw a router `redirect` from a POST handler; it uses 307 and repeats the POST.
10
10
  - Search params are plain strings (`src/router.tsx`). Build login links with `loginUrl()` so they match the Next.js app.
11
11
  - `src/routeTree.gen.ts` is generated by `vite dev` and `vite build`. Commit it, but do not edit it by hand.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,25 +30,31 @@ export function AuthForm({ action, children }: { action: string; children: React
30
30
  export function PasswordField({
31
31
  isSignUp,
32
32
  label = "Password",
33
+ name = "password",
34
+ minLength = 8,
33
35
  }: {
34
36
  isSignUp: boolean;
35
37
  label?: string;
38
+ /** The field's name and id; settings forms have more than one password field. */
39
+ name?: string;
40
+ /** 1 for fields that confirm an existing password, which may predate the current rules. */
41
+ minLength?: number;
36
42
  }) {
37
43
  const [visible, setVisible] = useState(false);
38
44
  const pending = useContext(PendingContext);
39
45
 
40
46
  return (
41
47
  <div className="grid gap-2">
42
- <Label htmlFor="password">{label}</Label>
48
+ <Label htmlFor={name}>{label}</Label>
43
49
  <div className="relative">
44
50
  <Input
45
- aria-describedby={isSignUp ? "password-hint" : undefined}
51
+ aria-describedby={isSignUp ? `${name}-hint` : undefined}
46
52
  autoComplete={isSignUp ? "new-password" : "current-password"}
47
53
  className="h-11 pr-11"
48
- id="password"
54
+ id={name}
49
55
  maxLength={128}
50
- minLength={8}
51
- name="password"
56
+ minLength={minLength}
57
+ name={name}
52
58
  placeholder={isSignUp ? "Create a password" : "Enter your password"}
53
59
  // readOnly rather than disabled: disabled fields are left out of the submitted form.
54
60
  readOnly={pending}
@@ -70,7 +76,7 @@ export function PasswordField({
70
76
  </button>
71
77
  </div>
72
78
  {isSignUp && (
73
- <p className="text-xs text-muted-foreground" id="password-hint">
79
+ <p className="text-xs text-muted-foreground" id={`${name}-hint`}>
74
80
  Use at least 8 characters.
75
81
  </p>
76
82
  )}
@@ -99,11 +105,24 @@ export function EmailField() {
99
105
  );
100
106
  }
101
107
 
102
- export function SubmitButton({ children, disabled }: { children: ReactNode; disabled?: boolean }) {
108
+ export function SubmitButton({
109
+ children,
110
+ disabled,
111
+ variant,
112
+ }: {
113
+ children: ReactNode;
114
+ disabled?: boolean;
115
+ variant?: "default" | "destructive";
116
+ }) {
103
117
  const pending = useContext(PendingContext);
104
118
 
105
119
  return (
106
- <Button className="h-11 w-full justify-center" disabled={disabled || pending} type="submit">
120
+ <Button
121
+ className="h-11 w-full justify-center"
122
+ disabled={disabled || pending}
123
+ type="submit"
124
+ variant={variant}
125
+ >
107
126
  {pending ? (
108
127
  <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" />
109
128
  ) : null}
@@ -1,7 +1,8 @@
1
1
  import { Link } from "@tanstack/react-router";
2
2
 
3
3
  import type { AuthUser } from "@/server/auth";
4
- import { Button } from "@clubedge/ui/components/button";
4
+ import { Button, buttonVariants } from "@clubedge/ui/components/button";
5
+ import { cn } from "@clubedge/ui/lib/utils";
5
6
 
6
7
  type AccountButtonProps = {
7
8
  user: AuthUser | null;
@@ -11,23 +12,28 @@ type AccountButtonProps = {
11
12
  export function AccountButton({ user, className }: AccountButtonProps) {
12
13
  if (!user) {
13
14
  return (
14
- <Button className={className} render={<Link to="/login" />} size="sm" variant="outline">
15
+ <Link to="/login" className={buttonVariants({ className, size: "sm", variant: "outline" })}>
15
16
  Sign in
16
- </Button>
17
+ </Link>
17
18
  );
18
19
  }
19
20
 
20
21
  return (
21
- <form action="/auth/sign-out" className={className} method="post">
22
- <Button
23
- className="w-full"
24
- size="sm"
25
- title={user.email ?? undefined}
26
- type="submit"
27
- variant="outline"
28
- >
29
- Sign out
30
- </Button>
31
- </form>
22
+ <div className={cn("flex items-center gap-2", className)}>
23
+ <Link to="/settings" className={buttonVariants({ size: "sm", variant: "ghost" })}>
24
+ Settings
25
+ </Link>
26
+ <form action="/auth/sign-out" className="flex-1" method="post">
27
+ <Button
28
+ className="w-full"
29
+ size="sm"
30
+ title={user.email ?? undefined}
31
+ type="submit"
32
+ variant="outline"
33
+ >
34
+ Sign out
35
+ </Button>
36
+ </form>
37
+ </div>
32
38
  );
33
39
  }
@@ -0,0 +1,49 @@
1
+ import { useEffect } from "react";
2
+
3
+ const MAX_REPORTS = 10;
4
+ let sent = 0;
5
+
6
+ type Kind = "error" | "unhandledrejection" | "render";
7
+
8
+ function describe(reason: unknown) {
9
+ if (reason instanceof Error)
10
+ return { name: reason.name, message: reason.message, stack: reason.stack };
11
+ return { name: "Error", message: String(reason) };
12
+ }
13
+
14
+ function send(kind: Kind, reason: unknown) {
15
+ if (sent >= MAX_REPORTS) return;
16
+ sent++;
17
+ const body = JSON.stringify({ kind, ...describe(reason), path: window.location.pathname });
18
+ // keepalive lets the report finish even if the page is being closed.
19
+ fetch("/api/client-errors", {
20
+ method: "POST",
21
+ body,
22
+ headers: { "content-type": "application/json" },
23
+ keepalive: true,
24
+ }).catch(() => {});
25
+ }
26
+
27
+ /** Reports a render error that an error boundary caught, so it never reaches the window. */
28
+ export function reportBrowserError(error: unknown) {
29
+ send("render", error);
30
+ }
31
+
32
+ /**
33
+ * Sends uncaught errors and unhandled promise rejections from the browser to /api/client-errors,
34
+ * which reports them like server errors. At most a few per page load.
35
+ */
36
+ export function ErrorReporting() {
37
+ useEffect(() => {
38
+ const onError = (event: ErrorEvent) => send("error", event.error ?? event.message);
39
+ const onRejection = (event: PromiseRejectionEvent) => send("unhandledrejection", event.reason);
40
+ // Capture phase: runs before handlers that might stop the event, such as a dev overlay.
41
+ window.addEventListener("error", onError, true);
42
+ window.addEventListener("unhandledrejection", onRejection, true);
43
+ return () => {
44
+ window.removeEventListener("error", onError, true);
45
+ window.removeEventListener("unhandledrejection", onRejection, true);
46
+ };
47
+ }, []);
48
+ return null;
49
+ }
@@ -0,0 +1,35 @@
1
+ // clubedge:if observability!=none
2
+ import { useEffect } from "react";
3
+ // clubedge:end
4
+ import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
5
+ import { Button } from "@clubedge/ui/components/button";
6
+ // clubedge:if observability!=none
7
+ import { reportBrowserError } from "@/components/error-reporting";
8
+ // clubedge:end
9
+
10
+ /** Shown in place of a page that failed to render. */
11
+ export function RouteError(props: ErrorComponentProps) {
12
+ const router = useRouter();
13
+ // clubedge:if observability!=none
14
+ // Error boundaries stop render errors before they reach the window, so report them here.
15
+ useEffect(() => {
16
+ reportBrowserError(props.error);
17
+ }, [props.error]);
18
+ // clubedge:end
19
+ return (
20
+ <main className="flex min-h-svh flex-col items-center justify-center gap-4 bg-background px-4 text-center">
21
+ <h1 className="text-xl font-semibold">Something went wrong</h1>
22
+ <p className="max-w-sm text-sm text-muted-foreground">
23
+ This page could not be shown. Try again, or come back in a moment.
24
+ </p>
25
+ <Button
26
+ onClick={() => {
27
+ props.reset();
28
+ void router.invalidate();
29
+ }}
30
+ >
31
+ Try again
32
+ </Button>
33
+ </main>
34
+ );
35
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect } from "react";
2
+ import type { BrowserErrorReporting } from "@clubedge/observability";
3
+
4
+ type Capture = (error: unknown) => void;
5
+ let reporting: Promise<Capture | null> | undefined;
6
+
7
+ // The settings come from /api/client-errors at runtime, and Sentry's browser SDK is only
8
+ // downloaded when a DSN is configured.
9
+ function startReporting() {
10
+ reporting ??= fetch("/api/client-errors")
11
+ .then((response) => (response.ok ? (response.json() as Promise<BrowserErrorReporting>) : null))
12
+ .then(async (config) => {
13
+ if (config?.provider !== "sentry") return null;
14
+ const { captureBrowserError, startBrowserErrorReporting } =
15
+ await import("@clubedge/observability-sentry/browser");
16
+ startBrowserErrorReporting(config);
17
+ return captureBrowserError;
18
+ })
19
+ .catch(() => null);
20
+ return reporting;
21
+ }
22
+
23
+ /** Reports a render error that an error boundary caught, so it never reaches the window. */
24
+ export function reportBrowserError(error: unknown) {
25
+ void startReporting().then((capture) => capture?.(error));
26
+ }
27
+
28
+ /** Reports uncaught browser errors to Sentry. */
29
+ export function ErrorReporting() {
30
+ useEffect(() => {
31
+ void startReporting();
32
+ }, []);
33
+ return null;
34
+ }
@@ -10,11 +10,14 @@ import { getAppUrl } from "./server";
10
10
  const supabaseAuthSchema = z.object({
11
11
  SUPABASE_URL: optional(z.url()),
12
12
  SUPABASE_PUBLISHABLE_KEY: optional(z.string()),
13
+ // Lets users delete their account from the settings page.
14
+ SUPABASE_SECRET_KEY: optional(z.string()),
13
15
  });
14
16
 
15
17
  export interface SupabaseAuthEnv {
16
18
  url: string;
17
19
  publishableKey: string;
20
+ secretKey?: string;
18
21
  }
19
22
 
20
23
  /** Supabase Auth settings, or null while they are not configured. */
@@ -22,7 +25,12 @@ export function getSupabaseAuthEnv(): SupabaseAuthEnv | null {
22
25
  const env = parseEnv(supabaseAuthSchema, "Supabase Auth");
23
26
  const url = env.SUPABASE_URL;
24
27
  const publishableKey = env.SUPABASE_PUBLISHABLE_KEY;
25
- return url && publishableKey ? { url, publishableKey } : null;
28
+ if (!url || !publishableKey) return null;
29
+ return {
30
+ url,
31
+ publishableKey,
32
+ ...(env.SUPABASE_SECRET_KEY ? { secretKey: env.SUPABASE_SECRET_KEY } : {}),
33
+ };
26
34
  }
27
35
  // clubedge:end
28
36
 
@@ -1,7 +1,31 @@
1
1
  import type { JobDefinition } from "@clubedge/jobs";
2
+ // clubedge:if email!=none
3
+ import type { EmailMessage } from "@clubedge/email";
4
+ import { defineJob } from "@clubedge/jobs";
5
+ import { email } from "@/server/email";
6
+ // clubedge:end
2
7
  import { sampleJob } from "./sample";
3
8
 
4
9
  export { sampleJob };
5
10
 
11
+ // clubedge:if email!=none
12
+ /**
13
+ * Sends an email in the background. Auth emails go through it, so a slow or failing email
14
+ * provider never delays sign-up, and failed sends are retried with backoff.
15
+ */
16
+ export const sendEmailJob = defineJob<EmailMessage>({
17
+ name: "send-email",
18
+ retryLimit: 5,
19
+ retryDelaySeconds: 30,
20
+ retryBackoff: true,
21
+ async run(message) {
22
+ await email.send(message);
23
+ },
24
+ });
25
+
26
+ // clubedge:end
6
27
  /** Every job the worker runs. Define a job in this folder, then add it here. */
7
28
  export const jobs: JobDefinition[] = [sampleJob];
29
+ // clubedge:if email!=none
30
+ jobs.push(sendEmailJob);
31
+ // clubedge:end
@@ -1,6 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
3
3
  import { TooltipProvider } from "@clubedge/ui/components/tooltip";
4
+ // clubedge:if observability!=none
5
+ import { ErrorReporting } from "@/components/error-reporting";
6
+ // clubedge:end
7
+ import { RouteError } from "@/components/route-error";
4
8
  import { ThemeProvider } from "@/components/theme/theme-provider";
5
9
  import { siteConfig } from "@/config/site";
6
10
  import appCss from "../styles.css?url";
@@ -32,6 +36,7 @@ export const Route = createRootRoute({
32
36
  }),
33
37
  shellComponent: RootDocument,
34
38
  component: RootComponent,
39
+ errorComponent: RouteError,
35
40
  });
36
41
 
37
42
  function RootComponent() {
@@ -39,6 +44,9 @@ function RootComponent() {
39
44
  <ThemeProvider>
40
45
  <TooltipProvider>
41
46
  <Outlet />
47
+ {/* clubedge:if observability!=none */}
48
+ <ErrorReporting />
49
+ {/* clubedge:end */}
42
50
  </TooltipProvider>
43
51
  </ThemeProvider>
44
52
  );