create-ailk 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +148 -21
  73. package/templates/database/CHANGELOG.md +14 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -1,13 +1,221 @@
1
- // account-config.ts — Experiment registry for the /account Results section (#4984)
2
- //
3
- // Mirrors `nav-config.ts`: the OSS template declares NO experiments — nothing
4
- // in `apps/web` runs a diagnostic today, so this list ships empty and the
5
- // Results section renders its empty state. A consuming site lists the
6
- // `experimentKey`s of the diagnostics it runs (the `key` of each
7
- // `waitlistExperiment` it registers) so `/account` can read the signed-in
8
- // respondent's own newest signup for each via `waitlistSignups.me(key)`
9
- // (`GET /v1/waitlist-signups/me?experimentKey=…`, #4731 D5). Keys the caller
10
- // has no signup for come back 404 and are skipped, so listing every
11
- // experiment a site runs is safe.
1
+ // account-config.ts — what the /account area is made of (#4984, #7429).
2
+ //
3
+ // The module contribution contract (spec 7423 D8, D8a): a module extends a
4
+ // site's account area by adding entries to the three arrays below — Inbox
5
+ // item kinds, User-settings sections and For-you sources — never a rail
6
+ // entry. The contract's types live in `@working-theory/templates/account`; a
7
+ // kind's label is a message key in the `account` namespace, and a For-you
8
+ // item's strings are built through the context's `t`.
9
+ //
10
+ // `accountExperimentKeys` — mirrors `nav-config.ts`: the OSS template
11
+ // declares NO experiments — nothing in `apps/web` runs a diagnostic today, so
12
+ // this list ships empty and the Inbox carries no `result` rows. A consuming
13
+ // site lists the `experimentKey`s of the diagnostics it runs (the `key` of
14
+ // each `waitlistExperiment` it registers) so `/account` can read the
15
+ // signed-in respondent's own newest signup for each via
16
+ // `waitlistSignups.me(key)` (`GET /v1/waitlist-signups/me?experimentKey=…`,
17
+ // #4731 D5). Keys the caller has no signup for come back 404 and are
18
+ // skipped, so listing every experiment a site runs is safe.
19
+ //
20
+ // The base contributions (D9, amended 2026-10-01 by D6a/D8a — #7448) use
21
+ // existing reads only, each with the caller's cookie forwarded (`ctx.init`):
22
+ //
23
+ // - Inbox kinds `result` and `purchase` — rows of the Recents and All tabs.
24
+ // - For-you sources `email-unverified` and `payment-due` — needs-you items
25
+ // with an Open action to User settings (General, Billing), never Inbox
26
+ // rows. Neither read carries the time the condition began, so their time
27
+ // line is the date of the read rather than an invented "Waiting N".
28
+ //
29
+ // A 404 means "nothing for this caller" — no signup / no user row / commerce
30
+ // module off — and loads `[]`; so does a 401/403 while the email is
31
+ // unverified (the `email-unverified` item already says so). Any other
32
+ // failure rejects — a 401/403 for a verified email included — so the area
33
+ // shows an error row with Retry instead of an empty Inbox (D12). Scheduled
34
+ // (the old Upcoming) stays empty in the base area; a module fills it.
35
+
36
+ import {
37
+ flowCheckouts,
38
+ subscription,
39
+ waitlistSignups,
40
+ type FlowCheckoutMeItem,
41
+ type WaitlistSignupMeResponse,
42
+ } from "@working-theory/api-client";
43
+ import type {
44
+ AccountForYouSource,
45
+ AccountInboxKind,
46
+ AccountSettingsSection,
47
+ AccountUser,
48
+ } from "@working-theory/templates/account";
49
+ import { localeHref } from "@working-theory/ui";
50
+ import { formatMoney, formatNumber } from "@working-theory/web-i18n";
51
+
52
+ import { unwrapApiResult } from "./server-api";
12
53
 
13
54
  export const accountExperimentKeys: string[] = [];
55
+
56
+ // ── Reads ─────────────────────────────────────────────────────────────────────
57
+
58
+ /** Structural mirror of `@working-theory/api-client`'s `ApiResult<T>` (not re-exported). */
59
+ type ApiResult<T> =
60
+ { ok: true; body: T } | { ok: false; status: number; body: unknown };
61
+
62
+ /**
63
+ * The body, or `null` when the status means "nothing for this caller" (see
64
+ * the header); any other failure throws `ApiError`.
65
+ */
66
+ async function read<T>(
67
+ result: Promise<ApiResult<T>>,
68
+ user: AccountUser,
69
+ ): Promise<T | null> {
70
+ const res = await result;
71
+ if (!res.ok) {
72
+ if (res.status === 404) return null;
73
+ if (!user.emailVerified && (res.status === 401 || res.status === 403))
74
+ return null;
75
+ }
76
+ return unwrapApiResult(Promise.resolve(res));
77
+ }
78
+
79
+ function formatWhen(iso: string, locale: string): string {
80
+ return new Intl.DateTimeFormat(locale, {
81
+ dateStyle: "medium",
82
+ timeStyle: "short",
83
+ }).format(new Date(iso));
84
+ }
85
+
86
+ // ── Base Inbox kinds (D9) ─────────────────────────────────────────────────────
87
+
88
+ const result: AccountInboxKind<WaitlistSignupMeResponse> = {
89
+ kind: "result",
90
+ label: "results.heading",
91
+ section: "updates",
92
+ async load({ init, user }) {
93
+ // One failed key fails the kind (D12) — never a partial list passed off as whole.
94
+ const signups = await Promise.all(
95
+ accountExperimentKeys.map((key) =>
96
+ read(waitlistSignups.me(key, init), user),
97
+ ),
98
+ );
99
+ return signups
100
+ .filter((s): s is WaitlistSignupMeResponse => s !== null)
101
+ .map((s) => ({
102
+ id: s.experimentKey,
103
+ title: s.experimentKey,
104
+ at: s.completedAt,
105
+ data: s,
106
+ }));
107
+ },
108
+ renderDetail({ data }, { locale, t }) {
109
+ return {
110
+ fields: {
111
+ heading: t("results.heading"),
112
+ items: [
113
+ { label: t("results.experiment"), value: data.experimentKey },
114
+ { label: t("results.waitlist"), value: data.waitlistKey },
115
+ {
116
+ label: t("results.tier"),
117
+ value: data.tier ?? t("results.unavailable"),
118
+ },
119
+ {
120
+ label: t("results.score"),
121
+ value:
122
+ data.score === null
123
+ ? t("results.unavailable")
124
+ : formatNumber(data.score, locale),
125
+ },
126
+ {
127
+ label: t("results.completedAt"),
128
+ value: formatWhen(data.completedAt, locale),
129
+ },
130
+ ],
131
+ },
132
+ };
133
+ },
134
+ };
135
+
136
+ const purchase: AccountInboxKind<FlowCheckoutMeItem> = {
137
+ kind: "purchase",
138
+ label: "purchases.heading",
139
+ section: "updates",
140
+ async load({ init, user }) {
141
+ const checkouts = (await read(flowCheckouts.me(init), user)) ?? [];
142
+ return checkouts.map((c) => ({
143
+ id: c.id,
144
+ title: c.offerKey,
145
+ at: c.completedAt ?? c.createdAt,
146
+ data: c,
147
+ }));
148
+ },
149
+ renderDetail({ data }, { locale, t }) {
150
+ return {
151
+ status: data.status,
152
+ fields: {
153
+ heading: t("purchases.heading"),
154
+ items: [
155
+ { label: t("purchases.offer"), value: data.offerKey },
156
+ {
157
+ label: t("purchases.amount"),
158
+ value: formatMoney(data.amountMinor, data.currency, locale),
159
+ },
160
+ { label: t("purchases.period"), value: data.period },
161
+ {
162
+ label: t("purchases.date"),
163
+ value: formatWhen(data.completedAt ?? data.createdAt, locale),
164
+ },
165
+ ],
166
+ },
167
+ };
168
+ },
169
+ };
170
+
171
+ // ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
172
+
173
+ const emailUnverified: AccountForYouSource = {
174
+ id: "email-unverified",
175
+ async load({ locale, user, t }) {
176
+ if (user.emailVerified) return [];
177
+ return [
178
+ {
179
+ id: "email-unverified",
180
+ type: { id: "email", label: t("profile.email"), tone: "needs-you" },
181
+ title: t("profile.unverified"),
182
+ time: { kind: "at", at: new Date().toISOString() },
183
+ action: { kind: "open", href: localeHref(locale, "/account/settings#general") },
184
+ dismissible: true,
185
+ },
186
+ ];
187
+ },
188
+ };
189
+
190
+ const paymentDue: AccountForYouSource = {
191
+ id: "payment-due",
192
+ async load({ init, locale, t, user }) {
193
+ const sub = await read(subscription.me(init), user);
194
+ // The API's own `actionRequired` decides — the status is never re-derived.
195
+ if (!sub?.actionRequired) return [];
196
+ return [
197
+ {
198
+ id: "payment-due",
199
+ type: { id: "payment", label: t("subscription.action"), tone: "needs-you" },
200
+ title: t("subscription.headline"),
201
+ time: { kind: "at", at: new Date().toISOString() },
202
+ action: { kind: "open", href: localeHref(locale, "/account/settings#billing") },
203
+ dismissible: true,
204
+ },
205
+ ];
206
+ },
207
+ };
208
+
209
+ // ── Contributions ─────────────────────────────────────────────────────────────
210
+
211
+ /** Inbox item kinds, in load order. A module appends its own. */
212
+ export const accountInboxKinds: AccountInboxKind[] = [result, purchase];
213
+
214
+ /** User-settings sections a module adds after the base ones. None in the base area. */
215
+ export const accountSettingsSections: AccountSettingsSection[] = [];
216
+
217
+ /** For-you sources, in load order. A module appends its own. */
218
+ export const accountForYouSources: AccountForYouSource[] = [
219
+ emailUnverified,
220
+ paymentDue,
221
+ ];
@@ -37,11 +37,12 @@ import { createHmac } from "node:crypto";
37
37
 
38
38
  import { authEmail } from "@working-theory/api-client";
39
39
 
40
- /** The three fixed kinds the relay will send. A caller picks no copy. */
40
+ /** The four fixed kinds the relay will send. A caller picks no copy. */
41
41
  export type AuthEmailKind =
42
42
  | "password-reset"
43
43
  | "email-verification"
44
- | "password-changed";
44
+ | "password-changed"
45
+ | "magic-link";
45
46
 
46
47
  export interface AuthEmailPayload {
47
48
  kind: AuthEmailKind;
@@ -313,6 +314,107 @@ export function makeSendPasswordChangedNotice(deps: DispatchDeps) {
313
314
  };
314
315
  }
315
316
 
317
+ /**
318
+ * Magic-link token lifetime (#7435 § D2). Five minutes — the library default,
319
+ * and the number `packages/email`'s magic-link template already promises ("This
320
+ * link expires in 5 minutes"). A longer window would make the email's copy
321
+ * false.
322
+ */
323
+ export const MAGIC_LINK_TTL_SECONDS = 300;
324
+
325
+ /**
326
+ * The narrow slice of Better Auth's endpoint context the magic-link callback
327
+ * touches. Method shorthand, for the reason `AuthEmailPrisma` gives: the real
328
+ * `GenericEndpointContext` satisfies it structurally, and a test passes a
329
+ * plain double.
330
+ */
331
+ export interface MagicLinkEndpointContext {
332
+ context: {
333
+ internalAdapter: {
334
+ findUserByEmail(
335
+ email: string,
336
+ ): Promise<{ user: { id: string; email: string } } | null>;
337
+ };
338
+ runInBackgroundOrAwait(promise: Promise<unknown> | void): unknown;
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Better Auth's `sendMagicLink` (#7435 § D3).
344
+ *
345
+ * WHY THE CALLBACK CHECKS FOR THE USER. The plugin's `POST /sign-in/magic-link`
346
+ * calls this for EVERY address typed into the form; `disableSignUp` is read
347
+ * only by the verify handler. So without this lookup, the plugin would mail a
348
+ * working-looking link to any address at all. The lookup is the same
349
+ * lowercasing `findUserByEmail` the verify handler uses, so "exists" means the
350
+ * same thing at both ends.
351
+ *
352
+ * WHY THE SEND IS DEFERRED. The plugin awaits this callback inline. A relay
353
+ * round-trip for known addresses and nothing for unknown ones would answer the
354
+ * same body at two different latencies — the existence oracle. So both
355
+ * branches cost one indexed read; the send goes to `runInBackgroundOrAwait`,
356
+ * which `auth.ts`'s `backgroundTasks.handler` runs after the response.
357
+ *
358
+ * NOTHING HERE THROWS, so a known and an unknown address both reach the
359
+ * plugin's `{ status: true }`.
360
+ */
361
+ function makeSendMagicLink(deps: DispatchDeps) {
362
+ const { send } = deps;
363
+ const log = deps.log ?? defaultLog;
364
+ return async function sendMagicLink(
365
+ data: { email: string; url: string; token: string },
366
+ ctx?: MagicLinkEndpointContext,
367
+ ): Promise<void> {
368
+ let found: { user: { id: string; email: string } } | null;
369
+ try {
370
+ if (!ctx) throw new Error("no endpoint context");
371
+ found = await ctx.context.internalAdapter.findUserByEmail(data.email);
372
+ } catch {
373
+ log.warn({
374
+ kind: "magic-link",
375
+ reason: "send_failed",
376
+ errorKind: "lookup_failed",
377
+ });
378
+ return;
379
+ }
380
+
381
+ if (!found) {
382
+ // No address in the line — the PII boundary holds for unknown addresses
383
+ // too, and they are the ones an enumerator types.
384
+ log.info({ kind: "magic-link", reason: "no_account" });
385
+ return;
386
+ }
387
+
388
+ const { user } = found;
389
+ // Not awaited: `runInBackgroundOrAwait` never rejects, and `dispatch`
390
+ // swallows its own failures into a log line.
391
+ void ctx.context.runInBackgroundOrAwait(
392
+ dispatch(
393
+ { kind: "magic-link", userId: user.id, to: user.email, url: data.url },
394
+ { send, log },
395
+ ),
396
+ );
397
+ };
398
+ }
399
+
400
+ /**
401
+ * The options `auth.ts` hands Better Auth's `magicLink()` plugin (#7435 § D2).
402
+ *
403
+ * `disableSignUp: true` is defence in depth behind § D3: even a leaked token
404
+ * for an address with no account cannot create one. `storeToken: "hashed"`
405
+ * keeps a database read from yielding a live sign-in token. `allowedAttempts`
406
+ * is left unset — the plugin consumes atomically regardless and warns on any
407
+ * value but 1.
408
+ */
409
+ export function makeMagicLinkOptions(deps: DispatchDeps) {
410
+ return {
411
+ disableSignUp: true,
412
+ expiresIn: MAGIC_LINK_TTL_SECONDS,
413
+ storeToken: "hashed" as const,
414
+ sendMagicLink: makeSendMagicLink(deps),
415
+ };
416
+ }
417
+
316
418
  /** The production transport — the canonical client, never a bare fetch. */
317
419
  export const authEmailTransport: AuthEmailTransport = (rawBody, headers) =>
318
420
  authEmail.send(rawBody, headers);
@@ -60,11 +60,12 @@ export function isResetAvailable(): boolean {
60
60
  * "google" — Google OAuth (GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET)
61
61
  * "github" — GitHub OAuth (GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET)
62
62
  *
63
- * "email" is reported as active when AUTH_EMAIL_SERVER + AUTH_EMAIL_FROM are
64
- * set so existing consumers can detect intent, but Better Auth core does not
65
- * ship a built-in magic-link provider — the wiring is deferred to a follow-up
66
- * (see spec §7 "Out of Scope"). Treat "email" here as an env-presence signal,
67
- * not a wired provider.
63
+ * "email" is the PASSWORD method's presence flag: AUTH_EMAIL_SERVER +
64
+ * AUTH_EMAIL_FROM (#5962 § D2).
65
+ *
66
+ * "magic-link" is the emailed sign-in link (#7435), reported last, exactly
67
+ * when `magicLinkSendConfigured()` holds. `auth.ts`, `/sign-in` and the
68
+ * `(authed)` gate all read it from here and nowhere else.
68
69
  */
69
70
  export function getActiveProviderIds(): string[] {
70
71
  const active: string[] = [];
@@ -81,9 +82,40 @@ export function getActiveProviderIds(): string[] {
81
82
  active.push("email");
82
83
  }
83
84
 
85
+ if (magicLinkSendConfigured()) {
86
+ active.push("magic-link");
87
+ }
88
+
84
89
  return active;
85
90
  }
86
91
 
92
+ /**
93
+ * True when this deployment can SAFELY email a sign-in link (#7435 § D1).
94
+ * Four conjuncts:
95
+ *
96
+ * AUTH_EMAIL_WEBHOOK_SECRET the send seam — without it the apps/api relay
97
+ * + AUTH_EMAIL_FROM answers 503 and no link is ever delivered.
98
+ * BETTER_AUTH_URL unset means Better Auth derives the emailed
99
+ * link's host from the REQUEST (`auth.ts`
100
+ * § D13). A sign-in credential minted on an
101
+ * attacker-chosen host is an account takeover.
102
+ * BETTER_AUTH_SECRET unset means the dev fallback — a string
103
+ * published in this repository — signs the
104
+ * session the link mints.
105
+ *
106
+ * `AUTH_EMAIL_SERVER` is deliberately NOT a conjunct: it is the password
107
+ * method's presence flag, and a site may run with password sign-in off and
108
+ * the email link on. No new variable and no separate opt-in flag.
109
+ */
110
+ export function magicLinkSendConfigured(): boolean {
111
+ return Boolean(
112
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET &&
113
+ process.env.AUTH_EMAIL_FROM &&
114
+ process.env.BETTER_AUTH_URL &&
115
+ process.env.BETTER_AUTH_SECRET,
116
+ );
117
+ }
118
+
87
119
  /**
88
120
  * Returns true when at least one provider is configured.
89
121
  * Used in middleware to skip Better Auth route setup entirely when no provider
@@ -8,7 +8,11 @@
8
8
  * and `/verify-email`, each under next-intl's `as-needed` prefixing (bare for
9
9
  * the default locale, `/<locale>/…` otherwise).
10
10
  *
11
- * No Next.js import — a pure function, asserted directly by its own suite.
11
+ * `isAccountPath()` (#7430, spec 7423 D4) is the same predicate for the
12
+ * signed-in account area — `/account` and everything under it — which the
13
+ * layout also renders without the marketing chrome.
14
+ *
15
+ * No Next.js import — pure functions, asserted directly by their own suite.
12
16
  */
13
17
 
14
18
  /** The five segments the `(auth)` route group serves. */
@@ -40,7 +44,33 @@ export function isAuthPath(
40
44
  pathname: string | undefined | null,
41
45
  locales: readonly string[],
42
46
  ): boolean {
43
- if (!pathname) return false;
47
+ const first = firstSegment(pathname, locales);
48
+ return AUTH_SEGMENTS.some((segment) => segment === first);
49
+ }
50
+
51
+ /**
52
+ * Whether `pathname` addresses the account area: `/account` or any path
53
+ * under it, bare or locale-prefixed (spec 7423 D4). The same prefix rule and
54
+ * the same fail-safe direction as `isAuthPath()` — an absent or empty
55
+ * pathname answers `false`, so a missing header never strips a marketing
56
+ * page's chrome.
57
+ */
58
+ export function isAccountPath(
59
+ pathname: string | undefined | null,
60
+ locales: readonly string[],
61
+ ): boolean {
62
+ return firstSegment(pathname, locales) === "account";
63
+ }
64
+
65
+ /**
66
+ * The first path segment after an enabled-locale prefix, or `undefined` for
67
+ * an absent or empty pathname.
68
+ */
69
+ function firstSegment(
70
+ pathname: string | undefined | null,
71
+ locales: readonly string[],
72
+ ): string | undefined {
73
+ if (!pathname) return undefined;
44
74
 
45
75
  // Drop a query string / fragment — `x-pathname` carries the path, but a
46
76
  // caller passing a full request path should get the same verdict.
@@ -50,6 +80,5 @@ export function isAuthPath(
50
80
  ? path.replace(new RegExp(`^/(${locales.join("|")})(?=/|$)`), "")
51
81
  : path;
52
82
 
53
- const first = bare.split("/")[1];
54
- return AUTH_SEGMENTS.some((segment) => segment === first);
83
+ return bare.split("/")[1];
55
84
  }
@@ -475,7 +475,16 @@
475
475
  "submit": "Sign in",
476
476
  "orContinue": "Or continue with",
477
477
  "signInFailed": "We couldn't sign you in. Check your details and try again.",
478
- "providerFailed": "We couldn't start sign-in with that provider. Try again."
478
+ "providerFailed": "We couldn't start sign-in with that provider. Try again.",
479
+ "emailLinkSubmit": "Email me a sign-in link",
480
+ "emailLinkDivider": "or",
481
+ "emailLinkSentHeading": "Check your email",
482
+ "emailLinkSentSubhead": "Look for a message from this site.",
483
+ "emailLinkBack": "Back to sign in",
484
+ "emailLinkSentBody": "If an account uses that address, a sign-in link is on its way. It works once and expires in 5 minutes.",
485
+ "emailLinkFailed": "We couldn't send a sign-in link. Try again.",
486
+ "emailLinkRateLimited": "Too many requests. Wait a minute, then try again.",
487
+ "emailLinkExpired": "That sign-in link has expired or was already used. Request a new one."
479
488
  },
480
489
  "auth": {
481
490
  "signUpTitle": "Create an account",
@@ -537,20 +546,145 @@
537
546
  },
538
547
  "account": {
539
548
  "title": "Your account",
540
- "description": "Your profile, results, and purchases.",
541
- "nav": {
542
- "profile": "Profile",
543
- "results": "Results",
544
- "purchases": "Purchases"
549
+ "description": "What changed, what's coming, and what needs you.",
550
+ "frame": {
551
+ "home": "Home",
552
+ "inbox": "Inbox",
553
+ "forYou": "For you",
554
+ "settings": "Settings",
555
+ "signOut": "Log out",
556
+ "help": "Help",
557
+ "contactSupport": "Contact support",
558
+ "helpCenter": "Help center",
559
+ "accountMenu": "Open account menu",
560
+ "tabBar": "Account",
561
+ "sidebar": "Account",
562
+ "collapseSidebar": "Collapse sidebar",
563
+ "expandSidebar": "Expand sidebar",
564
+ "collapseAside": "Collapse panel",
565
+ "expandAside": "Expand panel",
566
+ "openAside": "Open For you",
567
+ "closeAside": "Close For you"
568
+ },
569
+ "forYou": {
570
+ "title": "For you",
571
+ "filter": "Filter",
572
+ "allTypes": "All types",
573
+ "sort": "Sort",
574
+ "mostRelevant": "Most relevant",
575
+ "mostRecent": "Most recent",
576
+ "view": "View",
577
+ "open": "Open",
578
+ "dismiss": "Dismiss",
579
+ "whyItMatters": "Why it matters",
580
+ "expectedOutcome": "Expected outcome",
581
+ "waiting": "Waiting {duration}",
582
+ "empty": "Nothing here right now.",
583
+ "showAll": "Show all",
584
+ "close": "Close",
585
+ "addName": "Add your name",
586
+ "types": {
587
+ "profile": "Profile"
588
+ }
589
+ },
590
+ "inbox": {
591
+ "title": "Inbox",
592
+ "lead": "What changed, and what's coming.",
593
+ "tabs": {
594
+ "recents": "Recents",
595
+ "suggested": "Suggested",
596
+ "scheduled": "Scheduled",
597
+ "all": "All"
598
+ },
599
+ "empty": "Nothing here yet.",
600
+ "error": "We couldn't load everything in your Inbox.",
601
+ "retry": "Retry",
602
+ "back": "Inbox",
603
+ "itemError": "We couldn't load this item."
604
+ },
605
+ "support": {
606
+ "title": "Contact support",
607
+ "lead": "Tell us what you need. We'll reply to {email}.",
608
+ "subject": "Subject",
609
+ "message": "Message",
610
+ "submit": "Send request",
611
+ "submitting": "Sending…",
612
+ "required": "Fill in both the subject and the message.",
613
+ "success": "Thanks — your request is on its way. We'll reply by email.",
614
+ "error": "We couldn't send your request. Try again in a moment.",
615
+ "unavailable": "Requests can't be sent from this site yet. Email the site owner instead."
616
+ },
617
+ "settings": {
618
+ "title": "Settings",
619
+ "lead": "Your profile, sign-in, notifications and privacy.",
620
+ "general": {
621
+ "title": "General",
622
+ "lead": "Your name, email and appearance.",
623
+ "colorMode": "Color mode",
624
+ "light": "Light",
625
+ "auto": "Auto",
626
+ "dark": "Dark",
627
+ "name": "Name",
628
+ "namePlaceholder": "Your name",
629
+ "email": "Email",
630
+ "save": "Save changes",
631
+ "saving": "Saving…",
632
+ "saved": "Saved.",
633
+ "unsaved": "Unsaved changes",
634
+ "clean": "No unsaved changes",
635
+ "nameRequired": "Enter your name.",
636
+ "saveError": "We couldn't save your name. Try again."
637
+ },
638
+ "security": {
639
+ "title": "Security",
640
+ "lead": "The ways you can sign in to this site.",
641
+ "methods": "Sign-in methods",
642
+ "google": "Google",
643
+ "github": "GitHub",
644
+ "magicLink": "Email sign-in link",
645
+ "password": "Password",
646
+ "available": "Available",
647
+ "none": "No sign-in method is configured."
648
+ },
649
+ "notifications": {
650
+ "title": "Notifications",
651
+ "lead": "How this site contacts you.",
652
+ "placeholder": "Notification preferences are coming. Until then, we email you only about your account and your requests."
653
+ },
654
+ "privacy": {
655
+ "title": "Privacy",
656
+ "lead": "Cookies and your data.",
657
+ "cookies": "Cookie settings",
658
+ "deletion": "Request account deletion",
659
+ "deletionLead": "Ask the site owner to delete your account and its data. We'll confirm by email.",
660
+ "deletionSent": "Your deletion request is on its way. We'll confirm by email.",
661
+ "deletionError": "We couldn't send your request. Try again in a moment.",
662
+ "deletionSubject": "Account deletion request",
663
+ "deletionMessage": "Please delete my account and its data."
664
+ },
665
+ "billing": {
666
+ "title": "Billing",
667
+ "lead": "Your subscription.",
668
+ "status": "Status",
669
+ "accessRevokesAt": "Access ends",
670
+ "manage": "Manage billing",
671
+ "statuses": {
672
+ "trialing": "Trial",
673
+ "active": "Active",
674
+ "past_due": "Payment due",
675
+ "canceled": "Canceled"
676
+ }
677
+ }
678
+ },
679
+ "gate": {
680
+ "emptyHeadline": "Sign-in isn't available yet",
681
+ "emptyDescription": "This site hasn't turned on sign-in for members yet. Check back soon, or contact the site owner.",
682
+ "signInLink": "Go to the sign-in page",
683
+ "homeLink": "Back to the site"
545
684
  },
546
685
  "profile": {
547
- "heading": "Profile",
548
- "name": "Name",
549
686
  "email": "Email",
550
- "verified": "Email verified",
551
- "unverified": "Email not verified",
552
- "unavailable": "—",
553
- "signOut": "Sign out"
687
+ "unverified": "Email not verified"
554
688
  },
555
689
  "results": {
556
690
  "heading": "My results",
@@ -559,24 +693,17 @@
559
693
  "tier": "Tier",
560
694
  "score": "Score",
561
695
  "completedAt": "Completed",
562
- "unavailable": "—",
563
- "emptyHeadline": "No results yet",
564
- "emptyDescription": "Complete a diagnostic and your results will appear here."
696
+ "unavailable": "—"
565
697
  },
566
698
  "purchases": {
567
699
  "heading": "My purchases",
568
700
  "offer": "Offer",
569
- "status": "Status",
570
701
  "amount": "Amount",
571
702
  "period": "Period",
572
- "date": "Date",
573
- "emptyHeadline": "No purchases yet",
574
- "emptyDescription": "Purchases you complete will appear here."
703
+ "date": "Date"
575
704
  },
576
705
  "subscription": {
577
706
  "headline": "Your payment didn't go through",
578
- "descriptionPastDue": "Update your card to keep your subscription active.",
579
- "descriptionCanceled": "Your subscription was canceled. Update your card by {date} to keep your access.",
580
707
  "action": "Update payment method"
581
708
  }
582
709
  },