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
|
@@ -20,18 +20,23 @@
|
|
|
20
20
|
* current pathname (via `usePathname()`) so a successful social sign-in
|
|
21
21
|
* lands back on the exact dashboard page the visitor requested.
|
|
22
22
|
*
|
|
23
|
-
* Email sign-in link (#7435 § D9): when the layout passes `
|
|
24
|
-
* gate renders `/sign-in`'s own `SignInCard` — the same
|
|
25
|
-
* me a
|
|
26
|
-
* has no submit wiring and would otherwise sit inert
|
|
27
|
-
* Without the prop every render is what it was before.
|
|
23
|
+
* Email sign-in link (#7435 § D9): when the layout passes `card` with
|
|
24
|
+
* `emailLink`, the gate renders `/sign-in`'s own `SignInCard` — the same
|
|
25
|
+
* providers plus "Send me a magic link" — in place of `AuthPanel`, whose
|
|
26
|
+
* email + password form has no submit wiring and would otherwise sit inert
|
|
27
|
+
* beside the new field. Without the prop every render is what it was before.
|
|
28
|
+
*
|
|
29
|
+
* The one card (#9662): on the member `/account` gate the layout passes
|
|
30
|
+
* `card` whenever a method is on, with the site's mark, the "Don't have an
|
|
31
|
+
* account? Sign up" line and the Terms + Privacy acknowledgement, so the gate
|
|
32
|
+
* renders the same card `/sign-in` does.
|
|
28
33
|
*/
|
|
29
34
|
|
|
30
35
|
import { EmptyState, Link } from "@working-theory/ui/primitives";
|
|
31
36
|
import { AuthPanel, type AuthPanelProvider } from "@working-theory/ui/sections";
|
|
32
37
|
import { createAuthClient } from "better-auth/react";
|
|
33
38
|
import { usePathname } from "next/navigation";
|
|
34
|
-
import type { ReactElement } from "react";
|
|
39
|
+
import type { ReactElement, ReactNode } from "react";
|
|
35
40
|
|
|
36
41
|
import { SignInCard } from "../(auth)/sign-in/sign-in-card";
|
|
37
42
|
import type { SignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
@@ -52,17 +57,25 @@ export interface SignInGateProps {
|
|
|
52
57
|
/** The active social providers, resolved server-side by the layout (#7419). */
|
|
53
58
|
providers: AuthPanelProvider[];
|
|
54
59
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
60
|
+
* Renders `SignInCard` in place of `AuthPanel` — present when the email
|
|
61
|
+
* sign-in link is on (#7435) or on the member gate (#9662). The card's copy,
|
|
62
|
+
* mark, prospect line and footnote are resolved server-side by the layout.
|
|
57
63
|
*/
|
|
58
|
-
|
|
64
|
+
card?: {
|
|
65
|
+
text: SignInCardText;
|
|
66
|
+
/** Offer "Send me a magic link" — the email sign-in link is on. */
|
|
67
|
+
emailLink: boolean;
|
|
68
|
+
logo?: ReactNode;
|
|
69
|
+
prospect?: { prompt: string; linkLabel: string; href: string };
|
|
70
|
+
footnote?: ReactNode;
|
|
71
|
+
};
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
export function SignInGate({
|
|
62
75
|
labels,
|
|
63
76
|
signInPath,
|
|
64
77
|
providers,
|
|
65
|
-
|
|
78
|
+
card,
|
|
66
79
|
}: SignInGateProps): ReactElement {
|
|
67
80
|
const pathname = usePathname();
|
|
68
81
|
const signInHref = `${signInPath}?next=${encodeURIComponent(pathname)}`;
|
|
@@ -79,25 +92,35 @@ export function SignInGate({
|
|
|
79
92
|
</Link>
|
|
80
93
|
);
|
|
81
94
|
|
|
82
|
-
if (
|
|
83
|
-
const { text } =
|
|
95
|
+
if (card) {
|
|
96
|
+
const { text } = card;
|
|
84
97
|
return (
|
|
85
98
|
<div
|
|
86
99
|
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section"
|
|
87
100
|
data-testid="sign-in-gate"
|
|
88
101
|
>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
{/* AuthShell's 400px column: the cap sits on the wrapper, never on the card (AuthShell.tsx). */}
|
|
103
|
+
<div className="w-full max-w-100">
|
|
104
|
+
<SignInCard
|
|
105
|
+
providers={providers}
|
|
106
|
+
method="sso"
|
|
107
|
+
next={pathname}
|
|
108
|
+
labels={text.labels}
|
|
109
|
+
notConfiguredSubhead={text.notConfiguredSubhead}
|
|
110
|
+
errorLabels={text.errorLabels}
|
|
111
|
+
emailLink={card.emailLink}
|
|
112
|
+
errorCallbackURL={signInHref}
|
|
113
|
+
signInHref={signInHref}
|
|
114
|
+
emailLinkLabels={text.emailLinkLabels}
|
|
115
|
+
{...(card.logo !== undefined ? { logo: card.logo } : {})}
|
|
116
|
+
{...(card.prospect !== undefined
|
|
117
|
+
? { prospect: card.prospect }
|
|
118
|
+
: {})}
|
|
119
|
+
{...(card.footnote !== undefined
|
|
120
|
+
? { footnote: card.footnote }
|
|
121
|
+
: {})}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
101
124
|
{signInLink}
|
|
102
125
|
</div>
|
|
103
126
|
);
|
|
@@ -11,9 +11,11 @@ import { readLocales } from "@working-theory/web-i18n";
|
|
|
11
11
|
import { getTranslations } from "next-intl/server";
|
|
12
12
|
import type { ReactElement } from "react";
|
|
13
13
|
|
|
14
|
+
import { legalFootnote } from "../(auth)/legal-footnote";
|
|
14
15
|
import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
15
16
|
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
16
17
|
import { isAccountPath } from "../../../lib/auth-routes";
|
|
18
|
+
import { siteLogo } from "../../../lib/site-logo";
|
|
17
19
|
|
|
18
20
|
import { SignInGate } from "./sign-in-gate";
|
|
19
21
|
|
|
@@ -39,9 +41,7 @@ export async function SignedOutGate({
|
|
|
39
41
|
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
40
42
|
});
|
|
41
43
|
const active = getActiveProviderIds();
|
|
42
|
-
const emailLink = active.includes("magic-link")
|
|
43
|
-
? { text: await resolveSignInCardText(locale) }
|
|
44
|
-
: undefined;
|
|
44
|
+
const emailLink = active.includes("magic-link");
|
|
45
45
|
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
46
46
|
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
47
47
|
);
|
|
@@ -75,6 +75,25 @@ export async function SignedOutGate({
|
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// The sign-in card: with the email link on, and on the member gate, which
|
|
79
|
+
// also wears the one card's frame — the mark, the toggle line to /sign-up
|
|
80
|
+
// and the Terms + Privacy acknowledgement (#9662).
|
|
81
|
+
const text =
|
|
82
|
+
emailLink || isAccount ? await resolveSignInCardText(locale) : undefined;
|
|
83
|
+
const card = text
|
|
84
|
+
? {
|
|
85
|
+
text,
|
|
86
|
+
emailLink,
|
|
87
|
+
...(isAccount
|
|
88
|
+
? {
|
|
89
|
+
logo: siteLogo(),
|
|
90
|
+
prospect: { ...text.prospect, href: localeHref(locale, "/sign-up") },
|
|
91
|
+
footnote: legalFootnote(locale, text.acknowledgement),
|
|
92
|
+
}
|
|
93
|
+
: {}),
|
|
94
|
+
}
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
78
97
|
return (
|
|
79
98
|
<SignInGate
|
|
80
99
|
labels={{
|
|
@@ -84,7 +103,7 @@ export async function SignedOutGate({
|
|
|
84
103
|
}}
|
|
85
104
|
signInPath={signInPath}
|
|
86
105
|
providers={providers}
|
|
87
|
-
{...(
|
|
106
|
+
{...(card ? { card } : {})}
|
|
88
107
|
/>
|
|
89
108
|
);
|
|
90
109
|
}
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
getStealthRobots,
|
|
49
49
|
readLocales,
|
|
50
50
|
} from "@working-theory/web-i18n";
|
|
51
|
+
import { BotIdClient } from "botid/client";
|
|
51
52
|
import type { Metadata, Viewport } from "next";
|
|
52
53
|
import { cookies, headers } from "next/headers";
|
|
53
54
|
import { NextIntlClientProvider } from "next-intl";
|
|
@@ -77,6 +78,7 @@ import { SCHEDULING_PAGES } from "../../lib/modules.generated";
|
|
|
77
78
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
78
79
|
import { getOgImageUrl } from "../../lib/og";
|
|
79
80
|
import { backLinkHref } from "../../lib/profile-back-link";
|
|
81
|
+
import { BOT_CHECKED_ROUTES, bookingBotCheckOn } from "../../lib/relay";
|
|
80
82
|
import {
|
|
81
83
|
COPYRIGHT_HOLDER,
|
|
82
84
|
SITE_DESCRIPTION,
|
|
@@ -704,6 +706,19 @@ export default async function LocaleLayout({
|
|
|
704
706
|
dangerouslySetInnerHTML={{ __html: FLASH_PREVENTION_SCRIPT }}
|
|
705
707
|
/>
|
|
706
708
|
)}
|
|
709
|
+
{/* #9889 D5 — BotID's browser half: one inline script, nothing
|
|
710
|
+
visible, protecting exactly the pairs the relay checks. Mounted
|
|
711
|
+
only where the server check runs, so a site with it off is
|
|
712
|
+
byte-identical and one off Vercel never loads a challenge it
|
|
713
|
+
cannot complete. */}
|
|
714
|
+
{bookingBotCheckOn() ? (
|
|
715
|
+
<BotIdClient
|
|
716
|
+
protect={BOT_CHECKED_ROUTES.map((r) => ({
|
|
717
|
+
path: r.path,
|
|
718
|
+
method: r.method,
|
|
719
|
+
}))}
|
|
720
|
+
/>
|
|
721
|
+
) : null}
|
|
707
722
|
{/* Inline CSS-variable declarations for the active theme mode */}
|
|
708
723
|
<style
|
|
709
724
|
data-ailk="theme-vars"
|
|
@@ -952,7 +967,9 @@ export default async function LocaleLayout({
|
|
|
952
967
|
* cookie read — same #5557 hydration-safety property, no gate
|
|
953
968
|
* needed. This also means the banner never blocks the page: it
|
|
954
969
|
* reserves its own space (D1) and carries a close-without-choosing
|
|
955
|
-
* control (D2) instead of a hard server-side hide.
|
|
970
|
+
* control (D2) instead of a hard server-side hide. The standard
|
|
971
|
+
* chrome's compact card has no close control (#9656 D5): it is a
|
|
972
|
+
* non-modal corner card and Decline All is one tap.
|
|
956
973
|
*/}
|
|
957
974
|
{isAccountRoute ? null : (
|
|
958
975
|
<ConsentBanner
|
|
@@ -961,18 +978,36 @@ export default async function LocaleLayout({
|
|
|
961
978
|
consentCookieName={CONSENT_COOKIE_NAME}
|
|
962
979
|
consentDecided={consentDecided}
|
|
963
980
|
title={tConsent("title")}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
981
|
+
// #9656 D7 — the standard chrome mounts the compact floating
|
|
982
|
+
// card on every width, its Privacy Policy link on the button
|
|
983
|
+
// row; the minimal chrome keeps its hairline band (#7742 D5),
|
|
984
|
+
// the statement with the link inline.
|
|
985
|
+
{...(isMinimalChrome
|
|
986
|
+
? {
|
|
987
|
+
message: (
|
|
988
|
+
<>
|
|
989
|
+
{consentBannerStatement}{" "}
|
|
990
|
+
<Link
|
|
991
|
+
href={localeHref(locale, "/privacy")}
|
|
992
|
+
variant="unstyled"
|
|
993
|
+
className="font-semibold text-current underline underline-offset-4"
|
|
994
|
+
>
|
|
995
|
+
{tConsent("policyLinkLabel")}
|
|
996
|
+
</Link>
|
|
997
|
+
</>
|
|
998
|
+
),
|
|
999
|
+
}
|
|
1000
|
+
: {
|
|
1001
|
+
message: consentBannerStatement,
|
|
1002
|
+
presentation: "floating" as const,
|
|
1003
|
+
corner: "bottom-left" as const,
|
|
1004
|
+
consentAppearance: "compact" as const,
|
|
1005
|
+
mobileConsent: "inherit" as const,
|
|
1006
|
+
consentPolicyLink: {
|
|
1007
|
+
label: tConsent("policyLinkLabel"),
|
|
1008
|
+
href: "/privacy",
|
|
1009
|
+
},
|
|
1010
|
+
})}
|
|
976
1011
|
dismissible={false}
|
|
977
1012
|
dismissLabel={tConsent("dismissLabel")}
|
|
978
1013
|
acceptLabel={tConsent("acceptLabel")}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
"use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* BookingHandOff — `/meet/confirmed`'s booked time and Slack next step
|
|
6
|
+
* (#9853 D10). The booker writes one `sessionStorage` item before it
|
|
7
|
+
* navigates here (`BOOKING_CONFIRMATION_STORAGE_KEY`); this reads it once and
|
|
8
|
+
* removes it, the `/thanks` page's `AccountInviteLink` pattern, so the page's
|
|
9
|
+
* address carries nothing about the booking and a later visit shows nothing.
|
|
10
|
+
*
|
|
11
|
+
* It renders only an item written for this page (`to`'s path, without its
|
|
12
|
+
* query or hash, is `/meet/confirmed` or `/<locale>/meet/confirmed`) in the last ten
|
|
13
|
+
* minutes. The time is formatted in the zone Cal.com recorded, with the zone
|
|
14
|
+
* named, so the page and the Slack welcome name the same one; a zone `Intl`
|
|
15
|
+
* does not know renders nothing. Every string is a prop the server page read
|
|
16
|
+
* from the `schedule` namespace.
|
|
17
|
+
*/
|
|
18
|
+
import { Container, Heading, Text } from "@working-theory/ui";
|
|
19
|
+
import {
|
|
20
|
+
BOOKING_CONFIRMATION_STORAGE_KEY,
|
|
21
|
+
BookingConfirmationHandOffSchema,
|
|
22
|
+
} from "@working-theory/validation";
|
|
23
|
+
import type { ReactElement } from "react";
|
|
24
|
+
import { useEffect, useState } from "react";
|
|
25
|
+
|
|
26
|
+
/** A reserved name that never resolves: only used to read the path of `to`. */
|
|
27
|
+
const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
|
|
28
|
+
const CONFIRMED_PATH = "/meet/confirmed";
|
|
29
|
+
const MAX_AGE_MS = 10 * 60_000;
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
locale: string;
|
|
33
|
+
/** `schedule.confirmed.bookedTimeHeading`. */
|
|
34
|
+
bookedTimeHeading: string;
|
|
35
|
+
/** `schedule.confirmed.slackHeadline`. */
|
|
36
|
+
slackHeadline: string;
|
|
37
|
+
/** `schedule.confirmed.slackBody`. */
|
|
38
|
+
slackBody: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Shown {
|
|
42
|
+
time: string;
|
|
43
|
+
slackOptIn: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `to` names this page, with or without this page's `/<locale>`. */
|
|
47
|
+
function namesThisPage(to: string, locale: string): boolean {
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(to, PLACEHOLDER_ORIGIN);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (url.origin !== PLACEHOLDER_ORIGIN) return false;
|
|
55
|
+
return (
|
|
56
|
+
url.pathname === CONFIRMED_PATH ||
|
|
57
|
+
url.pathname === `/${locale}${CONFIRMED_PATH}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read the hand-off once, remove it, and answer what to show (or `null`). */
|
|
62
|
+
function takeHandOff(locale: string): Shown | null {
|
|
63
|
+
try {
|
|
64
|
+
const stored = window.sessionStorage.getItem(
|
|
65
|
+
BOOKING_CONFIRMATION_STORAGE_KEY,
|
|
66
|
+
);
|
|
67
|
+
if (stored === null) return null;
|
|
68
|
+
window.sessionStorage.removeItem(BOOKING_CONFIRMATION_STORAGE_KEY);
|
|
69
|
+
const parsed = BookingConfirmationHandOffSchema.safeParse(
|
|
70
|
+
JSON.parse(stored),
|
|
71
|
+
);
|
|
72
|
+
if (!parsed.success) return null;
|
|
73
|
+
const { to, savedAt, start, timeZone, slackOptIn } = parsed.data;
|
|
74
|
+
const age = Date.now() - savedAt;
|
|
75
|
+
if (!namesThisPage(to, locale) || age < 0 || age >= MAX_AGE_MS) return null;
|
|
76
|
+
const time = new Intl.DateTimeFormat(locale, {
|
|
77
|
+
weekday: "long",
|
|
78
|
+
month: "long",
|
|
79
|
+
day: "numeric",
|
|
80
|
+
hour: "numeric",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
timeZone,
|
|
83
|
+
timeZoneName: "longGeneric",
|
|
84
|
+
}).format(new Date(start));
|
|
85
|
+
return { time, slackOptIn };
|
|
86
|
+
} catch {
|
|
87
|
+
// Unparseable JSON, refused storage, or a zone `Intl` does not know.
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function BookingHandOff({
|
|
93
|
+
locale,
|
|
94
|
+
bookedTimeHeading,
|
|
95
|
+
slackHeadline,
|
|
96
|
+
slackBody,
|
|
97
|
+
}: Props): ReactElement | null {
|
|
98
|
+
const [shown, setShown] = useState<Shown | null>(null);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
// Only ever sets: React's development double-mount runs this twice, and
|
|
102
|
+
// the second run finds the item already removed.
|
|
103
|
+
const found = takeHandOff(locale);
|
|
104
|
+
if (found !== null) setShown(found);
|
|
105
|
+
}, [locale]);
|
|
106
|
+
|
|
107
|
+
if (shown === null) return null;
|
|
108
|
+
return (
|
|
109
|
+
<Container className="pt-block-loose">
|
|
110
|
+
<div role="status" data-testid="booking-hand-off">
|
|
111
|
+
<Heading level={2} visual={4}>
|
|
112
|
+
{bookedTimeHeading}
|
|
113
|
+
</Heading>
|
|
114
|
+
<Text className="mt-2">{shown.time}</Text>
|
|
115
|
+
{shown.slackOptIn && (
|
|
116
|
+
<>
|
|
117
|
+
<Heading level={2} visual={4} className="mt-block-tight">
|
|
118
|
+
{slackHeadline}
|
|
119
|
+
</Heading>
|
|
120
|
+
<Text tone="muted" className="mt-2">
|
|
121
|
+
{slackBody}
|
|
122
|
+
</Text>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</Container>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tests for `/meet/confirmed`'s booking hand-off (#9853 D9, D10; AC-7).
|
|
4
|
+
*
|
|
5
|
+
* The booker hands this page the booked time, its zone and the Slack opt-in
|
|
6
|
+
* through one `sessionStorage` item, never the address. The component reads
|
|
7
|
+
* it once and removes it, and shows it only when it was written for this page
|
|
8
|
+
* in the last ten minutes, formatted in the zone Cal.com recorded — the
|
|
9
|
+
* prospect's, not the viewer's. Anything else renders nothing.
|
|
10
|
+
*/
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { act, render, screen } from "@testing-library/react";
|
|
13
|
+
|
|
14
|
+
import { BookingHandOff } from "../BookingHandOff";
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = "ailk:booking-confirmation";
|
|
17
|
+
const PROPS = {
|
|
18
|
+
locale: "en",
|
|
19
|
+
bookedTimeHeading: "copy:confirmed.bookedTimeHeading",
|
|
20
|
+
slackHeadline: "copy:confirmed.slackHeadline",
|
|
21
|
+
slackBody: "copy:confirmed.slackBody",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function handOff(overrides: Record<string, unknown> = {}) {
|
|
25
|
+
return {
|
|
26
|
+
to: "/meet/confirmed",
|
|
27
|
+
savedAt: Date.now(),
|
|
28
|
+
start: "2026-10-30T20:00:00.000Z",
|
|
29
|
+
timeZone: "America/Denver",
|
|
30
|
+
slackOptIn: true,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function store(value: unknown): void {
|
|
36
|
+
window.sessionStorage.setItem(
|
|
37
|
+
STORAGE_KEY,
|
|
38
|
+
typeof value === "string" ? value : JSON.stringify(value),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function renderHandOff() {
|
|
43
|
+
let utils!: ReturnType<typeof render>;
|
|
44
|
+
await act(async () => {
|
|
45
|
+
utils = render(<BookingHandOff {...PROPS} />);
|
|
46
|
+
});
|
|
47
|
+
return utils;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
window.sessionStorage.clear();
|
|
52
|
+
window.history.replaceState(null, "", "/en/meet/confirmed");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("<BookingHandOff/> (#9853 AC-7)", () => {
|
|
56
|
+
it("shows the booked time in the prospect's zone, named, and the Slack step; then forgets it", async () => {
|
|
57
|
+
store(handOff());
|
|
58
|
+
const { container } = await renderHandOff();
|
|
59
|
+
|
|
60
|
+
expect(screen.getByText(PROPS.bookedTimeHeading)).toBeInTheDocument();
|
|
61
|
+
expect(container.textContent).toContain("2:00");
|
|
62
|
+
expect(container.textContent).toContain("Mountain Time");
|
|
63
|
+
expect(screen.getByText(PROPS.slackHeadline)).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText(PROPS.slackBody)).toBeInTheDocument();
|
|
65
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
66
|
+
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("accepts a destination written with a locale prefix, a query or a hash", async () => {
|
|
70
|
+
store(handOff({ to: "/en/meet/confirmed?x=1#top" }));
|
|
71
|
+
const { container } = await renderHandOff();
|
|
72
|
+
expect(container.textContent).toContain("Mountain Time");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows no Slack text when the box was not ticked", async () => {
|
|
76
|
+
store(handOff({ slackOptIn: false }));
|
|
77
|
+
const { container } = await renderHandOff();
|
|
78
|
+
expect(container.textContent).toContain("Mountain Time");
|
|
79
|
+
expect(screen.queryByText(PROPS.slackHeadline)).toBeNull();
|
|
80
|
+
expect(screen.queryByText(PROPS.slackBody)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each<[string, () => void]>([
|
|
84
|
+
["no item", () => undefined],
|
|
85
|
+
["an unparseable item", () => store("{not json")],
|
|
86
|
+
[
|
|
87
|
+
"an item with an extra email key",
|
|
88
|
+
() => store(handOff({ email: "ada@example.com" })),
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
"an item for another page",
|
|
92
|
+
() => store(handOff({ to: "/before-your-call" })),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
"an item eleven minutes old",
|
|
96
|
+
() => store(handOff({ savedAt: Date.now() - 11 * 60_000 })),
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
"a zone Intl does not know",
|
|
100
|
+
() => store(handOff({ timeZone: "Mars/Olympus" })),
|
|
101
|
+
],
|
|
102
|
+
])("renders nothing, and removes the item, for %s", async (_label, seed) => {
|
|
103
|
+
seed();
|
|
104
|
+
const { container } = await renderHandOff();
|
|
105
|
+
expect(container).toBeEmptyDOMElement();
|
|
106
|
+
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows nothing on a later visit", async () => {
|
|
110
|
+
store(handOff());
|
|
111
|
+
const first = await renderHandOff();
|
|
112
|
+
first.unmount();
|
|
113
|
+
const { container } = await renderHandOff();
|
|
114
|
+
expect(container).toBeEmptyDOMElement();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
"/fr/meet/confirmed",
|
|
119
|
+
"/anything/meet/confirmed",
|
|
120
|
+
"//evil.example/meet/confirmed",
|
|
121
|
+
])("renders nothing for a destination of %s", async (to) => {
|
|
122
|
+
store(handOff({ to }));
|
|
123
|
+
const { container } = await renderHandOff();
|
|
124
|
+
expect(container).toBeEmptyDOMElement();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* #8757 D9 (AC-6): an optional `login_hint` reaches the sign-in page as
|
|
15
15
|
* `email` on the no-session branch only; it never enters the continuation ref
|
|
16
16
|
* or the code redirect, and a repeated one is a 400 like any repeated key.
|
|
17
|
+
*
|
|
18
|
+
* #9912 D3 (AC-1): a single `prompt=create` sends a visitor with no session to
|
|
19
|
+
* `/sign-up` with the same continuation; any other value gives `/sign-in`, a
|
|
20
|
+
* session ignores it, and it never enters the ref or the code request.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
// --- Mocks set up BEFORE imports ---
|
|
@@ -459,3 +463,117 @@ describe("the login_hint (#8757 D9)", () => {
|
|
|
459
463
|
);
|
|
460
464
|
});
|
|
461
465
|
});
|
|
466
|
+
|
|
467
|
+
describe("the sign-up hint, prompt=create (#9912 D3)", () => {
|
|
468
|
+
/** The `next` value of the 303, and the ref inside it. */
|
|
469
|
+
function continuationOf(res: Response): { next: string; ref: string } {
|
|
470
|
+
const location = res.headers.get("location") ?? "";
|
|
471
|
+
const next = new URL(location, HUB).searchParams.get("next") ?? "";
|
|
472
|
+
return { next, ref: next.slice(`${CONTINUE}/`.length) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
it("no session + prompt=create: 303 to /sign-up?next=<continue path>; the ref opens to the same six-key query as without the prompt", async () => {
|
|
476
|
+
mockGetSession.mockResolvedValue(null);
|
|
477
|
+
const canonical = query();
|
|
478
|
+
|
|
479
|
+
const res = await GET(request(`${canonical}&prompt=create`));
|
|
480
|
+
const plain = await GET(request(canonical));
|
|
481
|
+
|
|
482
|
+
expect(res.status).toBe(303);
|
|
483
|
+
const location = new URL(res.headers.get("location") ?? "", HUB);
|
|
484
|
+
expect(location.pathname).toBe("/sign-up");
|
|
485
|
+
expect([...location.searchParams.keys()]).toEqual(["next"]);
|
|
486
|
+
const { next, ref } = continuationOf(res);
|
|
487
|
+
expect(res.headers.get("location")).toBe(
|
|
488
|
+
`/sign-up?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
489
|
+
);
|
|
490
|
+
// The sign-up page keeps it, as the sign-in page does.
|
|
491
|
+
expect(resolveNextPath(next, "/account")).toBe(next);
|
|
492
|
+
const opened = openContinuation(ref, mockSecret);
|
|
493
|
+
expect(opened).toBe(canonical);
|
|
494
|
+
expect(opened).toBe(
|
|
495
|
+
openContinuation(continuationOf(plain).ref, mockSecret),
|
|
496
|
+
);
|
|
497
|
+
expect(opened).not.toContain("prompt");
|
|
498
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
499
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
500
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("no session + prompt=create + a valid login_hint: /sign-up carries no email", async () => {
|
|
504
|
+
mockGetSession.mockResolvedValue(null);
|
|
505
|
+
|
|
506
|
+
const res = await GET(
|
|
507
|
+
request(`${query()}&prompt=create&login_hint=a%40b.example`),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
expect(res.status).toBe(303);
|
|
511
|
+
const location = new URL(res.headers.get("location") ?? "", HUB);
|
|
512
|
+
expect(location.pathname).toBe("/sign-up");
|
|
513
|
+
expect(location.searchParams.has("email")).toBe(false);
|
|
514
|
+
expect(res.headers.get("location")).not.toContain("b.example");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it.each([
|
|
518
|
+
["prompt=login", "login"],
|
|
519
|
+
["prompt=none", "none"],
|
|
520
|
+
["an empty prompt", ""],
|
|
521
|
+
["a cased prompt", "Create"],
|
|
522
|
+
])("no session + %s: today's /sign-in", async (_name, prompt) => {
|
|
523
|
+
mockGetSession.mockResolvedValue(null);
|
|
524
|
+
const canonical = query();
|
|
525
|
+
|
|
526
|
+
const res = await GET(
|
|
527
|
+
request(`${canonical}&prompt=${encodeURIComponent(prompt)}`),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
expect(res.status).toBe(303);
|
|
531
|
+
const { ref } = continuationOf(res);
|
|
532
|
+
expect(res.headers.get("location")).toBe(
|
|
533
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
534
|
+
);
|
|
535
|
+
expect(openContinuation(ref, mockSecret)).toBe(canonical);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it.each([
|
|
539
|
+
["no session", null],
|
|
540
|
+
["a session", { user: { id: "u1" } }],
|
|
541
|
+
])(
|
|
542
|
+
"a repeated prompt is a 400 invalid_request (%s)",
|
|
543
|
+
async (_name, session) => {
|
|
544
|
+
mockGetSession.mockResolvedValue(session);
|
|
545
|
+
|
|
546
|
+
const res = await GET(request(`${query()}&prompt=create&prompt=create`));
|
|
547
|
+
|
|
548
|
+
expect(res.status).toBe(400);
|
|
549
|
+
expect(res.headers.get("location")).toBeNull();
|
|
550
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
551
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
552
|
+
expect(mockGetSession).not.toHaveBeenCalled();
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
it("with a session: prompt=create is ignored — the same code redirect and the same API call", async () => {
|
|
557
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
558
|
+
mockCreateCode.mockResolvedValue({
|
|
559
|
+
ok: true,
|
|
560
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const withPrompt = await GET(
|
|
564
|
+
request(`${query()}&prompt=create`, {
|
|
565
|
+
cookie: "better-auth.session_token=tok",
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
const without = await GET(
|
|
569
|
+
request(query(), { cookie: "better-auth.session_token=tok" }),
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
expect(withPrompt.status).toBe(303);
|
|
573
|
+
const location = withPrompt.headers.get("location") ?? "";
|
|
574
|
+
expect(location).toBe(without.headers.get("location"));
|
|
575
|
+
expect(location).toBe(`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`);
|
|
576
|
+
expect(mockCreateCode).toHaveBeenCalledTimes(2);
|
|
577
|
+
expect(mockCreateCode.mock.calls[0]).toEqual(mockCreateCode.mock.calls[1]);
|
|
578
|
+
});
|
|
579
|
+
});
|