create-ailk 0.25.1 → 0.27.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.
- package/README.md +14 -5
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +6 -5
- package/dist/lib/module-license-gate.js +2 -3
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.js +14 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +36 -2
- package/dist/template-record.d.ts +11 -3
- package/dist/template-record.js +20 -7
- package/dist/topology.js +23 -4
- package/dist/update.d.ts +1 -1
- package/dist/update.js +240 -20
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.env.example +22 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +28 -0
- package/templates/apps/api/CLAUDE.md +35 -9
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +34 -6
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/layout.tsx +48 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/auth.ts +8 -4
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +271 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/middleware.ts +8 -1
- package/templates/apps/web/next.config.mjs +9 -1
- package/templates/apps/web/package.json +2 -1
- package/versions.json +48 -48
|
@@ -4,13 +4,30 @@
|
|
|
4
4
|
* RSC. Branches on `isSignUpOpen()`:
|
|
5
5
|
* - open → the sign-up form island, wired to Better Auth exactly as
|
|
6
6
|
* `/sign-in`'s island is (providers + method from auth-env).
|
|
7
|
-
*
|
|
8
|
-
* "
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* With the email sign-in link on, the method is the one-field
|
|
8
|
+
* "Send me a magic link" body (#9667 D6): the link creates the
|
|
9
|
+
* account on verify, and a failed link lands back here with
|
|
10
|
+
* `?error=<code>`, mapped to fixed copy.
|
|
11
|
+
* - closed → AuthCard's `invitation` intent: copy only. This is the
|
|
12
|
+
* DEFAULT — `apps/web/auth.ts` does not enable
|
|
13
|
+
* `emailAndPassword`, so a scaffold offers no public sign-up and
|
|
14
|
+
* the page must not advertise one.
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
16
|
+
* The one card (#9662) frames both: the site's mark, "Sign up", the
|
|
17
|
+
* "Already have an account? Sign in" line to `/sign-in` (in place of the
|
|
18
|
+
* footer's cross-link) and the Terms + Privacy acknowledgement. A closed site
|
|
19
|
+
* keeps its invitation copy inside that frame — never a form the server
|
|
20
|
+
* would refuse (operator, 2026-10-24).
|
|
21
|
+
*
|
|
22
|
+
* `?next=` (#9912 D4, D5) is read through `resolveNextPath`, the call
|
|
23
|
+
* `/sign-in` makes. The validated `next` is where every method completes —
|
|
24
|
+
* the password verification link, the magic link and Google — and both the
|
|
25
|
+
* "Sign in" toggle and the failed-link return carry it, so a buyer sent here
|
|
26
|
+
* by a spoke's round trip ends back on the spoke. A refused or absent `next`
|
|
27
|
+
* gives `/account` and the bare links, as before.
|
|
28
|
+
*
|
|
29
|
+
* `AuthShell` (the (auth) group's layout) owns the 400px column; the card
|
|
30
|
+
* carries the site's mark (#8790 D3).
|
|
14
31
|
*/
|
|
15
32
|
|
|
16
33
|
import { localeHref } from "@working-theory/ui";
|
|
@@ -26,12 +43,40 @@ import type { ReactElement } from "react";
|
|
|
26
43
|
|
|
27
44
|
import { getActiveProviderIds, isSignUpOpen } from "../../../../lib/auth-env";
|
|
28
45
|
import { consentPoliciesFor } from "../../../../lib/consent-policies";
|
|
46
|
+
import { siteLogo } from "../../../../lib/site-logo";
|
|
47
|
+
import { legalFootnote } from "../legal-footnote";
|
|
48
|
+
import { hrefWithNext, resolveNextPath } from "../sign-in/next-path";
|
|
49
|
+
import { resolveSignInCardText } from "../sign-in/sign-in-text";
|
|
29
50
|
|
|
30
51
|
import { SignUpCard, type SignUpProvider } from "./sign-up-card";
|
|
31
|
-
import { siteLogo } from "../../../../lib/site-logo";
|
|
32
52
|
|
|
33
53
|
interface Props {
|
|
34
54
|
params: Promise<{ locale: string }>;
|
|
55
|
+
searchParams?: Promise<{
|
|
56
|
+
next?: string | string[];
|
|
57
|
+
error?: string | string[];
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The magic-link verify's `?error=<code>` (#9667 D6), mapped to fixed copy —
|
|
63
|
+
* never the value itself. A repeated `error` reads its first value.
|
|
64
|
+
*/
|
|
65
|
+
const EXPIRED_LINK_ERRORS: ReadonlySet<string> = new Set([
|
|
66
|
+
"INVALID_TOKEN",
|
|
67
|
+
"new_user_signup_disabled",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function linkNoticeKey(
|
|
71
|
+
error: string | string[] | undefined,
|
|
72
|
+
): "signUpLinkExpired" | "signUpAgreementsRequired" | "signUpFailed" | null {
|
|
73
|
+
if (error === undefined) return null;
|
|
74
|
+
const code = Array.isArray(error) ? error[0] : error;
|
|
75
|
+
if (code === undefined) return null;
|
|
76
|
+
if (EXPIRED_LINK_ERRORS.has(code)) return "signUpLinkExpired";
|
|
77
|
+
return code === "AGREEMENTS_REQUIRED"
|
|
78
|
+
? "signUpAgreementsRequired"
|
|
79
|
+
: "signUpFailed";
|
|
35
80
|
}
|
|
36
81
|
|
|
37
82
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
@@ -42,24 +87,38 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
42
87
|
|
|
43
88
|
export default async function SignUpPage({
|
|
44
89
|
params,
|
|
90
|
+
searchParams,
|
|
45
91
|
}: Props): Promise<ReactElement> {
|
|
46
92
|
const { locale } = await params;
|
|
93
|
+
const { next: rawNext, error } = (await searchParams) ?? {};
|
|
47
94
|
const t = await getTranslations({ locale, namespace: "auth" });
|
|
48
|
-
const
|
|
95
|
+
const fallback = localeHref(locale, "/account");
|
|
96
|
+
const next = resolveNextPath(rawNext, fallback);
|
|
97
|
+
// The toggle line to /sign-in, and the acknowledgement /sign-in shares.
|
|
98
|
+
const prospect = {
|
|
99
|
+
prompt: t("signUpFooterPrompt"),
|
|
100
|
+
linkLabel: t("signUpFooterLink"),
|
|
101
|
+
href: hrefWithNext(localeHref(locale, "/sign-in"), next, fallback),
|
|
102
|
+
};
|
|
103
|
+
const footnote = legalFootnote(
|
|
104
|
+
locale,
|
|
105
|
+
(await resolveSignInCardText(locale)).acknowledgement,
|
|
106
|
+
);
|
|
49
107
|
|
|
50
108
|
if (!isSignUpOpen()) {
|
|
51
109
|
const closedLabels: AuthCardLabels = {
|
|
52
|
-
invitationHeading: t("
|
|
53
|
-
invitationSubhead: t("invitationSubhead"),
|
|
110
|
+
invitationHeading: t("signUpHeading"),
|
|
54
111
|
invitationBody: t("invitationBody"),
|
|
55
|
-
backToSignIn: t("backToSignIn"),
|
|
56
112
|
};
|
|
57
113
|
return (
|
|
58
114
|
<div className="flex flex-col gap-4" data-testid="sign-up-page">
|
|
59
115
|
<AuthCard
|
|
60
116
|
logo={siteLogo()}
|
|
61
117
|
intent="invitation"
|
|
62
|
-
|
|
118
|
+
footer="none"
|
|
119
|
+
subhead=""
|
|
120
|
+
prospect={prospect}
|
|
121
|
+
footnote={footnote}
|
|
63
122
|
labels={closedLabels}
|
|
64
123
|
/>
|
|
65
124
|
</div>
|
|
@@ -71,20 +130,24 @@ export default async function SignUpPage({
|
|
|
71
130
|
const providers = active.filter(
|
|
72
131
|
(id): id is SignUpProvider => id === "google" || id === "github",
|
|
73
132
|
);
|
|
74
|
-
const method = active.includes("
|
|
133
|
+
const method = active.includes("magic-link")
|
|
134
|
+
? "magic-link"
|
|
135
|
+
: active.includes("email")
|
|
136
|
+
? "password"
|
|
137
|
+
: "sso";
|
|
138
|
+
const noticeKey = method === "magic-link" ? linkNoticeKey(error) : null;
|
|
75
139
|
|
|
76
140
|
const labels: AuthCardLabels = {
|
|
77
141
|
signUpHeading: t("signUpHeading"),
|
|
78
|
-
signUpSubhead: t("signUpSubhead"),
|
|
79
142
|
signUpSubmit: t("signUpSubmit"),
|
|
80
|
-
signUpFooterPrompt: t("signUpFooterPrompt"),
|
|
81
|
-
signUpFooterLink: t("signUpFooterLink"),
|
|
82
143
|
ssoPrefix: t("ssoPrefix"),
|
|
83
144
|
google: t("google"),
|
|
84
145
|
github: t("github"),
|
|
85
146
|
email: t("email"),
|
|
86
147
|
password: t("password"),
|
|
87
148
|
orContinue: t("orContinue"),
|
|
149
|
+
magicLinkSubmit: t("magicLinkSubmit"),
|
|
150
|
+
emailLinkDivider: t("orContinue"),
|
|
88
151
|
};
|
|
89
152
|
|
|
90
153
|
// #8438 D13 — the site's sign-up agreements, resolved to this locale. No
|
|
@@ -105,8 +168,9 @@ export default async function SignUpPage({
|
|
|
105
168
|
logo={siteLogo()}
|
|
106
169
|
providers={providers}
|
|
107
170
|
method={method}
|
|
108
|
-
next={
|
|
109
|
-
|
|
171
|
+
next={next}
|
|
172
|
+
prospect={prospect}
|
|
173
|
+
footnote={footnote}
|
|
110
174
|
labels={labels}
|
|
111
175
|
notConfiguredSubhead={t("signUpNotConfiguredSubhead")}
|
|
112
176
|
errorLabels={{
|
|
@@ -118,6 +182,20 @@ export default async function SignUpPage({
|
|
|
118
182
|
agreementPolicies={consentPoliciesFor(locale)}
|
|
119
183
|
agreementsLegend={t("signUpAgreementsLegend")}
|
|
120
184
|
locale={locale}
|
|
185
|
+
errorCallbackURL={hrefWithNext(
|
|
186
|
+
localeHref(locale, "/sign-up"),
|
|
187
|
+
next,
|
|
188
|
+
fallback,
|
|
189
|
+
)}
|
|
190
|
+
{...(noticeKey ? { linkNotice: t(noticeKey) } : {})}
|
|
191
|
+
linkLabels={{
|
|
192
|
+
sentHeading: t("sentHeading"),
|
|
193
|
+
sentSubhead: t("sentSubhead"),
|
|
194
|
+
sentBody: t("signUpLinkSentBody"),
|
|
195
|
+
back: t("signUpLinkBack"),
|
|
196
|
+
failed: t("signUpLinkFailed"),
|
|
197
|
+
rateLimited: t("signUpLinkRateLimited"),
|
|
198
|
+
}}
|
|
121
199
|
/>
|
|
122
200
|
);
|
|
123
201
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use client"; // Better Auth's client calls (signUp.email / signIn.social) + error state
|
|
1
|
+
"use client"; // Better Auth's client calls (signUp.email / signIn.social / $fetch) + error state
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* SignUpCard — the /sign-up page's client island (#5963).
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* documented posture the sign-in island already carries for its password
|
|
14
14
|
* path. The page only renders this island at all when `isSignUpOpen()`.
|
|
15
15
|
*
|
|
16
|
-
* The
|
|
16
|
+
* The one card (#9662): the page hands in the site's mark (`logo`), the
|
|
17
|
+
* "Already have an account? Sign in" line (`prospect`, in place of AuthCard's
|
|
18
|
+
* footer cross-link) and the Terms + Privacy acknowledgement (`footnote`).
|
|
19
|
+
* The heading stands alone — no subhead unless the card is inert.
|
|
17
20
|
*
|
|
18
21
|
* Error surface: `AuthCard`'s `errors` prop is per-field, so a failed provider
|
|
19
22
|
* launch lands on `errors.email` (the social row sits inside the password form
|
|
@@ -37,6 +40,14 @@
|
|
|
37
40
|
* `additionalData`, which Better Auth keeps in the OAuth state for the
|
|
38
41
|
* callback. The server writes the grants with the user
|
|
39
42
|
* (`lib/sign-up-agreements.ts`); no cookie carries them.
|
|
43
|
+
*
|
|
44
|
+
* #9667 D6 — the magic-link method. With the email sign-in link on, the card
|
|
45
|
+
* is AuthCard's one-field "Send me a magic link" body, gated the same way.
|
|
46
|
+
* The request goes through the shared client's `$fetch`, never the
|
|
47
|
+
* `magicLinkClient()` plugin (for the reason `sign-in-card.tsx` records), with
|
|
48
|
+
* the agreements and locale in its `metadata` — which is what makes it a
|
|
49
|
+
* sign-up request. The server binds them to the token and creates the account
|
|
50
|
+
* on verify. The sent state never says whether the address had an account.
|
|
40
51
|
*/
|
|
41
52
|
|
|
42
53
|
import {
|
|
@@ -47,7 +58,13 @@ import {
|
|
|
47
58
|
} from "@working-theory/ui/blocks";
|
|
48
59
|
import { Alert, AlertDescription } from "@working-theory/ui/primitives";
|
|
49
60
|
import { SIGN_UP_AGREEMENTS_FIELD } from "@working-theory/validation";
|
|
50
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
useRef,
|
|
63
|
+
useState,
|
|
64
|
+
type MouseEvent,
|
|
65
|
+
type ReactElement,
|
|
66
|
+
type ReactNode,
|
|
67
|
+
} from "react";
|
|
51
68
|
|
|
52
69
|
import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
|
|
53
70
|
|
|
@@ -62,17 +79,35 @@ export interface SignUpAgreement {
|
|
|
62
79
|
statement: string;
|
|
63
80
|
}
|
|
64
81
|
|
|
82
|
+
/** Translated copy for the magic-link sign-up (#9667 D6). */
|
|
83
|
+
export interface SignUpLinkLabels {
|
|
84
|
+
sentHeading: string;
|
|
85
|
+
sentSubhead: string;
|
|
86
|
+
sentBody: string;
|
|
87
|
+
/** The sent state's "Back to sign up" link. */
|
|
88
|
+
back: string;
|
|
89
|
+
/** Any failed request other than a rate limit or the agreements. */
|
|
90
|
+
failed: string;
|
|
91
|
+
/** A 429 from Better Auth's per-IP limit. */
|
|
92
|
+
rateLimited: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
65
95
|
export interface SignUpCardProps {
|
|
66
96
|
/** The site's mark, centred in the card at 48px (#8790 D3). */
|
|
67
97
|
logo?: ReactNode;
|
|
68
98
|
/** Active OAuth providers, exactly as auth-env reports them. */
|
|
69
99
|
providers: SignUpProvider[];
|
|
70
|
-
/**
|
|
71
|
-
|
|
100
|
+
/**
|
|
101
|
+
* "magic-link" when the email sign-in link is active, else "password" when
|
|
102
|
+
* the email provider is, otherwise "sso".
|
|
103
|
+
*/
|
|
104
|
+
method: "sso" | "password" | "magic-link";
|
|
72
105
|
/** Post-sign-up path. */
|
|
73
106
|
next: string;
|
|
74
|
-
/**
|
|
75
|
-
|
|
107
|
+
/** The "Already have an account? Sign in" line to /sign-in (#9662). */
|
|
108
|
+
prospect: { prompt: string; linkLabel: string; href: string };
|
|
109
|
+
/** The acknowledgement sentence(s) at the card's foot (#9662). */
|
|
110
|
+
footnote: ReactNode;
|
|
76
111
|
/** Translated AuthCard labels (resolved server-side by the page). */
|
|
77
112
|
labels: AuthCardLabels;
|
|
78
113
|
/** Subhead shown when no provider is configured. */
|
|
@@ -92,6 +127,12 @@ export interface SignUpCardProps {
|
|
|
92
127
|
agreementsLegend: string;
|
|
93
128
|
/** The page's locale, sent with the agreements (the statements' language). */
|
|
94
129
|
locale: string;
|
|
130
|
+
/** Where a failed link lands (`/sign-up`); Better Auth appends `?error`. */
|
|
131
|
+
errorCallbackURL?: string;
|
|
132
|
+
/** Fixed copy for a failed link (`?error=` mapped by the page), shown above the card. */
|
|
133
|
+
linkNotice?: string;
|
|
134
|
+
/** The magic-link method's copy; required when `method` is "magic-link". */
|
|
135
|
+
linkLabels?: SignUpLinkLabels;
|
|
95
136
|
}
|
|
96
137
|
|
|
97
138
|
/** The server's body key for the ticked ids — also the group's control name. */
|
|
@@ -112,7 +153,8 @@ export function SignUpCard({
|
|
|
112
153
|
providers,
|
|
113
154
|
method,
|
|
114
155
|
next,
|
|
115
|
-
|
|
156
|
+
prospect,
|
|
157
|
+
footnote,
|
|
116
158
|
labels,
|
|
117
159
|
notConfiguredSubhead,
|
|
118
160
|
errorLabels,
|
|
@@ -121,6 +163,9 @@ export function SignUpCard({
|
|
|
121
163
|
agreementsLegend,
|
|
122
164
|
logo,
|
|
123
165
|
locale,
|
|
166
|
+
errorCallbackURL,
|
|
167
|
+
linkNotice,
|
|
168
|
+
linkLabels,
|
|
124
169
|
}: SignUpCardProps): ReactElement {
|
|
125
170
|
const { errors, providerError, launchProvider, submitPassword } =
|
|
126
171
|
useAuthCardFlow(next, {
|
|
@@ -135,6 +180,11 @@ export function SignUpCard({
|
|
|
135
180
|
const [agreementsError, setAgreementsError] = useState<string | undefined>(
|
|
136
181
|
undefined,
|
|
137
182
|
);
|
|
183
|
+
const [linkSent, setLinkSent] = useState(false);
|
|
184
|
+
const [linkError, setLinkError] = useState<string | undefined>(undefined);
|
|
185
|
+
// One request at a time: a double-click would spend two of the per-IP
|
|
186
|
+
// sends and mint two live tokens (as sign-in-card.tsx).
|
|
187
|
+
const linkPending = useRef(false);
|
|
138
188
|
const allAgreed = requiredAgreementIds.every((id) => ticked.has(id));
|
|
139
189
|
// What the server checks and records (D10): the ticked ids, in the order shown.
|
|
140
190
|
const agreed = (): { agreements: string[]; locale: string } => ({
|
|
@@ -201,8 +251,72 @@ export function SignUpCard({
|
|
|
201
251
|
);
|
|
202
252
|
};
|
|
203
253
|
|
|
254
|
+
const requestSignUpLink = async (email: string): Promise<void> => {
|
|
255
|
+
if (!allAgreed || linkPending.current) return;
|
|
256
|
+
linkPending.current = true;
|
|
257
|
+
setAgreementsError(undefined);
|
|
258
|
+
setLinkError(undefined);
|
|
259
|
+
try {
|
|
260
|
+
const { error } = await authClient.$fetch("/sign-in/magic-link", {
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: {
|
|
263
|
+
email,
|
|
264
|
+
// As the password submit: Better Auth wants a name on a new user.
|
|
265
|
+
name: email,
|
|
266
|
+
callbackURL: next,
|
|
267
|
+
errorCallbackURL,
|
|
268
|
+
metadata: agreed(),
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
if (!error) {
|
|
272
|
+
setLinkSent(true);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (errorCode({ error }) === AGREEMENTS_REQUIRED) {
|
|
276
|
+
setAgreementsError(errorLabels.agreementsRequired);
|
|
277
|
+
} else {
|
|
278
|
+
setLinkError(
|
|
279
|
+
error.status === 429 ? linkLabels?.rateLimited : linkLabels?.failed,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
setLinkError(linkLabels?.failed);
|
|
284
|
+
} finally {
|
|
285
|
+
linkPending.current = false;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if (method === "magic-link" && linkSent && linkLabels) {
|
|
290
|
+
// No `sentTo`: both a new and a taken address get a link, and the state
|
|
291
|
+
// must not say which.
|
|
292
|
+
return (
|
|
293
|
+
<div className="flex flex-col gap-4" data-testid="sign-up-page">
|
|
294
|
+
<AuthCard
|
|
295
|
+
{...(logo !== undefined ? { logo } : {})}
|
|
296
|
+
intent="sent"
|
|
297
|
+
footer="cross-link"
|
|
298
|
+
// "Back to sign up": this page, where a failed link also lands.
|
|
299
|
+
{...(errorCallbackURL !== undefined
|
|
300
|
+
? { signInHref: errorCallbackURL }
|
|
301
|
+
: {})}
|
|
302
|
+
labels={{
|
|
303
|
+
sentHeading: linkLabels.sentHeading,
|
|
304
|
+
sentSubhead: linkLabels.sentSubhead,
|
|
305
|
+
sentBody: linkLabels.sentBody,
|
|
306
|
+
backToSignIn: linkLabels.back,
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
204
313
|
return (
|
|
205
314
|
<div className="flex flex-col gap-4" data-testid="sign-up-page">
|
|
315
|
+
{linkNotice ? (
|
|
316
|
+
<Alert variant="error" data-testid="sign-up-link-notice">
|
|
317
|
+
<AlertDescription>{linkNotice}</AlertDescription>
|
|
318
|
+
</Alert>
|
|
319
|
+
) : null}
|
|
206
320
|
{method === "sso" && providerError ? (
|
|
207
321
|
<Alert variant="error" data-testid="sign-up-provider-error">
|
|
208
322
|
<AlertDescription>{providerError}</AlertDescription>
|
|
@@ -217,11 +331,17 @@ export function SignUpCard({
|
|
|
217
331
|
{...(logo !== undefined ? { logo } : {})}
|
|
218
332
|
intent="sign-up"
|
|
219
333
|
method={method}
|
|
220
|
-
|
|
221
|
-
{
|
|
334
|
+
footer="none"
|
|
335
|
+
subhead={notConfigured ? notConfiguredSubhead : ""}
|
|
336
|
+
prospect={prospect}
|
|
337
|
+
footnote={footnote}
|
|
222
338
|
providers={providerButtons}
|
|
223
|
-
onSubmit={
|
|
224
|
-
|
|
339
|
+
onSubmit={
|
|
340
|
+
method === "magic-link"
|
|
341
|
+
? (data) => void requestSignUpLink(data.email)
|
|
342
|
+
: handleSubmit
|
|
343
|
+
}
|
|
344
|
+
errors={linkError ? { ...errors, email: linkError } : errors}
|
|
225
345
|
labels={labels}
|
|
226
346
|
/>
|
|
227
347
|
</div>
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* 9. On an account path (#7430) the gate speaks to members — the
|
|
20
20
|
* `account.gate` copy, never `waitlistDashboard.gate` — and its
|
|
21
21
|
* not-configured headline is a real heading element.
|
|
22
|
+
* 10. The member gate renders the one card (#9662): `/sign-in`'s card with
|
|
23
|
+
* the site's mark, the toggle line to /sign-up and the Terms + Privacy
|
|
24
|
+
* acknowledgement.
|
|
22
25
|
*/
|
|
23
26
|
import { readFileSync } from "node:fs";
|
|
24
27
|
import { join } from "node:path";
|
|
@@ -407,13 +410,62 @@ describe("AuthedLayout — the signed-out gate on an account path (#7430)", () =
|
|
|
407
410
|
await renderSignedOutLayout();
|
|
408
411
|
expect(gateNamespaces()).toContain("account.gate");
|
|
409
412
|
expect(gateNamespaces()).not.toContain("waitlistDashboard.gate");
|
|
410
|
-
expect(screen.getByTestId("auth-
|
|
413
|
+
expect(screen.getByTestId("auth-sso-google")).toBeInTheDocument();
|
|
411
414
|
expect(screen.getByTestId("sign-in-gate-link")).toHaveAttribute(
|
|
412
415
|
"href",
|
|
413
416
|
"/sign-in?next=%2Faccount",
|
|
414
417
|
);
|
|
415
418
|
});
|
|
416
419
|
|
|
420
|
+
// A client component's props cross the RSC boundary: React refuses a
|
|
421
|
+
// function there, so the card's copy must arrive as plain data (#9662 —
|
|
422
|
+
// the 500 a function-valued `backTo` once caused here).
|
|
423
|
+
it.each([
|
|
424
|
+
["Google", false],
|
|
425
|
+
["Google + the email link", true],
|
|
426
|
+
] as const)("hands SignInGate no function in its card props (%s)", async (_name, withEmailLink) => {
|
|
427
|
+
configureGoogle();
|
|
428
|
+
if (withEmailLink) configureEmailLink();
|
|
429
|
+
mockGetSession.mockResolvedValue(null);
|
|
430
|
+
const element = (await AuthedLayout({
|
|
431
|
+
params: Promise.resolve({ locale: "en" }),
|
|
432
|
+
children: <div />,
|
|
433
|
+
})) as React.ReactElement<{ card?: { text: Record<string, unknown> } }>;
|
|
434
|
+
const text = element.props.card?.text;
|
|
435
|
+
expect(text).toBeDefined();
|
|
436
|
+
const functions = Object.entries(text ?? {}).filter(
|
|
437
|
+
([, value]) => typeof value === "function",
|
|
438
|
+
);
|
|
439
|
+
expect(functions).toEqual([]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it.each([
|
|
443
|
+
["Google", false],
|
|
444
|
+
["Google + the email link", true],
|
|
445
|
+
] as const)("renders the one card with %s: the mark, the toggle line and the acknowledgement (#9662)", async (_name, withEmailLink) => {
|
|
446
|
+
configureGoogle();
|
|
447
|
+
if (withEmailLink) configureEmailLink();
|
|
448
|
+
await renderSignedOutLayout();
|
|
449
|
+
expect(screen.getByTestId("sign-in-page")).toBeInTheDocument();
|
|
450
|
+
expect(screen.queryByTestId("auth-panel-card")).not.toBeInTheDocument();
|
|
451
|
+
expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
|
|
452
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
453
|
+
"href",
|
|
454
|
+
"/sign-up",
|
|
455
|
+
);
|
|
456
|
+
const footnote = screen.getByTestId("auth-card-footnote");
|
|
457
|
+
expect(footnote.querySelector('a[href="/terms"]')).not.toBeNull();
|
|
458
|
+
expect(footnote.querySelector('a[href="/privacy"]')).not.toBeNull();
|
|
459
|
+
if (withEmailLink) {
|
|
460
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
461
|
+
"copy:emailLinkSubmit",
|
|
462
|
+
);
|
|
463
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
464
|
+
} else {
|
|
465
|
+
expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
417
469
|
it("renders the not-configured headline as a real heading, not a <p>", async () => {
|
|
418
470
|
await renderSignedOutLayout();
|
|
419
471
|
expect(gateNamespaces()).toContain("account.gate");
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* button; (d) a 500 or a throw shows the error row, never "no
|
|
11
11
|
* subscription"; (e) a contributed billing section renders after every
|
|
12
12
|
* one of these; (f) the read asks for `include: "billing"`.
|
|
13
|
+
* #9401 AC-9 (spec docs/specs/9401-show-a-buyer-s-ailk-spec.md D7): a
|
|
14
|
+
* section the site adds to `siteBillingSections` renders after the
|
|
15
|
+
* modules' `accountBillingSections`, each under its `ctx.t(title)`.
|
|
13
16
|
* #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
|
|
14
17
|
* D10, D11): `openBillingPortal()` mints the portal through
|
|
15
18
|
* `billingPortalUrl()` — the hub's route, once, with the cookie init — and
|
|
@@ -81,6 +84,7 @@ jest.mock("next/navigation", () => ({
|
|
|
81
84
|
import type { AccountBillingSection } from "@working-theory/templates/account";
|
|
82
85
|
|
|
83
86
|
import { accountBillingSections } from "../../../../../lib/account-config";
|
|
87
|
+
import { siteBillingSections } from "../../../../../lib/site-contributions";
|
|
84
88
|
import { openBillingPortal } from "../actions";
|
|
85
89
|
|
|
86
90
|
// The Billing route's own page, loaded with `import()`: architecture.yaml bans
|
|
@@ -276,6 +280,40 @@ describe("AC-2 (e) — contributed billing sections render after the base conten
|
|
|
276
280
|
});
|
|
277
281
|
});
|
|
278
282
|
|
|
283
|
+
describe("#9401 AC-9 — a site's own section renders after the modules' (D7)", () => {
|
|
284
|
+
const moduleSection: AccountBillingSection = {
|
|
285
|
+
id: "module",
|
|
286
|
+
title: "settings.billing.currentPlan",
|
|
287
|
+
render: () => <p data-testid="module-body">Module</p>,
|
|
288
|
+
};
|
|
289
|
+
const siteSection: AccountBillingSection = {
|
|
290
|
+
id: "site",
|
|
291
|
+
title: "settings.billing.title",
|
|
292
|
+
render: () => <p data-testid="site-body">Site</p>,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
beforeAll(() => {
|
|
296
|
+
accountBillingSections.push(moduleSection);
|
|
297
|
+
siteBillingSections.push(siteSection);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
afterAll(() => {
|
|
301
|
+
accountBillingSections.splice(accountBillingSections.indexOf(moduleSection), 1);
|
|
302
|
+
siteBillingSections.splice(siteBillingSections.indexOf(siteSection), 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("renders the module's section, then the site's, each under its translated title", async () => {
|
|
306
|
+
await renderPage();
|
|
307
|
+
const contributed = screen.getByTestId("account-settings-module");
|
|
308
|
+
const site = screen.getByTestId("account-settings-site");
|
|
309
|
+
expect(within(contributed).getByTestId("module-body")).toBeInTheDocument();
|
|
310
|
+
expect(within(site).getByTestId("site-body")).toBeInTheDocument();
|
|
311
|
+
expect(within(contributed).getByRole("heading", { level: 2 })).toHaveTextContent("Current plan");
|
|
312
|
+
expect(within(site).getByRole("heading", { level: 2 })).toHaveTextContent("Billing");
|
|
313
|
+
expect(contributed.compareDocumentPosition(site) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
279
317
|
describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
|
|
280
318
|
const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
|
|
281
319
|
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*
|
|
20
20
|
* After any of the three, one plain line when some of the member's billing
|
|
21
21
|
* is on a Stripe account this spoke's key cannot read (#8217 D12), then
|
|
22
|
-
* each section a module contributes (`accountBillingSections`, #8231 D4)
|
|
22
|
+
* each section a module contributes (`accountBillingSections`, #8231 D4),
|
|
23
|
+
* then each section the site adds (`siteBillingSections`, #9401 D7).
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { AccountSettingsFrame, AccountSettingsPane, CurrentPlanCard } from "@working-theory/templates/account";
|
|
@@ -29,6 +30,7 @@ import { getTranslations } from "next-intl/server";
|
|
|
29
30
|
import type { ReactElement } from "react";
|
|
30
31
|
|
|
31
32
|
import { accountBillingSections } from "../../../../../lib/account-config";
|
|
33
|
+
import { siteBillingSections } from "../../../../../lib/site-contributions";
|
|
32
34
|
import { accountSession } from "../account-data";
|
|
33
35
|
import { openBillingPortal } from "../actions";
|
|
34
36
|
import { type BillingState, loadBilling } from "../billing-source";
|
|
@@ -161,7 +163,7 @@ export default async function AccountBillingPage({ params }: Props): Promise<Rea
|
|
|
161
163
|
</div>
|
|
162
164
|
) : null}
|
|
163
165
|
|
|
164
|
-
{accountBillingSections.map((section) => (
|
|
166
|
+
{[...accountBillingSections, ...siteBillingSections].map((section) => (
|
|
165
167
|
<SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
|
|
166
168
|
{section.render(ctx)}
|
|
167
169
|
</SettingsSection>
|