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
|
@@ -18,14 +18,21 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Since #5963 the page lives in the `(auth)` route group — the URL is
|
|
20
20
|
* unchanged (a group contributes no path segment) but the frame is
|
|
21
|
-
* `AuthShell`, not the marketing Nav + Footer. The shell owns the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* `AuthShell`, not the marketing Nav + Footer. The shell owns the 400px
|
|
22
|
+
* column; the card carries the site's mark (#8790 D3).
|
|
23
|
+
*
|
|
24
|
+
* The one card (#9662): "Sign in to <site>" (the name is the deployment's,
|
|
25
|
+
* read in `resolveSignInCardText`, never the request's), the "Don't have an
|
|
26
|
+
* account? Sign up" line to `/sign-up`, and the Terms + Privacy
|
|
27
|
+
* acknowledgement. The Sign up line carries the validated `next` (#9912 D4),
|
|
28
|
+
* so a buyer who switches to sign-up keeps the destination; a refused or
|
|
29
|
+
* absent `next` gives the bare `/sign-up`.
|
|
24
30
|
*
|
|
25
31
|
* Signed-in visitors are not redirected here — the Nav's session-aware action
|
|
26
32
|
* cluster (Account / Sign out) is the affordance for them.
|
|
27
33
|
*/
|
|
28
34
|
|
|
35
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
29
36
|
import { localeHref } from "@working-theory/ui";
|
|
30
37
|
import { accountEmailHintSchema } from "@working-theory/validation";
|
|
31
38
|
import type { Metadata } from "next";
|
|
@@ -36,15 +43,14 @@ import {
|
|
|
36
43
|
getActiveProviderIds,
|
|
37
44
|
resetSendConfigured,
|
|
38
45
|
} from "../../../../lib/auth-env";
|
|
39
|
-
|
|
40
|
-
import { hubSignIn } from "@working-theory/api-client";
|
|
41
|
-
import { Link, Text } from "@working-theory/ui/primitives";
|
|
42
46
|
import { siteLogo } from "../../../../lib/site-logo";
|
|
43
47
|
import {
|
|
44
48
|
CONTINUE_PATH,
|
|
45
49
|
openContinuation,
|
|
46
50
|
} from "../../../api/auth/hub/continuation";
|
|
47
|
-
import {
|
|
51
|
+
import { legalFootnote } from "../legal-footnote";
|
|
52
|
+
|
|
53
|
+
import { hrefWithNext, resolveNextPath } from "./next-path";
|
|
48
54
|
import { SignInCard, type SignInProvider } from "./sign-in-card";
|
|
49
55
|
import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
|
|
50
56
|
|
|
@@ -143,7 +149,8 @@ export default async function SignInPage({
|
|
|
143
149
|
);
|
|
144
150
|
const method = active.includes("email") ? "password" : "sso";
|
|
145
151
|
const emailLink = active.includes("magic-link");
|
|
146
|
-
const
|
|
152
|
+
const fallback = localeHref(locale, "/account");
|
|
153
|
+
const next = resolveNextPath(rawNext, fallback);
|
|
147
154
|
// A failed link returns here, keeping the visitor's destination.
|
|
148
155
|
const signInHref = `${localeHref(locale, "/sign-in")}?next=${encodeURIComponent(next)}`;
|
|
149
156
|
// Only the email link produces `?error=` here; with it off, today's render.
|
|
@@ -151,27 +158,7 @@ export default async function SignInPage({
|
|
|
151
158
|
const defaultEmail = hintedEmail(rawEmail);
|
|
152
159
|
|
|
153
160
|
const spoke = await spokeFromNext(next);
|
|
154
|
-
|
|
155
|
-
// inline link variant; the card never hardcodes a path.
|
|
156
|
-
// `caption` is the muted footnote role; a default `Text` would re-impose
|
|
157
|
-
// body size and the foreground colour over the card's footnote wrapper.
|
|
158
|
-
const footnote = (
|
|
159
|
-
<Text as="p" variant="caption">
|
|
160
|
-
{text.acknowledgement.lead}{" "}
|
|
161
|
-
<Link variant="inline" href={localeHref(locale, "/terms")}>
|
|
162
|
-
{text.acknowledgement.terms}
|
|
163
|
-
</Link>
|
|
164
|
-
,{" "}
|
|
165
|
-
<Link variant="inline" href={localeHref(locale, "/privacy")}>
|
|
166
|
-
{text.acknowledgement.privacy}
|
|
167
|
-
</Link>
|
|
168
|
-
, and{" "}
|
|
169
|
-
<Link variant="inline" href={localeHref(locale, "/working-agreement")}>
|
|
170
|
-
{text.acknowledgement.workingAgreement}
|
|
171
|
-
</Link>
|
|
172
|
-
.
|
|
173
|
-
</Text>
|
|
174
|
-
);
|
|
161
|
+
const t = await getTranslations({ locale, namespace: "signIn" });
|
|
175
162
|
return (
|
|
176
163
|
<SignInCard
|
|
177
164
|
logo={siteLogo()}
|
|
@@ -179,19 +166,15 @@ export default async function SignInPage({
|
|
|
179
166
|
? {
|
|
180
167
|
backLink: {
|
|
181
168
|
href: `${spoke.origin}/account`,
|
|
182
|
-
label:
|
|
183
|
-
},
|
|
184
|
-
}
|
|
185
|
-
: {})}
|
|
186
|
-
{...(text.prospect
|
|
187
|
-
? {
|
|
188
|
-
prospect: {
|
|
189
|
-
...text.prospect,
|
|
190
|
-
href: localeHref(locale, "/sign-up"),
|
|
169
|
+
label: t("backTo", { site: spoke.name }),
|
|
191
170
|
},
|
|
192
171
|
}
|
|
193
172
|
: {})}
|
|
194
|
-
|
|
173
|
+
prospect={{
|
|
174
|
+
...text.prospect,
|
|
175
|
+
href: hrefWithNext(localeHref(locale, "/sign-up"), next, fallback),
|
|
176
|
+
}}
|
|
177
|
+
footnote={legalFootnote(locale, text.acknowledgement)}
|
|
195
178
|
providers={providers}
|
|
196
179
|
method={method}
|
|
197
180
|
next={next}
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* method from auth-env and hands them in; this component never reads env
|
|
12
12
|
* itself.
|
|
13
13
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
14
|
+
* The site's mark arrives as `logo` (#8790 D3) and renders centred in the
|
|
15
|
+
* card; the shell draws no brand row.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* `footer="none"` drops AuthCard's "Not a member? Create an account"
|
|
18
|
+
* cross-link; the page's `prospect` line ("Don't have an account? Sign up",
|
|
19
|
+
* #9662) is the one route to `/sign-up`.
|
|
20
20
|
*
|
|
21
21
|
* Password path: `apps/web/auth.ts` does not enable `emailAndPassword` today,
|
|
22
22
|
* so `signIn.email` is wired but inert (Better Auth answers with an error)
|
|
@@ -242,7 +242,7 @@ export function SignInCard({
|
|
|
242
242
|
{...(logo !== undefined ? { logo } : {})}
|
|
243
243
|
{...(forgotHref !== undefined ? { forgotHref } : {})}
|
|
244
244
|
{...(forgot !== undefined ? { forgot } : {})}
|
|
245
|
-
//
|
|
245
|
+
// The heading stands alone (#8790 D3): no subhead unless the card
|
|
246
246
|
// is inert and must say why.
|
|
247
247
|
subhead={notConfigured ? notConfiguredSubhead : ""}
|
|
248
248
|
{...(prospect !== undefined ? { prospect } : {})}
|
|
@@ -4,26 +4,36 @@
|
|
|
4
4
|
* One helper so `/sign-in` and the `(authed)` gate render the SAME labels from
|
|
5
5
|
* the same `signIn` keys — the gate composes `SignInCard` when the email
|
|
6
6
|
* sign-in link is on, and a second copy of this object would drift.
|
|
7
|
+
*
|
|
8
|
+
* The heading names the site — "Sign in to <site>" (#9662) — from
|
|
9
|
+
* `SITE_NAME`, the deployment's own display name (env, else `_site.mdx`),
|
|
10
|
+
* never from the request.
|
|
11
|
+
*
|
|
12
|
+
* Plain data only: the gate hands this object to a client component, and a
|
|
13
|
+
* function cannot cross that boundary (`/sign-in` resolves its "Back to
|
|
14
|
+
* <spoke>" line itself).
|
|
7
15
|
*/
|
|
8
16
|
|
|
9
17
|
import type { AuthCardLabels } from "@working-theory/ui/blocks";
|
|
10
18
|
import { getTranslations } from "next-intl/server";
|
|
11
19
|
|
|
20
|
+
import { SITE_NAME } from "../../../../lib/site-brand";
|
|
21
|
+
|
|
12
22
|
import type { SignInEmailLinkLabels } from "./sign-in-card";
|
|
13
23
|
|
|
14
24
|
export interface SignInCardText {
|
|
15
25
|
labels: AuthCardLabels;
|
|
16
|
-
/**
|
|
17
|
-
backTo: (site: string) => string;
|
|
18
|
-
/** The acknowledgement sentence's parts (#8790 D6). */
|
|
26
|
+
/** The acknowledgement sentence's parts (#8790 D6; Terms + Privacy, #9662). */
|
|
19
27
|
acknowledgement: {
|
|
20
28
|
lead: string;
|
|
21
29
|
terms: string;
|
|
30
|
+
and: string;
|
|
22
31
|
privacy: string;
|
|
23
|
-
|
|
32
|
+
/** The sentence's closing mark — a message, so a locale can use its own. */
|
|
33
|
+
end: string;
|
|
24
34
|
};
|
|
25
|
-
/** The prospect line
|
|
26
|
-
prospect
|
|
35
|
+
/** The prospect line toggling to /sign-up (#8790 D3; on by default, #9662). */
|
|
36
|
+
prospect: { prompt: string; linkLabel: string };
|
|
27
37
|
notConfiguredSubhead: string;
|
|
28
38
|
errorLabels: { signInFailed: string; providerFailed: string };
|
|
29
39
|
emailLinkLabels: SignInEmailLinkLabels;
|
|
@@ -31,32 +41,13 @@ export interface SignInCardText {
|
|
|
31
41
|
emailLinkExpired: string;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
type HasKey = Parameters<Awaited<ReturnType<typeof getTranslations>>["has"]>[0];
|
|
35
|
-
function optional(
|
|
36
|
-
t: Awaited<ReturnType<typeof getTranslations>>,
|
|
37
|
-
key: string,
|
|
38
|
-
): boolean {
|
|
39
|
-
// A partial `getTranslations` mock (the (authed) gate suite's) carries no
|
|
40
|
-
// `has`; an absent `has` reads as an absent key, never a throw.
|
|
41
|
-
return typeof t.has === "function" && t.has(key as HasKey);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function prospect(
|
|
45
|
-
prompt: string,
|
|
46
|
-
linkLabel: string,
|
|
47
|
-
): { prospect: { prompt: string; linkLabel: string } } | null {
|
|
48
|
-
return prompt.trim() && linkLabel.trim()
|
|
49
|
-
? { prospect: { prompt, linkLabel } }
|
|
50
|
-
: null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
44
|
export async function resolveSignInCardText(
|
|
54
45
|
locale: string,
|
|
55
46
|
): Promise<SignInCardText> {
|
|
56
47
|
const t = await getTranslations({ locale, namespace: "signIn" });
|
|
57
48
|
return {
|
|
58
49
|
labels: {
|
|
59
|
-
signInHeading: t("heading"),
|
|
50
|
+
signInHeading: t("heading", { site: SITE_NAME }),
|
|
60
51
|
ssoPrefix: t("ssoPrefix"),
|
|
61
52
|
google: t("google"),
|
|
62
53
|
github: t("github"),
|
|
@@ -70,20 +61,14 @@ export async function resolveSignInCardText(
|
|
|
70
61
|
useEmailLink: t("useEmailLink"),
|
|
71
62
|
emailPlaceholder: t("emailPlaceholder"),
|
|
72
63
|
},
|
|
73
|
-
backTo: (site) => t("backTo", { site }),
|
|
74
64
|
acknowledgement: {
|
|
75
65
|
lead: t("acknowledgement"),
|
|
76
66
|
terms: t("termsLink"),
|
|
67
|
+
and: t("acknowledgementAnd"),
|
|
77
68
|
privacy: t("privacyLink"),
|
|
78
|
-
|
|
69
|
+
end: t("acknowledgementEnd"),
|
|
79
70
|
},
|
|
80
|
-
|
|
81
|
-
// messages — the shipped catalog carries neither (every leaf must be a
|
|
82
|
-
// non-empty string), so presence is the switch. The keys are optional
|
|
83
|
-
// extensions of the typed catalog, hence the cast on `has`.
|
|
84
|
-
...(optional(t, "prospectPrompt") && optional(t, "prospectLink")
|
|
85
|
-
? prospect(t("prospectPrompt" as never), t("prospectLink" as never))
|
|
86
|
-
: {}),
|
|
71
|
+
prospect: { prompt: t("prospectPrompt"), linkLabel: t("prospectLink") },
|
|
87
72
|
|
|
88
73
|
notConfiguredSubhead: t("notConfiguredSubhead"),
|
|
89
74
|
errorLabels: {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/sign-up`'s method and its failed-link notice (#9667 D6, AC-3).
|
|
3
|
+
*
|
|
4
|
+
* With the email sign-in link active, an open `/sign-up` renders AuthCard's
|
|
5
|
+
* one-field "Send me a magic link" body; without it, #9662's password or
|
|
6
|
+
* provider body. A failed link lands back here with `?error=<code>`, mapped
|
|
7
|
+
* to fixed copy and never echoed.
|
|
8
|
+
*
|
|
9
|
+
* #9912 D4, D5: the page reads `?next=` through `resolveNextPath`, as
|
|
10
|
+
* `/sign-in` does. The "Sign in" toggle (open and closed card) carries it, and
|
|
11
|
+
* the password, Google and magic-link paths each hand it to Better Auth as
|
|
12
|
+
* `callbackURL`, the magic link's failed-link return keeping it too. A `next`
|
|
13
|
+
* resolveNextPath refuses, or none, gives `/account` and the bare links.
|
|
14
|
+
*
|
|
15
|
+
* `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
|
|
16
|
+
* the real en.json.
|
|
17
|
+
*/
|
|
18
|
+
import { render, screen } from "@testing-library/react";
|
|
19
|
+
import userEvent from "@testing-library/user-event";
|
|
20
|
+
|
|
21
|
+
const mockGetActiveProviderIds = jest.fn();
|
|
22
|
+
const mockIsSignUpOpen = jest.fn();
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
|
|
24
|
+
jest.mock("../../../../../lib/auth-env", () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
getActiveProviderIds: () => mockGetActiveProviderIds(),
|
|
27
|
+
isSignUpOpen: () => mockIsSignUpOpen(),
|
|
28
|
+
isResetAvailable: () => false,
|
|
29
|
+
resetSendConfigured: () => false,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const mockSocial = jest.fn();
|
|
33
|
+
const mockSignUpEmail = jest.fn();
|
|
34
|
+
const mockFetch = jest.fn();
|
|
35
|
+
jest.mock("better-auth/react", () => ({
|
|
36
|
+
__esModule: true,
|
|
37
|
+
createAuthClient: () => ({
|
|
38
|
+
signIn: {
|
|
39
|
+
social: (...args: unknown[]) => mockSocial(...args),
|
|
40
|
+
email: jest.fn(),
|
|
41
|
+
},
|
|
42
|
+
signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
|
|
43
|
+
$fetch: (...args: unknown[]) => mockFetch(...args),
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
import en from "../../../../../messages/en.json";
|
|
48
|
+
import SignUpPage from "../page";
|
|
49
|
+
|
|
50
|
+
async function renderSignUp(
|
|
51
|
+
searchParams: Record<string, string | string[]> = {},
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
render(
|
|
54
|
+
await SignUpPage({
|
|
55
|
+
params: Promise.resolve({ locale: "en" }),
|
|
56
|
+
searchParams: Promise.resolve(searchParams),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
mockGetActiveProviderIds.mockReset();
|
|
63
|
+
mockIsSignUpOpen.mockReset().mockReturnValue(true);
|
|
64
|
+
mockSocial.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
65
|
+
mockSignUpEmail.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
66
|
+
mockFetch.mockReset().mockResolvedValue({ data: {}, error: null });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("the method (D6)", () => {
|
|
70
|
+
it('with "magic-link" active: one Email field, Send me a magic link, no password, the agreements, "or" + Google', async () => {
|
|
71
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
|
|
72
|
+
await renderSignUp();
|
|
73
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
74
|
+
en.auth.magicLinkSubmit,
|
|
75
|
+
);
|
|
76
|
+
expect(screen.queryByTestId("auth-password")).toBeNull();
|
|
77
|
+
expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByTestId("auth-divider")).toHaveTextContent(
|
|
79
|
+
en.auth.orContinue,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('with only ["google", "email"]: the password body', async () => {
|
|
85
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
86
|
+
await renderSignUp();
|
|
87
|
+
expect(screen.getByTestId("auth-password")).toBeInTheDocument();
|
|
88
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
89
|
+
en.auth.signUpSubmit,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("the failed-link notice (D6)", () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
mockGetActiveProviderIds.mockReturnValue(["magic-link"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
["INVALID_TOKEN", en.auth.signUpLinkExpired],
|
|
101
|
+
["new_user_signup_disabled", en.auth.signUpLinkExpired],
|
|
102
|
+
["AGREEMENTS_REQUIRED", en.auth.signUpAgreementsRequired],
|
|
103
|
+
["AGREEMENTS_UNAVAILABLE", en.auth.signUpFailed],
|
|
104
|
+
])("?error=%s renders its copy", async (error, copy) => {
|
|
105
|
+
await renderSignUp({ error });
|
|
106
|
+
expect(screen.getByTestId("sign-up-link-notice")).toHaveTextContent(copy);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a repeated error reads its first value, and the raw value is never echoed", async () => {
|
|
110
|
+
await renderSignUp({ error: ["INVALID_TOKEN", "<b>x</b>"] });
|
|
111
|
+
const notice = screen.getByTestId("sign-up-link-notice");
|
|
112
|
+
expect(notice).toHaveTextContent(en.auth.signUpLinkExpired);
|
|
113
|
+
expect(notice).not.toHaveTextContent("INVALID_TOKEN");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("no error, no notice", async () => {
|
|
117
|
+
await renderSignUp();
|
|
118
|
+
expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("the password method shows no link notice", async () => {
|
|
122
|
+
mockGetActiveProviderIds.mockReturnValue(["email"]);
|
|
123
|
+
await renderSignUp({ error: "INVALID_TOKEN" });
|
|
124
|
+
expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** A continue path as the hub's authorize step hands it (#8146). */
|
|
129
|
+
const CONTINUATION = "/api/auth/hub/continue/AbC-123_xyz";
|
|
130
|
+
|
|
131
|
+
/** The three values `resolveNextPath` refuses that a toggle must never echo. */
|
|
132
|
+
const REFUSED: ReadonlyArray<[string, string]> = [
|
|
133
|
+
["a protocol-relative next", "//evil.example"],
|
|
134
|
+
["an absolute next", "https://evil.example/x"],
|
|
135
|
+
["a backslash next", "/\\evil.example"],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/** Each query that carries no `next` a toggle may echo: the refused three, and none. */
|
|
139
|
+
const UNCARRIED: Array<[string, Record<string, string>]> = [
|
|
140
|
+
...REFUSED.map(([label, next]): [string, Record<string, string>] => [
|
|
141
|
+
label,
|
|
142
|
+
{ next },
|
|
143
|
+
]),
|
|
144
|
+
["no next", {}],
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/** The completion cases with no `next` to honour: one refused, and none. */
|
|
148
|
+
const TO_ACCOUNT: Array<[string, Record<string, string>]> = [
|
|
149
|
+
["a refused next", { next: "//evil.example" }],
|
|
150
|
+
["no next", {}],
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
describe("the Sign in toggle keeps the next (#9912 D4)", () => {
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("carries a validated next on the open card", async () => {
|
|
159
|
+
await renderSignUp({ next: "/account/billing" });
|
|
160
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
161
|
+
"href",
|
|
162
|
+
"/sign-in?next=%2Faccount%2Fbilling",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("carries a validated next on the closed (invitation) card", async () => {
|
|
167
|
+
mockIsSignUpOpen.mockReturnValue(false);
|
|
168
|
+
await renderSignUp({ next: "/account/billing" });
|
|
169
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
170
|
+
"href",
|
|
171
|
+
"/sign-in?next=%2Faccount%2Fbilling",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it.each(UNCARRIED)(
|
|
176
|
+
"%s gives the bare /sign-in on the open card",
|
|
177
|
+
async (_label, query) => {
|
|
178
|
+
await renderSignUp(query);
|
|
179
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
180
|
+
"href",
|
|
181
|
+
"/sign-in",
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it.each(UNCARRIED)(
|
|
187
|
+
"%s gives the bare /sign-in on the closed card",
|
|
188
|
+
async (_label, query) => {
|
|
189
|
+
mockIsSignUpOpen.mockReturnValue(false);
|
|
190
|
+
await renderSignUp(query);
|
|
191
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
192
|
+
"href",
|
|
193
|
+
"/sign-in",
|
|
194
|
+
);
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("sign-up completion follows the validated next (#9912 D5)", () => {
|
|
200
|
+
async function tickTerms(): Promise<void> {
|
|
201
|
+
await userEvent.setup().click(screen.getByTestId("consent-account_terms"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function submitPassword(): Promise<void> {
|
|
205
|
+
const user = userEvent.setup();
|
|
206
|
+
await tickTerms();
|
|
207
|
+
await user.type(screen.getByTestId("auth-email"), "ada@example.com");
|
|
208
|
+
await user.type(
|
|
209
|
+
screen.getByTestId("auth-password"),
|
|
210
|
+
"correct horse battery staple",
|
|
211
|
+
);
|
|
212
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function clickGoogle(): Promise<void> {
|
|
216
|
+
await tickTerms();
|
|
217
|
+
await userEvent.setup().click(screen.getByTestId("auth-social-google"));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function submitLink(): Promise<void> {
|
|
221
|
+
const user = userEvent.setup();
|
|
222
|
+
await tickTerms();
|
|
223
|
+
await user.type(screen.getByTestId("auth-email"), "ada@example.com");
|
|
224
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe("with a continuation next", () => {
|
|
228
|
+
it("password: signUp.email's callbackURL is the next", async () => {
|
|
229
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
230
|
+
await renderSignUp({ next: CONTINUATION });
|
|
231
|
+
await submitPassword();
|
|
232
|
+
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(mockSignUpEmail.mock.calls[0][0]).toMatchObject({
|
|
234
|
+
callbackURL: CONTINUATION,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("Google: signIn.social's callbackURL is the next", async () => {
|
|
239
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
240
|
+
await renderSignUp({ next: CONTINUATION });
|
|
241
|
+
await clickGoogle();
|
|
242
|
+
expect(mockSocial).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(mockSocial.mock.calls[0][0]).toMatchObject({
|
|
244
|
+
provider: "google",
|
|
245
|
+
callbackURL: CONTINUATION,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("magic link: the request's callbackURL is the next and its failed-link return keeps it", async () => {
|
|
250
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
|
|
251
|
+
await renderSignUp({ next: CONTINUATION });
|
|
252
|
+
await submitLink();
|
|
253
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
254
|
+
const [path, init] = mockFetch.mock.calls[0] as [
|
|
255
|
+
string,
|
|
256
|
+
{ body: Record<string, unknown> },
|
|
257
|
+
];
|
|
258
|
+
expect(path).toBe("/sign-in/magic-link");
|
|
259
|
+
expect(init.body.callbackURL).toBe(CONTINUATION);
|
|
260
|
+
expect(init.body.errorCallbackURL).toBe(
|
|
261
|
+
`/sign-up?next=${encodeURIComponent(CONTINUATION)}`,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe.each(TO_ACCOUNT)("with %s", (_label, query) => {
|
|
267
|
+
it("password: callbackURL /account", async () => {
|
|
268
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
269
|
+
await renderSignUp(query);
|
|
270
|
+
await submitPassword();
|
|
271
|
+
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(mockSignUpEmail.mock.calls[0][0]).toMatchObject({
|
|
273
|
+
callbackURL: "/account",
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("Google: callbackURL /account", async () => {
|
|
278
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
279
|
+
await renderSignUp(query);
|
|
280
|
+
await clickGoogle();
|
|
281
|
+
expect(mockSocial).toHaveBeenCalledTimes(1);
|
|
282
|
+
expect(mockSocial.mock.calls[0][0]).toMatchObject({
|
|
283
|
+
provider: "google",
|
|
284
|
+
callbackURL: "/account",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("magic link: callbackURL /account and the bare /sign-up return", async () => {
|
|
289
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
|
|
290
|
+
await renderSignUp(query);
|
|
291
|
+
await submitLink();
|
|
292
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
293
|
+
const [, init] = mockFetch.mock.calls[0] as [
|
|
294
|
+
string,
|
|
295
|
+
{ body: Record<string, unknown> },
|
|
296
|
+
];
|
|
297
|
+
expect(init.body.callbackURL).toBe("/account");
|
|
298
|
+
expect(init.body.errorCallbackURL).toBe("/sign-up");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -17,11 +17,13 @@ import messages from "../../../../../messages/en.json";
|
|
|
17
17
|
|
|
18
18
|
const mockSignUpEmail = jest.fn();
|
|
19
19
|
const mockSignInSocial = jest.fn();
|
|
20
|
+
const mockFetch = jest.fn();
|
|
20
21
|
jest.mock("better-auth/react", () => ({
|
|
21
22
|
__esModule: true,
|
|
22
23
|
createAuthClient: () => ({
|
|
23
24
|
signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
|
|
24
25
|
signIn: { social: (...args: unknown[]) => mockSignInSocial(...args) },
|
|
26
|
+
$fetch: (...args: unknown[]) => mockFetch(...args),
|
|
25
27
|
}),
|
|
26
28
|
}));
|
|
27
29
|
|
|
@@ -38,13 +40,11 @@ function props(overrides: Partial<SignUpCardProps> = {}): SignUpCardProps {
|
|
|
38
40
|
providers: ["google"],
|
|
39
41
|
method: "password",
|
|
40
42
|
next: "/account",
|
|
41
|
-
|
|
43
|
+
prospect: { prompt: "Already have an account?", linkLabel: "Sign in", href: "/sign-in" },
|
|
44
|
+
footnote: <span>You acknowledge our terms.</span>,
|
|
42
45
|
labels: {
|
|
43
|
-
signUpHeading: "
|
|
44
|
-
signUpSubhead: "",
|
|
46
|
+
signUpHeading: "Sign up",
|
|
45
47
|
signUpSubmit: "Sign up",
|
|
46
|
-
signUpFooterPrompt: "Already have an account?",
|
|
47
|
-
signUpFooterLink: "Sign in",
|
|
48
48
|
ssoPrefix: "Continue with",
|
|
49
49
|
google: "Google",
|
|
50
50
|
github: "GitHub",
|
|
@@ -201,3 +201,109 @@ describe("provider buttons are gated too (AC-3)", () => {
|
|
|
201
201
|
expect(document.cookie).toBe("");
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
|
+
|
|
205
|
+
describe("the magic-link method (#9667 AC-3)", () => {
|
|
206
|
+
const linkProps = (overrides: Partial<SignUpCardProps> = {}) =>
|
|
207
|
+
props({
|
|
208
|
+
method: "magic-link",
|
|
209
|
+
labels: {
|
|
210
|
+
...props().labels,
|
|
211
|
+
magicLinkSubmit: messages.auth.magicLinkSubmit,
|
|
212
|
+
emailLinkDivider: messages.auth.orContinue,
|
|
213
|
+
},
|
|
214
|
+
errorCallbackURL: "/sign-up",
|
|
215
|
+
linkLabels: {
|
|
216
|
+
sentHeading: messages.auth.sentHeading,
|
|
217
|
+
sentSubhead: messages.auth.sentSubhead,
|
|
218
|
+
sentBody: messages.auth.signUpLinkSentBody,
|
|
219
|
+
back: messages.auth.signUpLinkBack,
|
|
220
|
+
failed: messages.auth.signUpLinkFailed,
|
|
221
|
+
rateLimited: messages.auth.signUpLinkRateLimited,
|
|
222
|
+
},
|
|
223
|
+
...overrides,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
async function requestLink(): Promise<void> {
|
|
227
|
+
const user = userEvent.setup();
|
|
228
|
+
await user.type(screen.getByTestId("auth-email"), "ada@example.com");
|
|
229
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
mockFetch.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("renders one Email field, Send me a magic link, no password, the agreements and the providers", () => {
|
|
237
|
+
render(<SignUpCard {...linkProps()} />);
|
|
238
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(messages.auth.magicLinkSubmit);
|
|
239
|
+
expect(screen.queryByTestId("auth-password")).toBeNull();
|
|
240
|
+
expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
|
|
241
|
+
expect(screen.getByTestId("auth-divider")).toHaveTextContent(messages.auth.orContinue);
|
|
242
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("does nothing while the agreement is unticked", async () => {
|
|
246
|
+
render(<SignUpCard {...linkProps()} />);
|
|
247
|
+
await requestLink();
|
|
248
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("once ticked, POSTs /sign-in/magic-link once with the agreements in metadata, then shows the sent card", async () => {
|
|
252
|
+
render(<SignUpCard {...linkProps()} />);
|
|
253
|
+
await tick("account_terms");
|
|
254
|
+
await requestLink();
|
|
255
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(mockFetch).toHaveBeenCalledWith("/sign-in/magic-link", {
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: {
|
|
259
|
+
email: "ada@example.com",
|
|
260
|
+
name: "ada@example.com",
|
|
261
|
+
callbackURL: "/account",
|
|
262
|
+
errorCallbackURL: "/sign-up",
|
|
263
|
+
metadata: { agreements: ["account_terms"], locale: "en" },
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
expect(await screen.findByTestId("auth-sent-body")).toHaveTextContent(
|
|
267
|
+
messages.auth.signUpLinkSentBody,
|
|
268
|
+
);
|
|
269
|
+
expect(mockSignUpEmail).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("an AGREEMENTS_REQUIRED answer is shown on the agreements", async () => {
|
|
273
|
+
mockFetch.mockResolvedValue({
|
|
274
|
+
data: null,
|
|
275
|
+
error: { code: "AGREEMENTS_REQUIRED", status: 400 },
|
|
276
|
+
});
|
|
277
|
+
render(<SignUpCard {...linkProps()} />);
|
|
278
|
+
await tick("account_terms");
|
|
279
|
+
await requestLink();
|
|
280
|
+
const agreements = screen.getByTestId("sign-up-agreements");
|
|
281
|
+
await waitFor(() =>
|
|
282
|
+
expect(within(agreements).getByRole("alert")).toHaveTextContent(
|
|
283
|
+
messages.auth.signUpAgreementsRequired,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it.each([
|
|
289
|
+
[429, messages.auth.signUpLinkRateLimited],
|
|
290
|
+
[400, messages.auth.signUpLinkFailed],
|
|
291
|
+
])("a %s answer shows its copy and no sent card", async (status, copy) => {
|
|
292
|
+
mockFetch.mockResolvedValue({
|
|
293
|
+
data: null,
|
|
294
|
+
error: { code: status === 400 ? "SIGN_UP_CLOSED" : undefined, status },
|
|
295
|
+
});
|
|
296
|
+
render(<SignUpCard {...linkProps()} />);
|
|
297
|
+
await tick("account_terms");
|
|
298
|
+
await requestLink();
|
|
299
|
+
expect(await screen.findByText(copy)).toBeInTheDocument();
|
|
300
|
+
expect(screen.queryByTestId("auth-sent-body")).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("renders a link notice above the card", () => {
|
|
304
|
+
render(<SignUpCard {...linkProps({ linkNotice: messages.auth.signUpLinkExpired })} />);
|
|
305
|
+
expect(screen.getByTestId("sign-up-link-notice")).toHaveTextContent(
|
|
306
|
+
messages.auth.signUpLinkExpired,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
});
|