create-ailk 0.25.1 → 0.26.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 +12 -3
- 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 +12 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +29 -0
- 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 +188 -18
- 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 +15 -0
- package/templates/apps/api/.env.example +21 -0
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/CLAUDE.md +22 -1
- 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__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- 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 +108 -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 +78 -16
- 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 +33 -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/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/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/versions.json +48 -48
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
|
|
18
18
|
* the real en.json, so the `auth` copy is asserted by its real strings.
|
|
19
19
|
*/
|
|
20
|
-
import { render, screen } from "@testing-library/react";
|
|
20
|
+
import { render, screen, within } from "@testing-library/react";
|
|
21
21
|
|
|
22
22
|
const mockGetActiveProviderIds = jest.fn();
|
|
23
23
|
const mockIsSignUpOpen = jest.fn();
|
|
@@ -217,12 +217,51 @@ describe("/sign-up follows isSignUpOpen()", () => {
|
|
|
217
217
|
it("renders the invitation intent when sign-up is closed", async () => {
|
|
218
218
|
mockIsSignUpOpen.mockReturnValue(false);
|
|
219
219
|
await renderPage(SignUpPage);
|
|
220
|
-
expect(screen.getByTestId("auth-invitation-body")).
|
|
220
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
221
|
+
en.auth.invitationBody,
|
|
222
|
+
);
|
|
221
223
|
expect(
|
|
222
|
-
screen.getByRole("heading", { name: en.auth.
|
|
224
|
+
screen.getByRole("heading", { name: en.auth.signUpHeading }),
|
|
223
225
|
).toBeInTheDocument();
|
|
224
226
|
expect(screen.queryByTestId("auth-submit")).not.toBeInTheDocument();
|
|
225
227
|
});
|
|
228
|
+
|
|
229
|
+
// The one card (#9662) frames both branches: the mark, "Sign up", the toggle
|
|
230
|
+
// line to /sign-in (no footer cross-link) and the acknowledgement.
|
|
231
|
+
it.each([
|
|
232
|
+
["closed", false],
|
|
233
|
+
["open", true],
|
|
234
|
+
] as const)("frames the %s card: mark, 'Sign up', toggle line, footnote (#9662)", async (_name, open) => {
|
|
235
|
+
mockIsSignUpOpen.mockReturnValue(open);
|
|
236
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
237
|
+
await renderPage(SignUpPage);
|
|
238
|
+
expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
|
|
239
|
+
expect(screen.getByRole("heading", { name: "Sign up" })).toBeInTheDocument();
|
|
240
|
+
expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
|
|
241
|
+
/^Already have an account\? Sign in$/,
|
|
242
|
+
);
|
|
243
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
244
|
+
"href",
|
|
245
|
+
"/sign-in",
|
|
246
|
+
);
|
|
247
|
+
expect(screen.queryByTestId("auth-footer-link")).not.toBeInTheDocument();
|
|
248
|
+
expect(screen.getByTestId("auth-card-footnote")).toHaveTextContent(
|
|
249
|
+
/^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
|
|
250
|
+
);
|
|
251
|
+
const footnote = within(screen.getByTestId("auth-card-footnote"));
|
|
252
|
+
expect(footnote.getByRole("link", { name: "Terms of Service" })).toHaveAttribute("href", "/terms");
|
|
253
|
+
expect(footnote.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute("href", "/privacy");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("offers 'or' + Google under the open card's form, with the consent checkboxes kept (#9662; #8438 D13)", async () => {
|
|
257
|
+
mockIsSignUpOpen.mockReturnValue(true);
|
|
258
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
259
|
+
await renderPage(SignUpPage);
|
|
260
|
+
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
|
261
|
+
expect(screen.getByTestId("auth-divider")).toHaveTextContent(/^or$/);
|
|
262
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
263
|
+
expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
|
|
264
|
+
});
|
|
226
265
|
});
|
|
227
266
|
|
|
228
267
|
describe("/forgot-password and /reset-password follow the reset predicate", () => {
|
|
@@ -266,9 +305,9 @@ describe("/forgot-password and /reset-password follow the reset predicate", () =
|
|
|
266
305
|
|
|
267
306
|
// ── Every non-form page routes back to sign-in ───────────────────────────────
|
|
268
307
|
|
|
308
|
+
// A closed /sign-up routes back through its toggle line instead (#9662, above).
|
|
269
309
|
describe("the copy-only intents route back to sign-in", () => {
|
|
270
310
|
it.each([
|
|
271
|
-
["sign-up (closed)", SignUpPage],
|
|
272
311
|
["forgot-password (unavailable)", ForgotPasswordPage],
|
|
273
312
|
["reset-password (unavailable)", ResetPasswordPage],
|
|
274
313
|
["verify-email", VerifyEmailPage],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acknowledgement at the foot of every auth card (#8790 D6) — "You
|
|
3
|
+
* acknowledge that you read, and agree to our Terms of Service and our Privacy
|
|
4
|
+
* Policy." (#9662) — linking the site's own policy routes through the inline
|
|
5
|
+
* link variant, so the card never hardcodes a path.
|
|
6
|
+
*
|
|
7
|
+
* One builder for `/sign-in`, `/sign-up` and the `(authed)` gate.
|
|
8
|
+
* `caption` is the muted footnote role; a default `Text` would re-impose body
|
|
9
|
+
* size and the foreground colour over the card's footnote wrapper.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { localeHref } from "@working-theory/ui";
|
|
13
|
+
import { Link, Text } from "@working-theory/ui/primitives";
|
|
14
|
+
import type { ReactElement } from "react";
|
|
15
|
+
|
|
16
|
+
import type { SignInCardText } from "./sign-in/sign-in-text";
|
|
17
|
+
|
|
18
|
+
export function legalFootnote(
|
|
19
|
+
locale: string,
|
|
20
|
+
text: SignInCardText["acknowledgement"],
|
|
21
|
+
): ReactElement {
|
|
22
|
+
return (
|
|
23
|
+
<Text as="p" variant="caption">
|
|
24
|
+
{text.lead}{" "}
|
|
25
|
+
<Link variant="inline" href={localeHref(locale, "/terms")}>
|
|
26
|
+
{text.terms}
|
|
27
|
+
</Link>{" "}
|
|
28
|
+
{text.and}{" "}
|
|
29
|
+
<Link variant="inline" href={localeHref(locale, "/privacy")}>
|
|
30
|
+
{text.privacy}
|
|
31
|
+
</Link>
|
|
32
|
+
{text.end}
|
|
33
|
+
</Text>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -24,11 +24,16 @@
|
|
|
24
24
|
* (i) the hinted email (#8757 D9): a valid single `?email=` is the starting
|
|
25
25
|
* value of every email input of the card; an invalid or repeated value,
|
|
26
26
|
* or none, leaves them empty as before. It signs no one in.
|
|
27
|
+
* (j) the one card (#9662): the site's logo and "Sign in to <site>" from
|
|
28
|
+
* the deployment's own data — a crafted query parameter cannot change
|
|
29
|
+
* the name — the "Don't have an account? Sign up" line linking
|
|
30
|
+
* /sign-up, and the Terms + Privacy acknowledgement.
|
|
27
31
|
*
|
|
28
|
-
* `next-intl/server` is mocked
|
|
29
|
-
* the
|
|
32
|
+
* `next-intl/server` is mocked here against the real en.json, with `{name}`
|
|
33
|
+
* placeholders filled from the values passed, so `signIn` copy is asserted
|
|
34
|
+
* by its real strings and the heading by the name actually interpolated.
|
|
30
35
|
*/
|
|
31
|
-
import { render, screen } from "@testing-library/react";
|
|
36
|
+
import { render, screen, within } from "@testing-library/react";
|
|
32
37
|
import userEvent from "@testing-library/user-event";
|
|
33
38
|
|
|
34
39
|
const mockGetActiveProviderIds = jest.fn();
|
|
@@ -75,7 +80,31 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
75
80
|
__esModule: true,
|
|
76
81
|
hubSignIn: { clientInfo: (...args: unknown[]) => mockClientInfo(...args) },
|
|
77
82
|
}));
|
|
83
|
+
// The global shim (__tests__/__mocks__/next-intl-server.ts) returns messages
|
|
84
|
+
// uninterpolated; this suite fills `{name}` placeholders so the heading's
|
|
85
|
+
// organization name is the one the page actually passed (#9662).
|
|
86
|
+
jest.mock("next-intl/server", () => {
|
|
87
|
+
const messages = jest.requireActual("../../../../../messages/en.json") as Record<
|
|
88
|
+
string,
|
|
89
|
+
Record<string, string>
|
|
90
|
+
>;
|
|
91
|
+
return {
|
|
92
|
+
__esModule: true,
|
|
93
|
+
getTranslations: async ({ namespace }: { namespace: string }) => {
|
|
94
|
+
const catalog = messages[namespace];
|
|
95
|
+
const t = (key: string, values?: Record<string, string>): string =>
|
|
96
|
+
(catalog?.[key] ?? `${namespace}.${key}`).replace(
|
|
97
|
+
/\{(\w+)\}/g,
|
|
98
|
+
(match, name: string) => values?.[name] ?? match,
|
|
99
|
+
);
|
|
100
|
+
t.has = (key: string): boolean => catalog?.[key] !== undefined;
|
|
101
|
+
return t;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
78
106
|
import { mintContinuation } from "../../../../api/auth/hub/continuation";
|
|
107
|
+
import { SITE_NAME } from "../../../../../lib/site-brand";
|
|
79
108
|
import en from "../../../../../messages/en.json";
|
|
80
109
|
import Page, { generateMetadata } from "../page";
|
|
81
110
|
|
|
@@ -119,7 +148,9 @@ describe("/sign-in page", () => {
|
|
|
119
148
|
expect(screen.queryByTestId("auth-submit")).toBeNull();
|
|
120
149
|
expect(screen.queryByTestId("auth-footer-link")).toBeNull();
|
|
121
150
|
expect(screen.queryByText(/create an account/i)).toBeNull();
|
|
122
|
-
expect(
|
|
151
|
+
expect(
|
|
152
|
+
screen.getByRole("heading", { name: `Sign in to ${SITE_NAME}` }),
|
|
153
|
+
).toBeInTheDocument();
|
|
123
154
|
});
|
|
124
155
|
|
|
125
156
|
it("(b) email + OAuth: password body with both social buttons, no sign-up footer", async () => {
|
|
@@ -241,7 +272,7 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
|
|
|
241
272
|
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
242
273
|
en.signIn.emailLinkSubmit,
|
|
243
274
|
);
|
|
244
|
-
expect(en.signIn.emailLinkSubmit).toBe("
|
|
275
|
+
expect(en.signIn.emailLinkSubmit).toBe("Send me a magic link");
|
|
245
276
|
// One "or" divider between the primary and Google (#8790 D5).
|
|
246
277
|
expect(screen.getAllByTestId("auth-divider")).toHaveLength(1);
|
|
247
278
|
expect(screen.queryByTestId("auth-email-link-divider")).toBeNull();
|
|
@@ -252,9 +283,11 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
|
|
|
252
283
|
await renderPage();
|
|
253
284
|
expect(screen.getByTestId("auth-email")).toBeInTheDocument();
|
|
254
285
|
expect(screen.queryByText(en.signIn.notConfiguredSubhead)).toBeNull();
|
|
255
|
-
//
|
|
256
|
-
expect(
|
|
257
|
-
|
|
286
|
+
// The heading stands alone — no subhead (#8790 D3); #9662 names the site.
|
|
287
|
+
expect(
|
|
288
|
+
screen.getByRole("heading", { name: `Sign in to ${SITE_NAME}` }),
|
|
289
|
+
).toBeInTheDocument();
|
|
290
|
+
expect(en.signIn.heading).toBe("Sign in to {site}");
|
|
258
291
|
expect(screen.queryByTestId("auth-method-switch")).toBeNull();
|
|
259
292
|
});
|
|
260
293
|
|
|
@@ -415,3 +448,79 @@ describe("/sign-in page — the hinted email (#8757 D9)", () => {
|
|
|
415
448
|
},
|
|
416
449
|
);
|
|
417
450
|
});
|
|
451
|
+
|
|
452
|
+
describe("/sign-in page — the one card (#9662)", () => {
|
|
453
|
+
beforeEach(() => {
|
|
454
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("(j) renders the site's logo and 'Sign in to <site>' from the deployment's data", async () => {
|
|
458
|
+
await renderPage("/account");
|
|
459
|
+
expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
|
|
460
|
+
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
|
461
|
+
`Sign in to ${SITE_NAME}`,
|
|
462
|
+
);
|
|
463
|
+
expect(screen.getByLabelText(en.signIn.email)).toBe(
|
|
464
|
+
screen.getByTestId("auth-email"),
|
|
465
|
+
);
|
|
466
|
+
expect(en.signIn.email).toBe("Email");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("(j) a crafted query parameter cannot change the organization name", async () => {
|
|
470
|
+
const crafted = "Evil Corp";
|
|
471
|
+
expect(SITE_NAME).not.toBe(crafted);
|
|
472
|
+
const el = await Page({
|
|
473
|
+
params: Promise.resolve({ locale: "en" }),
|
|
474
|
+
searchParams: Promise.resolve({
|
|
475
|
+
next: `/account?site=${encodeURIComponent(crafted)}`,
|
|
476
|
+
site: crafted,
|
|
477
|
+
organization: crafted,
|
|
478
|
+
name: crafted,
|
|
479
|
+
siteName: crafted,
|
|
480
|
+
}),
|
|
481
|
+
});
|
|
482
|
+
render(el);
|
|
483
|
+
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
|
484
|
+
`Sign in to ${SITE_NAME}`,
|
|
485
|
+
);
|
|
486
|
+
expect(document.body.textContent).not.toContain(crafted);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("(j) the prospect line toggles to /sign-up, with no trailing period", async () => {
|
|
490
|
+
await renderPage("/account");
|
|
491
|
+
expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
|
|
492
|
+
/^Don't have an account\? Sign up$/,
|
|
493
|
+
);
|
|
494
|
+
expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
|
|
495
|
+
"href",
|
|
496
|
+
"/sign-up",
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("(j) 'Send me a magic link' first, the in-place password switch, then 'or' and Google", async () => {
|
|
501
|
+
await renderPage("/account");
|
|
502
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
503
|
+
"Send me a magic link",
|
|
504
|
+
);
|
|
505
|
+
expect(screen.getByTestId("auth-method-switch")).toHaveTextContent(
|
|
506
|
+
"Sign in using password",
|
|
507
|
+
);
|
|
508
|
+
expect(screen.getByTestId("auth-divider")).toHaveTextContent(/^or$/);
|
|
509
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("(j) ends on the Terms of Service + Privacy Policy acknowledgement, linking the site's own routes", async () => {
|
|
513
|
+
await renderPage("/account");
|
|
514
|
+
const footnote = screen.getByTestId("auth-card-footnote");
|
|
515
|
+
expect(footnote).toHaveTextContent(
|
|
516
|
+
/^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
|
|
517
|
+
);
|
|
518
|
+
expect(
|
|
519
|
+
within(footnote).getByRole("link", { name: "Terms of Service" }),
|
|
520
|
+
).toHaveAttribute("href", "/terms");
|
|
521
|
+
expect(
|
|
522
|
+
within(footnote).getByRole("link", { name: "Privacy Policy" }),
|
|
523
|
+
).toHaveAttribute("href", "/privacy");
|
|
524
|
+
expect(within(footnote).getAllByRole("link")).toHaveLength(2);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
@@ -18,14 +18,19 @@
|
|
|
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.
|
|
24
28
|
*
|
|
25
29
|
* Signed-in visitors are not redirected here — the Nav's session-aware action
|
|
26
30
|
* cluster (Account / Sign out) is the affordance for them.
|
|
27
31
|
*/
|
|
28
32
|
|
|
33
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
29
34
|
import { localeHref } from "@working-theory/ui";
|
|
30
35
|
import { accountEmailHintSchema } from "@working-theory/validation";
|
|
31
36
|
import type { Metadata } from "next";
|
|
@@ -36,14 +41,13 @@ import {
|
|
|
36
41
|
getActiveProviderIds,
|
|
37
42
|
resetSendConfigured,
|
|
38
43
|
} from "../../../../lib/auth-env";
|
|
39
|
-
|
|
40
|
-
import { hubSignIn } from "@working-theory/api-client";
|
|
41
|
-
import { Link, Text } from "@working-theory/ui/primitives";
|
|
42
44
|
import { siteLogo } from "../../../../lib/site-logo";
|
|
43
45
|
import {
|
|
44
46
|
CONTINUE_PATH,
|
|
45
47
|
openContinuation,
|
|
46
48
|
} from "../../../api/auth/hub/continuation";
|
|
49
|
+
import { legalFootnote } from "../legal-footnote";
|
|
50
|
+
|
|
47
51
|
import { resolveNextPath } from "./next-path";
|
|
48
52
|
import { SignInCard, type SignInProvider } from "./sign-in-card";
|
|
49
53
|
import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
|
|
@@ -151,27 +155,7 @@ export default async function SignInPage({
|
|
|
151
155
|
const defaultEmail = hintedEmail(rawEmail);
|
|
152
156
|
|
|
153
157
|
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
|
-
);
|
|
158
|
+
const t = await getTranslations({ locale, namespace: "signIn" });
|
|
175
159
|
return (
|
|
176
160
|
<SignInCard
|
|
177
161
|
logo={siteLogo()}
|
|
@@ -179,19 +163,12 @@ export default async function SignInPage({
|
|
|
179
163
|
? {
|
|
180
164
|
backLink: {
|
|
181
165
|
href: `${spoke.origin}/account`,
|
|
182
|
-
label:
|
|
183
|
-
},
|
|
184
|
-
}
|
|
185
|
-
: {})}
|
|
186
|
-
{...(text.prospect
|
|
187
|
-
? {
|
|
188
|
-
prospect: {
|
|
189
|
-
...text.prospect,
|
|
190
|
-
href: localeHref(locale, "/sign-up"),
|
|
166
|
+
label: t("backTo", { site: spoke.name }),
|
|
191
167
|
},
|
|
192
168
|
}
|
|
193
169
|
: {})}
|
|
194
|
-
|
|
170
|
+
prospect={{ ...text.prospect, href: localeHref(locale, "/sign-up") }}
|
|
171
|
+
footnote={legalFootnote(locale, text.acknowledgement)}
|
|
195
172
|
providers={providers}
|
|
196
173
|
method={method}
|
|
197
174
|
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,108 @@
|
|
|
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
|
+
* `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
|
|
10
|
+
* the real en.json.
|
|
11
|
+
*/
|
|
12
|
+
import { render, screen } from "@testing-library/react";
|
|
13
|
+
|
|
14
|
+
const mockGetActiveProviderIds = jest.fn();
|
|
15
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
|
|
16
|
+
jest.mock("../../../../../lib/auth-env", () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
getActiveProviderIds: () => mockGetActiveProviderIds(),
|
|
19
|
+
isSignUpOpen: () => true,
|
|
20
|
+
isResetAvailable: () => false,
|
|
21
|
+
resetSendConfigured: () => false,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock("better-auth/react", () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
createAuthClient: () => ({
|
|
27
|
+
signIn: { social: jest.fn(), email: jest.fn() },
|
|
28
|
+
signUp: { email: jest.fn() },
|
|
29
|
+
$fetch: jest.fn(),
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import en from "../../../../../messages/en.json";
|
|
34
|
+
import SignUpPage from "../page";
|
|
35
|
+
|
|
36
|
+
async function renderSignUp(
|
|
37
|
+
searchParams: Record<string, string | string[]> = {},
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
render(
|
|
40
|
+
await SignUpPage({
|
|
41
|
+
params: Promise.resolve({ locale: "en" }),
|
|
42
|
+
searchParams: Promise.resolve(searchParams),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
mockGetActiveProviderIds.mockReset();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("the method (D6)", () => {
|
|
52
|
+
it('with "magic-link" active: one Email field, Send me a magic link, no password, the agreements, "or" + Google', async () => {
|
|
53
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
|
|
54
|
+
await renderSignUp();
|
|
55
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
56
|
+
en.auth.magicLinkSubmit,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.queryByTestId("auth-password")).toBeNull();
|
|
59
|
+
expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
|
|
60
|
+
expect(screen.getByTestId("auth-divider")).toHaveTextContent(
|
|
61
|
+
en.auth.orContinue,
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('with only ["google", "email"]: the password body', async () => {
|
|
67
|
+
mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
|
|
68
|
+
await renderSignUp();
|
|
69
|
+
expect(screen.getByTestId("auth-password")).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByTestId("auth-submit")).toHaveTextContent(
|
|
71
|
+
en.auth.signUpSubmit,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("the failed-link notice (D6)", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
mockGetActiveProviderIds.mockReturnValue(["magic-link"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it.each([
|
|
82
|
+
["INVALID_TOKEN", en.auth.signUpLinkExpired],
|
|
83
|
+
["new_user_signup_disabled", en.auth.signUpLinkExpired],
|
|
84
|
+
["AGREEMENTS_REQUIRED", en.auth.signUpAgreementsRequired],
|
|
85
|
+
["AGREEMENTS_UNAVAILABLE", en.auth.signUpFailed],
|
|
86
|
+
])("?error=%s renders its copy", async (error, copy) => {
|
|
87
|
+
await renderSignUp({ error });
|
|
88
|
+
expect(screen.getByTestId("sign-up-link-notice")).toHaveTextContent(copy);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("a repeated error reads its first value, and the raw value is never echoed", async () => {
|
|
92
|
+
await renderSignUp({ error: ["INVALID_TOKEN", "<b>x</b>"] });
|
|
93
|
+
const notice = screen.getByTestId("sign-up-link-notice");
|
|
94
|
+
expect(notice).toHaveTextContent(en.auth.signUpLinkExpired);
|
|
95
|
+
expect(notice).not.toHaveTextContent("INVALID_TOKEN");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("no error, no notice", async () => {
|
|
99
|
+
await renderSignUp();
|
|
100
|
+
expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("the password method shows no link notice", async () => {
|
|
104
|
+
mockGetActiveProviderIds.mockReturnValue(["email"]);
|
|
105
|
+
await renderSignUp({ error: "INVALID_TOKEN" });
|
|
106
|
+
expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|