create-ailk 0.11.2 → 0.11.3
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/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -1,13 +1,221 @@
|
|
|
1
|
-
// account-config.ts —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,144 @@
|
|
|
537
546
|
},
|
|
538
547
|
"account": {
|
|
539
548
|
"title": "Your account",
|
|
540
|
-
"description": "
|
|
541
|
-
"
|
|
542
|
-
"
|
|
543
|
-
"
|
|
544
|
-
"
|
|
549
|
+
"description": "What changed, what's coming, and what needs you.",
|
|
550
|
+
"frame": {
|
|
551
|
+
"home": "Home",
|
|
552
|
+
"inbox": "Inbox",
|
|
553
|
+
"settings": "Settings",
|
|
554
|
+
"signOut": "Log out",
|
|
555
|
+
"help": "Help",
|
|
556
|
+
"contactSupport": "Contact support",
|
|
557
|
+
"helpCenter": "Help center",
|
|
558
|
+
"accountMenu": "Open account menu",
|
|
559
|
+
"tabBar": "Account",
|
|
560
|
+
"sidebar": "Account",
|
|
561
|
+
"collapseSidebar": "Collapse sidebar",
|
|
562
|
+
"expandSidebar": "Expand sidebar",
|
|
563
|
+
"collapseAside": "Collapse panel",
|
|
564
|
+
"expandAside": "Expand panel",
|
|
565
|
+
"openAside": "Open For you",
|
|
566
|
+
"closeAside": "Close For you"
|
|
567
|
+
},
|
|
568
|
+
"forYou": {
|
|
569
|
+
"title": "For you",
|
|
570
|
+
"filter": "Filter",
|
|
571
|
+
"allTypes": "All types",
|
|
572
|
+
"sort": "Sort",
|
|
573
|
+
"mostRelevant": "Most relevant",
|
|
574
|
+
"mostRecent": "Most recent",
|
|
575
|
+
"view": "View",
|
|
576
|
+
"open": "Open",
|
|
577
|
+
"dismiss": "Dismiss",
|
|
578
|
+
"whyItMatters": "Why it matters",
|
|
579
|
+
"expectedOutcome": "Expected outcome",
|
|
580
|
+
"waiting": "Waiting {duration}",
|
|
581
|
+
"empty": "Nothing here right now.",
|
|
582
|
+
"showAll": "Show all",
|
|
583
|
+
"close": "Close",
|
|
584
|
+
"addName": "Add your name",
|
|
585
|
+
"types": {
|
|
586
|
+
"profile": "Profile"
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
"inbox": {
|
|
590
|
+
"title": "Inbox",
|
|
591
|
+
"lead": "What changed, and what's coming.",
|
|
592
|
+
"tabs": {
|
|
593
|
+
"recents": "Recents",
|
|
594
|
+
"suggested": "Suggested",
|
|
595
|
+
"scheduled": "Scheduled",
|
|
596
|
+
"all": "All"
|
|
597
|
+
},
|
|
598
|
+
"empty": "Nothing here yet.",
|
|
599
|
+
"error": "We couldn't load everything in your Inbox.",
|
|
600
|
+
"retry": "Retry",
|
|
601
|
+
"back": "Inbox",
|
|
602
|
+
"itemError": "We couldn't load this item."
|
|
603
|
+
},
|
|
604
|
+
"support": {
|
|
605
|
+
"title": "Contact support",
|
|
606
|
+
"lead": "Tell us what you need. We'll reply to {email}.",
|
|
607
|
+
"subject": "Subject",
|
|
608
|
+
"message": "Message",
|
|
609
|
+
"submit": "Send request",
|
|
610
|
+
"submitting": "Sending…",
|
|
611
|
+
"required": "Fill in both the subject and the message.",
|
|
612
|
+
"success": "Thanks — your request is on its way. We'll reply by email.",
|
|
613
|
+
"error": "We couldn't send your request. Try again in a moment.",
|
|
614
|
+
"unavailable": "Requests can't be sent from this site yet. Email the site owner instead."
|
|
615
|
+
},
|
|
616
|
+
"settings": {
|
|
617
|
+
"title": "Settings",
|
|
618
|
+
"lead": "Your profile, sign-in, notifications and privacy.",
|
|
619
|
+
"general": {
|
|
620
|
+
"title": "General",
|
|
621
|
+
"lead": "Your name, email and appearance.",
|
|
622
|
+
"colorMode": "Color mode",
|
|
623
|
+
"light": "Light",
|
|
624
|
+
"auto": "Auto",
|
|
625
|
+
"dark": "Dark",
|
|
626
|
+
"name": "Name",
|
|
627
|
+
"namePlaceholder": "Your name",
|
|
628
|
+
"email": "Email",
|
|
629
|
+
"save": "Save changes",
|
|
630
|
+
"saving": "Saving…",
|
|
631
|
+
"saved": "Saved.",
|
|
632
|
+
"unsaved": "Unsaved changes",
|
|
633
|
+
"clean": "No unsaved changes",
|
|
634
|
+
"nameRequired": "Enter your name.",
|
|
635
|
+
"saveError": "We couldn't save your name. Try again."
|
|
636
|
+
},
|
|
637
|
+
"security": {
|
|
638
|
+
"title": "Security",
|
|
639
|
+
"lead": "The ways you can sign in to this site.",
|
|
640
|
+
"methods": "Sign-in methods",
|
|
641
|
+
"google": "Google",
|
|
642
|
+
"github": "GitHub",
|
|
643
|
+
"magicLink": "Email sign-in link",
|
|
644
|
+
"password": "Password",
|
|
645
|
+
"available": "Available",
|
|
646
|
+
"none": "No sign-in method is configured."
|
|
647
|
+
},
|
|
648
|
+
"notifications": {
|
|
649
|
+
"title": "Notifications",
|
|
650
|
+
"lead": "How this site contacts you.",
|
|
651
|
+
"placeholder": "Notification preferences are coming. Until then, we email you only about your account and your requests."
|
|
652
|
+
},
|
|
653
|
+
"privacy": {
|
|
654
|
+
"title": "Privacy",
|
|
655
|
+
"lead": "Cookies and your data.",
|
|
656
|
+
"cookies": "Cookie settings",
|
|
657
|
+
"deletion": "Request account deletion",
|
|
658
|
+
"deletionLead": "Ask the site owner to delete your account and its data. We'll confirm by email.",
|
|
659
|
+
"deletionSent": "Your deletion request is on its way. We'll confirm by email.",
|
|
660
|
+
"deletionError": "We couldn't send your request. Try again in a moment.",
|
|
661
|
+
"deletionSubject": "Account deletion request",
|
|
662
|
+
"deletionMessage": "Please delete my account and its data."
|
|
663
|
+
},
|
|
664
|
+
"billing": {
|
|
665
|
+
"title": "Billing",
|
|
666
|
+
"lead": "Your subscription.",
|
|
667
|
+
"status": "Status",
|
|
668
|
+
"accessRevokesAt": "Access ends",
|
|
669
|
+
"manage": "Manage billing",
|
|
670
|
+
"statuses": {
|
|
671
|
+
"trialing": "Trial",
|
|
672
|
+
"active": "Active",
|
|
673
|
+
"past_due": "Payment due",
|
|
674
|
+
"canceled": "Canceled"
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
"gate": {
|
|
679
|
+
"emptyHeadline": "Sign-in isn't available yet",
|
|
680
|
+
"emptyDescription": "This site hasn't turned on sign-in for members yet. Check back soon, or contact the site owner.",
|
|
681
|
+
"signInLink": "Go to the sign-in page",
|
|
682
|
+
"homeLink": "Back to the site"
|
|
545
683
|
},
|
|
546
684
|
"profile": {
|
|
547
|
-
"heading": "Profile",
|
|
548
|
-
"name": "Name",
|
|
549
685
|
"email": "Email",
|
|
550
|
-
"
|
|
551
|
-
"unverified": "Email not verified",
|
|
552
|
-
"unavailable": "—",
|
|
553
|
-
"signOut": "Sign out"
|
|
686
|
+
"unverified": "Email not verified"
|
|
554
687
|
},
|
|
555
688
|
"results": {
|
|
556
689
|
"heading": "My results",
|
|
@@ -559,24 +692,17 @@
|
|
|
559
692
|
"tier": "Tier",
|
|
560
693
|
"score": "Score",
|
|
561
694
|
"completedAt": "Completed",
|
|
562
|
-
"unavailable": "—"
|
|
563
|
-
"emptyHeadline": "No results yet",
|
|
564
|
-
"emptyDescription": "Complete a diagnostic and your results will appear here."
|
|
695
|
+
"unavailable": "—"
|
|
565
696
|
},
|
|
566
697
|
"purchases": {
|
|
567
698
|
"heading": "My purchases",
|
|
568
699
|
"offer": "Offer",
|
|
569
|
-
"status": "Status",
|
|
570
700
|
"amount": "Amount",
|
|
571
701
|
"period": "Period",
|
|
572
|
-
"date": "Date"
|
|
573
|
-
"emptyHeadline": "No purchases yet",
|
|
574
|
-
"emptyDescription": "Purchases you complete will appear here."
|
|
702
|
+
"date": "Date"
|
|
575
703
|
},
|
|
576
704
|
"subscription": {
|
|
577
705
|
"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
706
|
"action": "Update payment method"
|
|
581
707
|
}
|
|
582
708
|
},
|